Harness spec 1.1.0: one plan, prototypes and the library shared; the CLI implements it

The spec: plan_write and plan_update (new, shared, offered in every mode) and
plan_submit (now shared) keep one structured plan — findings, objectives,
phases of tasks — in place of the todo list, which lives on as a former name
of plan_write. The shared system text is one text for both projects, with
variables for what differs; the mode texts, a prototypes block and a library
block are shared. acp.md: the client hands the device what only it knows
(context, library, prototypes), the plan travels as a structure (plan_state),
library calls carry the chat's scope, and a client may rewind a session.
loop.json says its numbers as numbers. Four conformance areas: plan,
prototype, library_scope, toolcall_index.

The CLI: the plan is kept per session in the store and drawn above the prompt
and on the approval card; the prompt is assembled from the spec's texts with
their variables filled; the link reads the instance's instructions,
personalization, knowledge scope and prototype offer, sends the plan as a
structure and answers rewind; library calls to the instance carry the session
as scope; a prototype written in the terminal is saved as a file and named in
the transcript; a tool-call fragment without an index continues the last call;
the default theme is Moria; dead exports and Hermes leftovers removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Fable 5.1 committed 2026-10-18 04:02:12 +02:00
1 parent 6e70ff46d0
commit 837f38c744
96 files changed
+3259 -527

No files matched your search

