Harness spec 1.2.0: the library's tools fit both projects; the CLI implements it

The CLI's facts in memory, notes_search, note_manage, skills_list,
skill_view and skill_manage are `variables` now, filled with the CLI's own
values (src/tool/spec-vars.ts): every description and parameter list the CLI
sends is word for word what 1.1.0 said, except note and document ids, which
are strings on both sides (a number is still taken). Descriptions are
filled by the prompt texts' rules, parameter descriptions too. `narrow`
says what a project offers of a shared tool's parameters. Memory's changes
are one pure function (applyOps). Conformance: fill, narrow, memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-20 11:45:14 +00:00
1 parent b7fcb46446
commit 539896494f
23 files changed
+1398 -88

No files matched your search

+8 -1
View File
@@ -371,7 +371,14 @@ export function createApp(o: AppOptions): App {
background: " For a server, a watcher or anything long, set `background: true`: it returns at once with a job id; read its output with bash_output, list jobs with bash_list, stop one with bash_kill.",
},
}
const tools: Tool[] = offered.map((t) => (TOOL_SPECS[t.name]?.variables ? { ...t, description: fillDescription(t.description, vars[t.name] ?? {}) } : t))
// A tool whose values are its own (the library's, set where it is defined) is filled already;
// any other with variables is filled here, and one with none given fails rather than reach the
// model with a literal `{{name}}`.
const tools: Tool[] = offered.map((t) => {
if (!TOOL_SPECS[t.name]?.variables || (t.vars && !vars[t.name])) return t
const v = vars[t.name] ?? {}
return { ...t, description: fillDescription(t.description, v), vars: { ...t.vars, ...v } }
})
const spawn: NonNullable<EngineOptions["spawn"]> = async (name, prompt, signal, callId, opts = {}) => {
const spec = agentList.find((a) => a.name === name)
if (!spec) throw new ToolError(`There is no agent "${name}". The agents are: ${agentList.map((a) => a.name).join(", ")}.`)
+74 -9
View File
@@ -37,6 +37,7 @@ import t_view_image from "../harness/tools/view_image.json"
import t_web_fetch from "../harness/tools/web_fetch.json"
import t_web_search from "../harness/tools/web_search.json"
import t_write from "../harness/tools/write.json"
import { fillPrompt } from "./prompt/fill.ts"
export interface ToolSpecDef {
name: string
@@ -57,6 +58,8 @@ export interface ToolSpecDef {
argument_aliases?: Record<string, string>
/** `{{name}}` in the description: a fact the projects differ in, filled in by each (fillDescription). */
variables?: Record<string, string>
/** What a project offers of the parameters, where it offers less than all (narrowParameters). */
narrow?: Partial<Record<"cli" | "llembas", Narrow>>
description: string
parameters: Record<string, unknown>
}
@@ -77,22 +80,84 @@ export const LOOP: {
/** The `purpose` argument (harness/purpose.json). */
export const PURPOSE_DEF: { name: string; schema: Record<string, unknown> } = PURPOSE
/** A description with its `{{variables}}` filled in. A variable left unfilled is an error: it
* would reach the model as a literal `{{name}}`. */
/** A description with its `{{variables}}` filled in, by the prompt texts' rules (fillPrompt): a
* line that is only a bullet and a variable goes when the value is empty, and a variable left
* unfilled is an error — it would reach the model as a literal `{{name}}` (conformance: fill). */
export function fillDescription(description: string, vars: Record<string, string>): string {
return description.replace(/\{\{(\w+)\}\}/g, (m, k: string) => {
if (!Object.hasOwn(vars, k)) throw new Error(`harness: no value for {{${k}}} in a tool description`)
return vars[k]!
})
try {
return fillPrompt(description, vars)
} catch (e) {
throw new Error(`harness: ${(e as Error).message.replace(/^prompt: /, "")} in a tool description`)
}
}
/** A tool's parameters with every `description` in them filled the same way. */
export function fillParameters(parameters: Record<string, unknown>, vars: Record<string, string>): Record<string, unknown> {
const walk = (o: unknown): unknown => {
if (Array.isArray(o)) return o.map(walk)
if (!o || typeof o !== "object") return o
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(o)) out[k] = k === "description" && typeof v === "string" ? fillDescription(v, vars) : walk(v)
return out
}
return walk(parameters) as Record<string, unknown>
}
/** What one project offers of a shared tool's parameters: optional properties left out (`drop`),
* a property's values narrowed (`enum`). `a.b` is b inside a, `a[].b` b inside a's items. */
export interface Narrow {
drop?: string[]
enum?: Record<string, string[]>
}
export class NarrowError extends Error {
constructor(readonly code: "required" | "unknown" | "not_subset", path: string) {
super(`harness: cannot narrow ${path}: ${code}`)
}
}
/** The parameters as a project offers them: a subset, never more and never retyped
* (conformance: narrow). The spec's own object is not changed. */
export function narrowParameters(parameters: Record<string, unknown>, narrow: Narrow = {}): Record<string, unknown> {
const out = structuredClone(parameters)
type Obj = { properties?: Record<string, Record<string, unknown>>; required?: string[]; items?: Obj }
const locate = (path: string): { parent: Obj; key: string } => {
let node = out as Obj
const steps = path.split(".")
for (const [i, raw] of steps.entries()) {
const items = raw.endsWith("[]")
const key = items ? raw.slice(0, -2) : raw
const props = node.properties
if (!props || !Object.hasOwn(props, key)) throw new NarrowError("unknown", path)
if (i === steps.length - 1 && !items) return { parent: node, key }
const next = props[key] as Obj
node = items ? (next.items as Obj) : next
if (!node) throw new NarrowError("unknown", path)
}
throw new NarrowError("unknown", path)
}
for (const path of narrow.drop ?? []) {
const { parent, key } = locate(path)
if (parent.required?.includes(key)) throw new NarrowError("required", path)
delete parent.properties![key]
}
for (const [path, values] of Object.entries(narrow.enum ?? {})) {
const { parent, key } = locate(path)
const own = parent.properties![key]!.enum as unknown[] | undefined
if (!own || !values.every((v) => own.includes(v))) throw new NarrowError("not_subset", path)
parent.properties![key]!.enum = own.filter((v) => values.includes(v as string))
}
return out
}
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
([t_apply_patch, t_ask_user, t_bash, t_bash_kill, t_bash_list, t_bash_output, t_decisions, t_edit, t_glob, t_grep, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_multiedit, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_read, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
)
/** A tool's name and description as the spec has them, for its definition: `...described("read")`. */
export function described(name: string): { name: string; description: string } {
/** A tool's name and description as the spec has them, for its definition: `...described("read")`.
* With `vars`, the description filled and the values kept for its parameters (toSpec). */
export function described(name: string, vars?: Record<string, string>): { name: string; description: string; vars?: Record<string, string> } {
const t = TOOL_SPECS[name]
if (!t) throw new Error(`harness: no tool ${name} in the spec`)
return { name: t.name, description: t.description }
return vars ? { name: t.name, description: fillDescription(t.description, vars), vars } : { name: t.name, description: t.description }
}
+59 -40
View File
@@ -20,6 +20,8 @@ const HEADERS: Record<Target, string> = { memory: "MEMORY (your personal notes)"
export interface Op {
action: "add" | "replace" | "remove"
content?: string
/** Another name for content. */
new_text?: string
old_text?: string
}
@@ -29,6 +31,59 @@ export interface Outcome {
/** On a failure the model can fix by consolidating: what is there now. */
entries?: string[]
usage: string
/** The entries after the change (applyOps): to be saved when ok. */
after?: string[]
}
const size = (entries: string[]) => entries.join(DELIMITER).length
/** How full a target is: "12% — 164/1,375 chars". */
export function usageOf(entries: string[], limit: number): string {
const n = size(entries)
return `${limit > 0 ? Math.min(100, Math.floor((n / limit) * 100)) : 0}% — ${n.toLocaleString("en")}/${limit.toLocaleString("en")} chars`
}
/** One target's entries changed by `ops`, all together or not at all; the size limit is checked
* on the result only, so one call can make room and add, and a change that does not grow the
* entries always goes through. Pure: the harness spec's conformance area `memory`, which LLeMbas
* runs through its own implementation. `check` refuses an entry's text (threats, here). */
export function applyOps(t: Target, before: string[], ops: Op[], limit: number, check?: (content: string) => string | undefined): Outcome {
const work = [...before]
const fail = (message: string, show = false): Outcome => ({ ok: false, message: ops.length > 1 ? `${message} Nothing was changed (the operations go together or not at all).` : message, usage: usageOf(before, limit), ...(show ? { entries: before } : {}) })
if (!ops.length) return fail("Nothing to do: give action (with content and/or old_text), or operations.")
for (const [i, op] of ops.entries()) {
const at = ops.length > 1 ? `operation ${i + 1}: ` : ""
const content = (op.content ?? op.new_text)?.trim() ?? ""
if (op.action !== "remove") {
if (!content) return fail(`${at}${op.action} needs content${op.action === "replace" ? " — the complete new entry" : ""}.`)
const threat = check?.(content)
if (threat) return fail(`${at}${threat}`)
if (/(^|\n)[ \t]*§[ \t]*(\n|$)/.test(content)) return fail(`${at}a line holding only "§" separates entries; it cannot be inside one.`)
if (content.length > MAX_ENTRY) return fail(`${at}an entry is one fact, under ${MAX_ENTRY} characters; this one is ${content.length}. Split it into facts, or keep only what will still matter.`)
}
if (op.action === "add") {
if (!work.includes(content)) work.push(content)
continue
}
if (!op.old_text?.trim())
return fail(`${at}${op.action} needs old_text: a short unique part of the entry to ${op.action}.${op.action === "replace" ? " content is the COMPLETE new entry; the whole matched entry is overwritten." : ""}`, true)
const hit = findEntry(work, op.old_text.trim())
if (hit.ambiguous) return fail(`${at}"${op.old_text}" matches more than one entry — use a longer, unique part.`, true)
if (hit.index === undefined) return fail(`${at}no entry contains "${op.old_text}".`, true)
if (op.action === "remove") work.splice(hit.index, 1)
else work[hit.index] = content
}
// Over the limit already (it was lowered, or the file edited)? Anything that does not grow it
// still goes through, so "forget X" always works.
if (size(work) > limit && size(work) > size(before))
return fail(
`${t === "user" ? "The user profile" : "Memory"} would be ${size(work).toLocaleString("en")}/${limit.toLocaleString("en")} chars. Consolidate in the same call: replace overlapping entries with one shorter entry, or remove stale ones, together with the add.`,
true,
)
const unique = [...new Set(work)]
const first = ops[0]!
const message = ops.length > 1 ? `Applied ${ops.length} operations.` : first.action === "add" && before.includes((first.content ?? first.new_text)!.trim()) ? "That entry already exists; nothing added." : `Entry ${first.action === "add" ? "added" : first.action === "replace" ? "replaced" : "removed"}.`
return { ok: true, message, usage: usageOf(unique, limit), after: unique }
}
export const memoryDir = () => join(paths.config, "memory")
@@ -69,12 +124,8 @@ export class MemoryStore {
return existsSync(f) ? parseEntries(readFileSync(f, "utf8")) : []
}
private size = (entries: string[]) => entries.join(DELIMITER).length
usage(t: Target, entries = this.entries(t)): string {
const n = this.size(entries)
const limit = this.limits[t]
return `${limit > 0 ? Math.min(100, Math.floor((n / limit) * 100)) : 0}% — ${n.toLocaleString("en")}/${limit.toLocaleString("en")} chars`
return usageOf(entries, this.limits[t])
}
private save(t: Target, entries: string[]) {
@@ -120,41 +171,9 @@ export class MemoryStore {
private applyNow(t: Target, ops: Op[]): Outcome {
const before = this.entries(t)
const work = [...before]
const fail = (message: string, show = false): Outcome => ({ ok: false, message: ops.length > 1 ? `${message} Nothing was changed (the operations go together or not at all).` : message, usage: this.usage(t, before), ...(show ? { entries: before } : {}) })
for (const [i, op] of ops.entries()) {
const at = ops.length > 1 ? `operation ${i + 1}: ` : ""
const content = op.content?.trim() ?? ""
if (op.action !== "remove") {
if (!content) return fail(`${at}${op.action} needs content${op.action === "replace" ? " — the complete new entry" : ""}.`)
const threat = threatMessage(content, "The entry")
if (threat) return fail(`${at}${threat}`)
if (/(^|\n)[ \t]*§[ \t]*(\n|$)/.test(content)) return fail(`${at}a line holding only "§" separates entries; it cannot be inside one.`)
if (content.length > MAX_ENTRY) return fail(`${at}an entry is one fact, under ${MAX_ENTRY} characters; this one is ${content.length}. Split it into facts, or keep only what will still matter.`)
}
if (op.action === "add") {
if (!work.includes(content)) work.push(content)
continue
}
if (!op.old_text?.trim())
return fail(`${at}${op.action} needs old_text: a short unique part of the entry to ${op.action}.${op.action === "replace" ? " content is the COMPLETE new entry; the whole matched entry is overwritten." : ""}`, true)
const hit = findEntry(work, op.old_text.trim())
if (hit.ambiguous) return fail(`${at}"${op.old_text}" matches more than one entry — use a longer, unique part.`, true)
if (hit.index === undefined) return fail(`${at}no entry contains "${op.old_text}".`, true)
if (op.action === "remove") work.splice(hit.index, 1)
else work[hit.index] = content
}
const limit = this.limits[t]
// Over the limit already (it was lowered, or the file edited)? Anything that does not grow it
// still goes through, so "forget X" always works.
if (this.size(work) > limit && this.size(work) > this.size(before))
return fail(
`${t === "user" ? "The user profile" : "Memory"} would be ${this.size(work).toLocaleString("en")}/${limit.toLocaleString("en")} chars. Consolidate in the same call: replace overlapping entries with one shorter entry, or remove stale ones, together with the add.`,
true,
)
const unique = [...new Set(work)]
if (unique.join("\u0000") !== before.join("\u0000")) this.save(t, unique)
return { ok: true, message: ops.length > 1 ? `Applied ${ops.length} operations.` : ops[0]!.action === "add" && before.includes(ops[0]!.content!.trim()) ? "That entry already exists; nothing added." : `Entry ${ops[0]!.action === "add" ? "added" : ops[0]!.action === "replace" ? "replaced" : "removed"}.`, usage: this.usage(t, unique) }
const r = applyOps(t, before, ops, this.limits[t], (content) => threatMessage(content, "The entry"))
if (r.ok && r.after!.join("\u0000") !== before.join("\u0000")) this.save(t, r.after!)
return r
}
/** The system-prompt block for one target, or "" when it is empty. */
+17 -7
View File
@@ -5,6 +5,7 @@ import { z } from "zod"
import { resolveBase, SNIPPET_CHARS } from "../library/store.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
import { CLI_VARS } from "./spec-vars.ts"
const MAX_GET = 40_000
const MAX_NOTE = 20_000
@@ -22,7 +23,16 @@ function noteScopes(ctx: ToolContext) {
}
const scopeLabel = (scope: string) => (scope === "global" ? "global" : "project")
function ownNote(ctx: ToolContext, id: number) {
/** An id as the spec sends it (a string, since 1.2.0: LLeMbas's ids are not numbers), as this
* library's number. A model that sends the number itself is taken at its word too. */
const Id = z.preprocess((v) => (typeof v === "number" ? String(v) : typeof v === "string" ? v.trim() : v), z.string())
function numeric(id: string, what: string): number {
if (!/^\d+$/.test(id)) throw new ToolError(`"${id}" is not ${what} id here: the ids are the numbers in [brackets].`)
return Number(id)
}
function ownNote(ctx: ToolContext, raw: string) {
const id = numeric(raw, "a note")
const { L, scopes } = noteScopes(ctx)
const n = L.lib.note(id)
if (!n || !scopes.includes(n.scope)) throw new ToolError(`There is no note ${id} here.`)
@@ -30,7 +40,7 @@ function ownNote(ctx: ToolContext, id: number) {
}
export const notesSearchTool = defineTool({
...described("notes_search"),
...described("notes_search", CLI_VARS.notes_search),
access: "notes",
purpose: false,
schema: z.object({
@@ -50,7 +60,7 @@ export const noteViewTool = defineTool({
access: "notes",
purpose: false,
schema: z.object({
id: z.number().int(),
id: Id,
}),
permission: (a) => ({ permission: "notes", class: "read", patterns: [String(a.id)] }),
async run(a, ctx) {
@@ -60,12 +70,12 @@ export const noteViewTool = defineTool({
})
export const noteManageTool = defineTool({
...described("note_manage"),
...described("note_manage", CLI_VARS.note_manage),
access: "notes",
purpose: false,
schema: z.object({
action: z.enum(["create", "edit", "delete"]),
id: z.number().int().optional(),
id: Id.optional(),
title: z.string().max(200).optional(),
body: z.string().optional(),
scope: z.enum(["project", "global"]).optional(),
@@ -130,11 +140,11 @@ export const knowledgeGetTool = defineTool({
access: "knowledge",
purpose: false,
requires: "knowledge",
schema: z.object({ id: z.number().int(), offset: z.number().int().min(0).optional() }),
schema: z.object({ id: Id, offset: z.number().int().min(0).optional() }),
permission: () => ({ permission: "knowledge", class: "read", patterns: ["*"] }),
async run(a, ctx) {
const L = lib(ctx)
const d = L.lib.document(a.id)
const d = L.lib.document(numeric(a.id, "a document"))
if (!d || (L.bases && !L.bases.includes(d.base))) throw new ToolError(`There is no document ${a.id} in this project's knowledge bases.`)
if (d.error && !d.text) return { output: `[${d.id}] ${d.title}: no text — ${d.error}`, title: d.title, isError: true }
const from = a.offset ?? 0
+3 -3
View File
@@ -4,6 +4,7 @@ import { z } from "zod"
import { MemoryStore, type Op } from "../memory/store.ts"
import { defineTool, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
import { CLI_VARS } from "./spec-vars.ts"
const OpSchema = z.object({
action: z.enum(["add", "replace", "remove"]),
@@ -13,7 +14,7 @@ const OpSchema = z.object({
})
export const memoryTool = defineTool({
...described("memory"),
...described("memory", CLI_VARS.memory),
access: "memory",
schema: z.object({
target: z.enum(["memory", "user", "project"]),
@@ -27,8 +28,7 @@ export const memoryTool = defineTool({
async run(a, ctx) {
if (a.target === "project" && !ctx.projectMemory) throw new ToolError("This project has no project memory: it is not trusted, or has no .agent directory. Use 'memory' for facts that hold everywhere.")
const store = a.target === "project" ? ctx.projectMemory! : (ctx.memory ?? new MemoryStore())
const norm = (o: z.infer<typeof OpSchema>): Op => ({ action: o.action, content: o.content ?? o.new_text, old_text: o.old_text })
const ops: Op[] = a.operations?.length ? a.operations.map(norm) : a.action ? [norm({ action: a.action, content: a.content, new_text: a.new_text, old_text: a.old_text })] : []
const ops: Op[] = a.operations?.length ? a.operations : a.action ? [{ action: a.action, content: a.content, new_text: a.new_text, old_text: a.old_text }] : []
if (!ops.length) throw new ToolError("Nothing to do: give action (with content and/or old_text), or operations.")
const r = store.apply(a.target === "project" ? "memory" : a.target, ops)
const label = a.target === "user" ? "user profile" : a.target === "project" ? "project memory" : "memory"
+4 -3
View File
@@ -9,11 +9,12 @@ import { within, findSkill, globalSkillsDir, INDEX_DESCRIPTION, linkedFiles, loa
import { replace } from "./replace-text.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
import { CLI_VARS } from "./spec-vars.ts"
const skillsOf = (ctx: ToolContext): Skill[] => ctx.skills?.list() ?? loadSkills()
export const skillsListTool = defineTool({
...described("skills_list"),
...described("skills_list", CLI_VARS.skills_list),
purpose: false,
access: "skills",
schema: z.object({ category: z.string().optional() }),
@@ -28,7 +29,7 @@ export const skillsListTool = defineTool({
})
export const skillViewTool = defineTool({
...described("skill_view"),
...described("skill_view", CLI_VARS.skill_view),
purpose: false,
access: "skills",
schema: z.object({
@@ -135,7 +136,7 @@ function supportPath(s: { dir: string }, rel?: string): string {
}
export const skillManageTool = defineTool({
...described("skill_manage"),
...described("skill_manage", CLI_VARS.skill_manage),
access: "skill_manage",
schema: z.object({ operations: z.array(Op).min(1).max(20) }),
permission: (a) => ({ permission: "skill_manage", class: "write", patterns: a.operations.map((o) => `${o.action} ${o.name}`) }),
+27
View File
@@ -0,0 +1,27 @@
// The CLI's values for the harness spec's `{{variables}}` in the library's tool descriptions
// (harness/tools/<name>.json `variables`): the facts of this machine's library — files, projects,
// supporting files, sessions — where LLeMbas fills in its own. Filled where each tool is defined
// (described); the per-session ones (task, bash) are filled in src/app.ts.
export const CLI_VARS: Record<string, Record<string, string>> = {
memory: {
project_home: " Anything about this project belongs in its AGENTS.md or the project's decision log, not here.",
targets: "'user' — who the user is: name, role, preferences, how they like to work. 'memory' — your own notes: environment, conventions, tool quirks, lessons.",
past_work: " (session_search finds past work)",
when_seen: "What you save now is in the files at once but appears in your system prompt only from the next session.",
target_values: "'memory' for your notes, 'user' for the user profile, 'project' for facts about this project only (kept in its .agent/local, never committed).",
},
notes_search: { kept: ", global or this project's; both are searched" },
note_manage: {
create_scope: ", scope",
scope: 'scope: "project" (default, when there is a project) or "global".',
records: ", or the project's own records (tasks, decisions)",
},
skills_list: { category: ", category" },
skill_view: { files: ", and the list of its supporting files (references, templates, scripts, assets). To read one of those, call again with file_path." },
skill_manage: {
create_where: " Global unless where: project.",
files: "write_file / remove_file: supporting files under references/, templates/, scripts/ or assets/.",
revert_deleted: " (a deleted one too)",
index_cut: " — only that much shows in the skills list",
},
}
+5 -2
View File
@@ -4,7 +4,7 @@ import { z } from "zod"
import type { PermissionRequest } from "../permission/evaluate.ts"
import { expandHome } from "../config/paths.ts"
import type { ToolSpec } from "../provider/types.ts"
import { PURPOSE_DEF, TOOL_SPECS } from "../harness.ts"
import { fillParameters, PURPOSE_DEF, TOOL_SPECS } from "../harness.ts"
export interface ToolContext {
/** Absolute project root. */
@@ -91,6 +91,9 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
schema: S
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
jsonSchema?: Record<string, unknown>
/** The values of the spec's `{{variables}}` for this tool: its description is filled with them
* already (described), its parameters' descriptions when it is sent (toSpec). */
vars?: Record<string, string>
/** Where the model says what a call is for, shown when the user is asked to allow it: the
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's
* `description`), or false for a tool that never asks. */
@@ -134,7 +137,7 @@ export function toSpec(t: Tool): ToolSpec {
if (t.jsonSchema) schema = t.jsonSchema
// A built-in tool's parameters are the harness spec's, as LLeMbas sends them too; its zod schema
// validates the call, and tests/harness.test.ts keeps the two the same shape.
else if (TOOL_SPECS[t.name]) schema = structuredClone(TOOL_SPECS[t.name]!.parameters)
else if (TOOL_SPECS[t.name]) schema = t.vars ? fillParameters(TOOL_SPECS[t.name]!.parameters, t.vars) : structuredClone(TOOL_SPECS[t.name]!.parameters)
else {
schema = z.toJSONSchema(t.schema, { target: "draft-7", io: "input" }) as Record<string, unknown>
delete schema.$schema