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:
96 files changed
+3259
-527
No files matched your search
+27
-14
@@ -1,18 +1,19 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createApp } from "../src/app.ts"
|
||||
import type { Asker, AskReply } from "../src/bus/index.ts"
|
||||
import { paths } from "../src/config/paths.ts"
|
||||
import type { PlanReply } from "../src/tool/plan_exit.ts"
|
||||
import type { PlanReply, PlanRequest } from "../src/tool/plan_submit.ts"
|
||||
import { delta, fakeProvider, toolCall, type Fake } from "./fake-provider.ts"
|
||||
|
||||
let fake: Fake | undefined
|
||||
afterEach(() => fake?.stop())
|
||||
|
||||
const PLAN = "# Fix the average\n\n## Goal\nTests pass.\n\n## Changes\n1. calc.py: divide by len(values)\n"
|
||||
function app(script: Parameters<typeof fakeProvider>[0], plan?: (r: { path: string; text: string }) => Promise<PlanReply>) {
|
||||
const PLAN = { title: "Fix the average", objectives: [{ text: "Tests pass" }], phases: [{ title: "Changes", tasks: ["calc.py: divide by len(values)"] }] }
|
||||
const RENDERED = "# Fix the average\nObjectives: [o1] Tests pass (open)\n## p1 Changes (pending)\n[t1] [ ] calc.py: divide by len(values)"
|
||||
function app(script: Parameters<typeof fakeProvider>[0], plan?: (r: PlanRequest) => Promise<PlanReply>) {
|
||||
fake = fakeProvider(script)
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n dialect: openai-chat\n base_url: ${fake.url}\n models: { m: {} }\n`, { mode: 0o600 })
|
||||
@@ -22,23 +23,35 @@ function app(script: Parameters<typeof fakeProvider>[0], plan?: (r: { path: stri
|
||||
const asker: Asker = { ask: async (): Promise<AskReply> => ({ kind: "once" }), ...(plan ? { plan } : {}) }
|
||||
return createApp({ cwd, mode: "plan", store: false, asker })
|
||||
}
|
||||
const writePlan = (name = "2026-09-29-fix.md") => toolCall(0, "w1", "write", JSON.stringify({ path: `.agent/plans/${name}`, content: PLAN }))
|
||||
const exitPlan = (name = "2026-09-29-fix.md") => toolCall(0, "x1", "plan_submit", JSON.stringify({ path: `.agent/plans/${name}` }))
|
||||
const writePlan = () => toolCall(0, "w1", "plan_write", JSON.stringify(PLAN))
|
||||
// `path` is a former argument, ignored: the plan is the one written with plan_write.
|
||||
const exitPlan = () => toolCall(0, "x1", "plan_submit", JSON.stringify({ path: ".agent/plans/old.md" }))
|
||||
const names = (r: any) => r.tools.map((t: any) => t.function.name)
|
||||
|
||||
describe("plan mode", () => {
|
||||
test("write the plan, plan_submit, approve → edit mode in the same turn, and plan_submit is no longer offered", async () => {
|
||||
const seen: string[] = []
|
||||
test("plan_write, plan_submit, approve → edit mode in the same turn, and plan_submit is no longer offered", async () => {
|
||||
const seen: PlanRequest[] = []
|
||||
const a = app([{ chunks: [writePlan()] }, { chunks: [exitPlan()] }, { chunks: [delta({ content: "Implementing." })] }], async (r) => {
|
||||
seen.push(r.path, r.text)
|
||||
seen.push(r)
|
||||
return { kind: "approve", mode: "edit" }
|
||||
})
|
||||
await a.engine.prompt("fix it")
|
||||
expect(seen).toEqual([".agent/plans/2026-09-29-fix.md", PLAN])
|
||||
// The card gets the plan, its text with ids, and the copy kept under plans/ for the record.
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]!.text).toBe(RENDERED)
|
||||
expect(seen[0]!.plan.title).toBe("Fix the average")
|
||||
expect(seen[0]!.path).toMatch(/^\.agent\/plans\/\d{4}-\d{2}-\d{2}-fix-the-average\.md$/)
|
||||
expect(readFileSync(join(a.project.root, seen[0]!.path), "utf8")).toBe(RENDERED + "\n")
|
||||
expect(a.engine.plan?.steps).toEqual(["calc.py: divide by len(values)"])
|
||||
// The model read the plan back with its ids from plan_write.
|
||||
expect(fake!.requests[1].messages.at(-1).content).toBe(RENDERED)
|
||||
expect(names(fake!.requests[0])).toContain("plan_submit")
|
||||
expect(names(fake!.requests[0])).toContain("plan_write")
|
||||
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: plan")
|
||||
expect(fake!.requests[2].messages[0].content).toContain("Permission mode: edit")
|
||||
expect(names(fake!.requests[2])).not.toContain("plan_submit")
|
||||
// plan_write and plan_update are offered in every mode: the plan is kept current while implementing.
|
||||
expect(names(fake!.requests[2])).toContain("plan_update")
|
||||
expect(fake!.requests[2].messages.at(-1).content).toContain("The user approved the plan")
|
||||
expect(a.engine.mode).toBe("edit")
|
||||
})
|
||||
@@ -56,11 +69,11 @@ describe("plan mode", () => {
|
||||
expect(b.engine.mode).toBe("plan")
|
||||
})
|
||||
|
||||
test("plan_submit refuses a file outside the plans directory; edit mode does not offer it", async () => {
|
||||
const a = app([{ chunks: [toolCall(0, "x1", "plan_submit", '{"path":"README.md"}')] }, { chunks: [delta({ content: "ok" })] }], async () => ({ kind: "approve", mode: "edit" }))
|
||||
writeFileSync(join(a.project.root, "README.md"), "x")
|
||||
test("plan_submit with no plan written is an error naming plan_write; edit mode does not offer it", async () => {
|
||||
const a = app([{ chunks: [exitPlan()] }, { chunks: [delta({ content: "ok" })] }], async () => ({ kind: "approve", mode: "edit" }))
|
||||
await a.engine.prompt("go")
|
||||
expect(fake!.requests[1].messages.at(-1).content).toContain("must be a file under")
|
||||
expect(fake!.requests[1].messages.at(-1).content).toContain("Write it with plan_write")
|
||||
expect(a.engine.mode).toBe("plan")
|
||||
a.engine.mode = "edit"
|
||||
await a.engine.prompt("again").catch(() => {})
|
||||
expect(names(fake!.requests.at(-1))).not.toContain("plan_submit")
|
||||
|
||||
Reference in new issue
Block a user