File renamed without changes.
+7 -3
View File
@@ -2,7 +2,7 @@
// Wording of knowledge_search after LLeMbas's tool.knowledge prompt (© Jaroslav Beneš,
// MIT): look there before the web, and read the document before answering from it.
import { z } from "zod"
import { SNIPPET_CHARS } from "../library/store.ts"
import { resolveBase, SNIPPET_CHARS } from "../library/store.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
@@ -104,8 +104,12 @@ export const knowledgeSearchTool = defineTool({
permission: (a) => ({ permission: "knowledge", class: "read", patterns: [a.base ?? "*"] }),
async run(a, ctx) {
const L = lib(ctx)
const bases = a.base ? [a.base] : L.bases
if (a.base && L.bases && !L.bases.includes(a.base)) throw new ToolError(`"${a.base}" is not one of this project's knowledge bases: ${L.bases.join(", ")}`)
// The bases in scope (all of this machine's, or the project's `knowledge:`), and `base` read
// against them as LLeMbas reads it: by name whatever its case, an unknown one named back.
const inScope = L.lib.bases().filter((b) => !L.bases || L.bases.includes(b.name)).map((b) => ({ id: b.name, name: b.name }))
const resolved = resolveBase(inScope, a.base)
if ("error" in resolved) throw new ToolError(resolved.error)
const bases = resolved.ids
let note = ""
let hits
try {
+9 -1
View File
@@ -3,11 +3,13 @@
// agent file, a model trained on the old name — runs the tool it now is. Only the CLI's own
// former names: LLeMbas's differ in their arguments too, and are LLeMbas's to read.
import { TOOL_SPECS } from "../harness.ts"
import { fromTodos, isPlan } from "../plan/plan.ts"
const RENAMED: Record<string, string> = {}
const ARGUMENTS_RENAMED: Record<string, Record<string, string>> = {}
for (const t of Object.values(TOOL_SPECS)) {
// notes became three tools; which one depends on the call's action (below).
// notes became three tools; which one depends on the call's action (below). todo took a
// different argument (todos[]), converted below.
for (const old of t.aliases.cli) if (old !== "notes") RENAMED[old] = t.name
if (t.argument_aliases) ARGUMENTS_RENAMED[t.name] = t.argument_aliases
}
@@ -24,6 +26,12 @@ export function resolveCall(name: string, raw: unknown): { name: string; raw: un
else to = "note_manage"
args = to === "note_manage" ? obj : rest
}
// todo's list is one phase of the plan now (the spec's one argument conversion, conformance
// plan.json `todos`): the whole plan is sent, as plan_write takes it.
if (name === "todo" && obj) {
const plan = fromTodos(obj.todos)
args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] }
}
const renamed = ARGUMENTS_RENAMED[to]
if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
const a = { ...(args as Record<string, unknown>) }
-51
View File
@@ -1,51 +0,0 @@
import { existsSync, readFileSync, realpathSync } from "node:fs"
import { z } from "zod"
import type { Mode } from "../config/schema.ts"
import { absPath, defineTool, showPath, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
export type PlanReply = { kind: "approve"; mode: Extract<Mode, "edit" | "manual"> } | { kind: "revise"; feedback: string } | { kind: "back"; text: string } | { kind: "dismissed" }
/** What the model is told when nobody can approve (also where the client turns out to have no plan
* card). */
export const nobodyToApprove = (path: string) => `Nobody is present to approve the plan. It is saved at ${path}. Stop here: say the plan is ready and where it is. Make no changes.`
export const planSubmitTool = defineTool({
...described("plan_submit"),
purpose: false,
access: "plan_submit",
requires: "plan",
exclusive: true,
schema: z.object({ path: z.string() }),
permission: () => ({ permission: "plan_submit", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const abs = absPath(a.path, ctx)
const plans = ctx.planDir
if (plans && !abs.startsWith(plans + "/")) throw new ToolError(`The plan must be a file under ${showPath(plans, ctx)}; ${showPath(abs, ctx)} is not.`)
if (!existsSync(abs)) throw new ToolError(`${showPath(abs, ctx)} does not exist — write the plan first.`)
// By where it really is: a link under the plans directory could point anywhere.
if (plans && !realpathSync(abs).startsWith(realpathSync(plans) + "/")) throw new ToolError(`${showPath(abs, ctx)} leads out of the plans directory.`)
const text = readFileSync(abs, "utf8")
if (!ctx.plan)
return {
output: nobodyToApprove(showPath(abs, ctx)),
title: `${showPath(abs, ctx)} · saved, not approved`,
}
const r = await ctx.plan({ path: showPath(abs, ctx), text }, ctx.callId)
const shown = showPath(abs, ctx)
switch (r.kind) {
case "approve":
ctx.setMode?.(r.mode)
return {
output: `The user approved the plan in ${shown}. The permission mode is now ${r.mode}. Implement it now, step by step as written, keeping a todo list that follows its steps. If you find the plan wrong somewhere, say so and adjust rather than follow it into a mistake.`,
title: `${shown} · approved → ${r.mode}`,
}
case "revise":
return { output: `The user wants the plan revised: ${r.feedback}\nUpdate ${shown}, then call plan_submit again.`, title: `${shown} · revise` }
case "back":
return { output: `Before deciding, the user asks: "${r.text}"\nAnswer it in your reply, revise ${shown} if the answer changes it, then call plan_submit again.`, title: `${shown} · asked back` }
case "dismissed":
return { output: `The user closed the plan without deciding. Stay in plan mode; say the plan is at ${shown} and wait.`, title: `${shown} · not decided` }
}
},
})
+75
View File
@@ -0,0 +1,75 @@
// plan_submit: the plan written with plan_write, presented for approval — plan mode only, where
// it is the approval. The plan itself is the session's (src/plan/plan.ts); a copy goes under the
// project's plans directory for the record, since that is the one place plan mode may write.
import { mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { z } from "zod"
import type { Mode } from "../config/schema.ts"
import { described } from "../harness.ts"
import { render, type Plan } from "../plan/plan.ts"
import { defineTool, showPath, ToolError } from "./tool.ts"
export type PlanReply = { kind: "approve"; mode: Extract<Mode, "edit" | "manual"> } | { kind: "revise"; feedback: string } | { kind: "back"; text: string } | { kind: "dismissed" }
/** What is put to the user: the plan, its text as the card draws it, and the file it was saved to
* for the record (relative to the project), or "" where there is no plans directory. */
export interface PlanRequest {
path: string
text: string
plan: Plan
}
/** What the model is told when nobody can approve (also where the client turns out to have no plan
* card). */
export const nobodyToApprove = (path: string) => `Nobody is present to approve the plan.${path ? ` It is saved at ${path}.` : ""} Stop here: say the plan is ready${path ? " and where it is" : ""}. Make no changes.`
const slug = (title: string) =>
title
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "plan"
export const planSubmitTool = defineTool({
...described("plan_submit"),
purpose: false,
access: "plan_submit",
requires: "plan",
exclusive: true,
schema: z.object({ path: z.string().optional() }),
permission: () => ({ permission: "plan_submit", class: "interact", patterns: ["*"] }),
async run(_a, ctx) {
const plan = ctx.plan?.get()
if (!plan) throw new ToolError("There is no plan yet. Write it with plan_write, then call plan_submit.")
const text = render(plan)
// For the record: the plans directory is the one place plan mode writes (made on the way, as
// the write tool made it before), and a plan that was approved is worth finding again. Nothing
// waits on it: a directory that cannot be written is not a reason to lose the approval.
let path = ""
if (ctx.planDir) {
const abs = join(ctx.planDir, `${new Date().toISOString().slice(0, 10)}-${slug(plan.title)}.md`)
try {
mkdirSync(ctx.planDir, { recursive: true })
writeFileSync(abs, text + "\n")
path = showPath(abs, ctx)
} catch {}
}
const saved = path ? ` It is saved at ${path}.` : ""
if (!ctx.plan?.ask) return { output: nobodyToApprove(path), title: `${plan.title} · saved, not approved` }
const r = await ctx.plan.ask({ path, text, plan }, ctx.callId)
switch (r.kind) {
case "approve":
ctx.setMode?.(r.mode)
return {
output: `The user approved the plan.${saved} The permission mode is now ${r.mode}. Implement it now, phase by phase as written, keeping the plan current with plan_update: a task doing when you start it, done once you have checked it. If you find the plan wrong somewhere, say so and adjust rather than follow it into a mistake.`,
title: `${plan.title} · approved → ${r.mode}`,
}
case "revise":
return { output: `The user wants the plan revised: ${r.feedback}\nChange it with plan_write or plan_update, then call plan_submit again.`, title: `${plan.title} · revise` }
case "back":
return { output: `Before deciding, the user asks: "${r.text}"\nAnswer it in your reply, revise the plan if the answer changes it, then call plan_submit again.`, title: `${plan.title} · asked back` }
case "dismissed":
return { output: `The user closed the plan without deciding. Stay in plan mode; say the plan is ready${saved} and wait.`, title: `${plan.title} · not decided` }
}
},
})
+34
View File
@@ -0,0 +1,34 @@
// plan_update: the plan changed as the work goes — a task marked doing or done, one dropped with
// why, work added, a finding recorded. Several changes in one call; an unknown id is an error
// naming the known ones, and then nothing changes (src/plan/plan.ts apply).
import { z } from "zod"
import { described } from "../harness.ts"
import { apply, OBJECTIVE_STATUSES, PHASE_STATUSES, render, TASK_STATUSES } from "../plan/plan.ts"
import { planTitle } from "./plan_write.ts"
import { defineTool } from "./tool.ts"
const asList = (v: unknown) => (v === undefined || v === null || Array.isArray(v) ? v : [v])
export const planUpdateSchema = z.object({
tasks: z.preprocess(asList, z.array(z.object({ id: z.string().min(1), status: z.enum(TASK_STATUSES).optional(), note: z.string().max(300).optional() }))).optional(),
objectives: z.preprocess(asList, z.array(z.object({ id: z.string().min(1), status: z.enum(OBJECTIVE_STATUSES) }))).optional(),
phases: z.preprocess(asList, z.array(z.object({ id: z.string().min(1), status: z.enum(PHASE_STATUSES) }))).optional(),
add_tasks: z.preprocess(asList, z.array(z.object({ phase: z.string().min(1), text: z.string().min(1).max(300), after: z.string().optional() }))).optional(),
add_findings: z.preprocess(asList, z.array(z.string().min(1).max(300))).optional(),
})
export const planUpdateTool = defineTool({
...described("plan_update"),
purpose: false,
access: "plan",
schema: planUpdateSchema,
permission: () => ({ permission: "plan", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const current = ctx.plan?.get()
if (!current) return { output: "There is no plan to update. Write one with plan_write first.", isError: true }
const r = apply(current, a)
if ("error" in r) return { output: r.error, isError: true }
ctx.plan?.set(r.plan)
return { output: render(r.plan), title: planTitle(r.plan) }
},
})
+58
View File
@@ -0,0 +1,58 @@
// plan_write: the plan for this work, written whole (it replaces the previous one). The structure,
// its ids and bounds are src/plan/plan.ts; the engine keeps the plan per session and puts it into
// the prompt on every step while it exists.
import { z } from "zod"
import { described } from "../harness.ts"
import { isPlan, normalise, OBJECTIVE_STATUSES, PHASE_STATUSES, progress, render, TASK_STATUSES, type Plan } from "../plan/plan.ts"
import { defineTool, ToolError } from "./tool.ts"
// Small models send a task as a bare string, a phase as its title, a finding as `{text}`: all of
// it is accepted here and read by normalise, as LLeMbas does.
const asText = (v: unknown) => (v && typeof v === "object" && "text" in v ? String((v as { text: unknown }).text ?? "") : v)
const asItem = (v: unknown) => (typeof v === "string" ? { text: v } : v)
const asPhase = (v: unknown) => (typeof v === "string" ? { title: v, tasks: [] } : v)
const asList = (v: unknown) => (v === undefined || v === null || Array.isArray(v) ? v : [v])
export const planWriteSchema = z.object({
title: z.string().min(1).max(120),
summary: z.string().max(300).optional(),
findings: z.preprocess(asList, z.array(z.preprocess(asText, z.string().max(300))).max(20)).optional(),
objectives: z
.preprocess(asList, z.array(z.preprocess(asItem, z.object({ text: z.string().min(1).max(300), status: z.enum(OBJECTIVE_STATUSES).default("open") }))).max(8))
.optional(),
phases: z.preprocess(
asList,
z
.array(
z.preprocess(
asPhase,
z.object({
title: z.string().max(120).optional(),
status: z.enum(PHASE_STATUSES).default("pending"),
tasks: z.preprocess(asList, z.array(z.preprocess(asItem, z.object({ text: z.string().min(1).max(300), status: z.enum(TASK_STATUSES).default("todo"), note: z.string().max(300).optional() }))).max(12)),
}),
),
)
.max(8),
),
})
/** The tool's title line: how much of the plan is done. */
export const planTitle = (plan: Plan) => {
const { done, total } = progress(plan)
return `${done}/${total} done`
}
export const planWriteTool = defineTool({
...described("plan_write"),
purpose: false,
access: "plan",
schema: planWriteSchema,
permission: () => ({ permission: "plan", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const plan = normalise(a)
if (!isPlan(plan)) throw new ToolError("The plan is empty: give it a title and at least one phase with tasks.")
ctx.plan?.set(plan)
return { output: render(plan), title: planTitle(plan) }
},
})
+6 -4
View File
@@ -2,12 +2,13 @@ import { applyPatchTool } from "./apply_patch.ts"
import { bashKillTool, bashListTool, bashOutputTool, bashTool } from "./bash.ts"
import { editTool } from "./edit.ts"
import { multieditTool } from "./multiedit.ts"
import { askUserTool } from "./question.ts"
import { askUserTool } from "./ask_user.ts"
import { readTool } from "./read.ts"
import { globTool, grepTool, listTool } from "./search.ts"
import { todoTool } from "./todo.ts"
import { viewImageTool } from "./view_image.ts"
import { planSubmitTool } from "./plan_exit.ts"
import { planSubmitTool } from "./plan_submit.ts"
import { planUpdateTool } from "./plan_update.ts"
import { planWriteTool } from "./plan_write.ts"
import { taskTool } from "./task.ts"
import { decisionsTool, tasksTool } from "./project_tools.ts"
import { webFetchTool, webSearchTool } from "./web.ts"
@@ -35,7 +36,8 @@ export const BUILTIN_TOOLS: Tool[] = [
webSearchTool,
webFetchTool,
taskTool,
todoTool,
planWriteTool,
planUpdateTool,
tasksTool,
decisionsTool,
askUserTool,
-40
View File
@@ -1,40 +0,0 @@
// A working todo list for multi-step tasks. The whole list is sent each time (OpenCode's
// todowrite): simpler for small models than Hermes' merge-by-id, and nothing can drift.
import { z } from "zod"
import { defineTool } from "./tool.ts"
import { described } from "../harness.ts"
export const TODO_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const
export type Todo = { content: string; status: (typeof TODO_STATUSES)[number] }
const MARK: Record<Todo["status"], string> = { pending: "[ ]", in_progress: "[>]", completed: "[x]", cancelled: "[~]" }
export function renderTodos(items: Todo[]): string {
return items.map((t) => `${MARK[t.status]} ${t.content}`).join("\n") || "(empty)"
}
export const todoTool = defineTool({
...described("todo"),
purpose: false,
access: "todo",
schema: z.object({
todos: z
.array(
z.preprocess(
(t) => (typeof t === "string" ? { content: t, status: "pending" } : t),
z.object({ content: z.string().min(1), status: z.enum(TODO_STATUSES).default("pending") }),
),
)
.max(30),
}),
permission: () => ({ permission: "todo", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const items = a.todos as Todo[]
if (ctx.todos) {
ctx.todos.items = items
ctx.todos.changed?.(items)
}
const done = items.filter((t) => t.status === "completed").length
return { output: renderTodos(items), title: `${done}/${items.length} done` }
},
})
+11 -5
View File
@@ -20,7 +20,7 @@ export interface ToolContext {
/** Instruction files (AGENTS.md / CLAUDE.md) the model has already been given. */
instructionsSeen?: Set<string>
/** Ask the user questions (the ask_user tool). Absent where nobody can answer. */
question?: (req: import("./question.ts").QuestionRequest, callId?: string) => Promise<import("./question.ts").QuestionReply>
question?: (req: import("./ask_user.ts").QuestionRequest, callId?: string) => Promise<import("./ask_user.ts").QuestionReply>
/** Run a subagent (the task tool). Absent inside a subagent: no recursion. */
spawn?: (agent: string, prompt: string, signal: AbortSignal, callId?: string, opts?: { worktree?: boolean; description?: string; model?: string }) => Promise<import("./task.ts").SubagentResult>
/** The id of the call being run (set by the engine per call). */
@@ -29,8 +29,13 @@ export interface ToolContext {
projectDir?: string
/** The plans directory (plan mode writes only there). */
planDir?: string
/** Present a plan for approval (plan_submit). Absent where nobody can answer. */
plan?: (req: { path: string; text: string }, callId?: string) => Promise<import("./plan_exit.ts").PlanReply>
/** The session's plan (plan_write, plan_update, plan_submit): read it, replace it (null clears),
* and present it for approval — `ask` is absent where nobody can answer. */
plan?: {
get(): import("../plan/plan.ts").Plan | undefined
set(plan: import("../plan/plan.ts").Plan | null): void
ask?: (req: import("./plan_submit.ts").PlanRequest, callId?: string) => Promise<import("./plan_submit.ts").PlanReply>
}
/** Change the permission mode (plan_submit, on approval). */
setMode?: (mode: import("../config/schema.ts").Mode) => void
/** Web search and fetch settings (config `search:`). */
@@ -50,6 +55,9 @@ export interface ToolContext {
set(key: string, value: unknown, scope?: import("../config/settings.ts").Scope): import("../settings.ts").SetResult
models(): string[]
}
/** The scope sent on every call to the instance's library (`_meta.lembas.scope`, harness/acp.md):
* the session's id, where the client has said what the session may search. */
libraryScope?: string
/** The library: notes, and the knowledge bases this project searches (all, when
* `bases` is undefined); `project` is the scope of this project's notes. */
library?: {
@@ -58,8 +66,6 @@ export interface ToolContext {
bases?: string[]
embedder?: import("../library/store.ts").Embedder
}
/** The session's todo list (the todo tool). */
todos?: { items: import("./todo.ts").Todo[]; changed?: (items: import("./todo.ts").Todo[]) => void }
}
export interface ToolResult {