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

+73 -9
View File
@@ -3,6 +3,8 @@ import { platform, release } from "node:os"
import { dirname, join, resolve, sep } from "node:path"
import type { Mode } from "../config/schema.ts"
import { expandHome, paths } from "../config/paths.ts"
import { render, type Plan } from "../plan/plan.ts"
import { fillPrompt } from "./fill.ts"
import defaultSystem from "../../harness/prompts/system/default.md" with { type: "text" }
import identity from "../../harness/prompts/system/identity.md" with { type: "text" }
import blockMemory from "../../harness/prompts/blocks/memory.md" with { type: "text" }
@@ -11,6 +13,8 @@ import blockSkillsEmpty from "../../harness/prompts/blocks/skills-empty.md" with
import blockAgentsMissing from "../../harness/prompts/blocks/agents-missing.md" with { type: "text" }
import blockAgentsGit from "../../harness/prompts/blocks/agents-git.md" with { type: "text" }
import blockAgentsKeep from "../../harness/prompts/blocks/agents-keep.md" with { type: "text" }
import blockLibrary from "../../harness/prompts/blocks/library.md" with { type: "text" }
import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" }
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
import modeEdit from "../../harness/prompts/modes/edit.md" with { type: "text" }
import modeAuto from "../../harness/prompts/modes/auto.md" with { type: "text" }
@@ -43,6 +47,8 @@ const BUILTIN: Record<string, string> = {
"blocks/agents-missing.md": blockAgentsMissing,
"blocks/agents-git.md": blockAgentsGit,
"blocks/agents-keep.md": blockAgentsKeep,
"blocks/library.md": blockLibrary,
"blocks/prototypes.md": blockPrototypes,
"modes/manual.md": modeManual,
"modes/edit.md": modeEdit,
"modes/auto.md": modeAuto,
@@ -75,10 +81,41 @@ export function promptText(rel: string): string {
return raw.replace(/<!--[\s\S]*?-->\n?/g, "").trim()
}
/** A plain replace, for the CLI's own task texts (compact, review) whose variables are whole
* sections. A spec text with variables goes through fillPrompt (fill.ts), which also drops a
* bullet whose variable is empty and refuses one left unfilled. */
export function fill(text: string, vars: Record<string, string>): string {
return text.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (Object.hasOwn(vars, k) ? vars[k]! : m))
}
// What the CLI fills into system/default.md (harness/prompts/index.json says what each means):
// the facts the two projects differ in, not the design.
const INTRO = "Use the instructions below and the tools you have to help with software engineering and with running the project."
const SURFACE = "Your output is shown in a terminal and rendered as GitHub-flavoured markdown in a monospace font."
const FILE_TOOLS = "Use the dedicated tools for files: read (not cat), edit and write (not sed or heredocs), grep and glob (not find or grep in bash)."
const BACKGROUND = "A long-running command (a server, a watcher) runs with bash `background: true`; check it with bash_output and stop it with bash_kill."
const GIT_SECTION = [
"# Git",
"- How git is used in this project — whether you commit on your own, and on which branch — is in the \"## Git\" section of its AGENTS.md. Follow it. Where nothing says, only commit, amend, push, tag or create branches when the user asks.",
"- Before a commit, look at `git status`, `git diff` and recent `git log`, stage files by name, and write a message in the repository's style.",
"- Never change git config, skip hooks, force-push or rewrite history unless the user explicitly asks.",
].join("\n")
/** Where a prototype is seen from a terminal (blocks/prototypes.md's {{prototype_surface}}). */
export const PROTOTYPE_SURFACE = "It is saved as a file you can open, and the LLeMbas web UI renders it live."
/** `{{plan_context}}`: the plan with its ids under a heading, and the sentence to work to it. */
export function planContext(plan: Plan | undefined): string {
if (!plan) return ""
return `### The current plan\n\n${render(plan)}\n\nThis is the plan as it stands now. Work to it, and quote the ids above rather than restating the plan in your answer.`
}
/** `{{knowledge_scope}}` (blocks/library.md): the sentence naming the bases a session may search,
* or "" where it may search them all. */
export function knowledgeScope(names: string[] | undefined): string {
if (!names || !names.length) return ""
return `Knowledge searches here cover only: ${names.join(", ")}. Finding nothing there means nothing is there, not that the library is empty.`
}
/** Instruction files: the global AGENTS.md, then the nearest project AGENTS.md or CLAUDE.md
* walking up to the project root (first match wins, as OpenCode does — ancestors do not stack). */
/** An `instructions` entry from config.yaml. A project's may only name files inside the project:
@@ -162,21 +199,44 @@ export interface PromptContext {
mcp?: string
/** The other configured models, one line each (with their notes). */
roster?: string
/** The session's plan (plan_write / plan_update), put into the prompt while it exists. */
plan?: Plan
/** An instance's own instructions for this session (the chat's authored prompt, over the link),
* appended after the shared text under `## Instructions from <name>`. */
instance?: { name: string; instructions: string }
/** The library block (blocks/library.md): set when knowledge tools are offered or the instance
* library is in use; `scope` the sentence naming the bases in scope, or "" for all of them. */
library?: { scope: string }
/** Prototypes are offered (blocks/prototypes.md); `sources` the sentence saying what a page
* may load, or "" where nothing says. */
prototypes?: { sources: string }
}
const MAX_INSTRUCTION_CHARS = 40_000
export function assembleSystem(ctx: PromptContext): string {
const vars = { agent_name: ctx.agentName ?? "LLeMbas", plan_dir: ctx.planDir }
const vars = {
agent_name: ctx.agentName ?? "LLeMbas",
plan_dir: ctx.planDir,
intro: INTRO,
surface: SURFACE,
tool_list: "",
file_tools: FILE_TOOLS,
background: BACKGROUND,
git: GIT_SECTION,
plan_context: planContext(ctx.plan),
}
const sections: string[] = []
// Who: the built-in line.
sections.push(fill(promptText("system/identity.md"), vars))
sections.push(fillPrompt(promptText("system/identity.md"), vars))
// One shared base, then what this model family needs on top (see the wiki: Prompts-provenance).
// A user may still replace the whole base for a family with prompts/system/<family>.md.
const own = `system/${ctx.family}.md`
sections.push(fill(promptText(existsSync(join(paths.config, "prompts", own)) ? own : "system/default.md"), vars))
sections.push(fillPrompt(promptText(existsSync(join(paths.config, "prompts", own)) ? own : "system/default.md"), vars).trim())
const overlay = `family/${ctx.family}.md`
if (BUILTIN[overlay] || existsSync(join(paths.config, "prompts", overlay))) sections.push(fill(promptText(overlay), vars))
if (BUILTIN[overlay] || existsSync(join(paths.config, "prompts", overlay))) sections.push(fillPrompt(promptText(overlay), vars))
// What the instance holds for this session (over the link): the chat's authored prompt.
if (ctx.instance?.instructions.trim()) sections.push(`## Instructions from ${ctx.instance.name}\n\n${ctx.instance.instructions.trim()}`)
sections.push(
[
@@ -214,7 +274,7 @@ export function assembleSystem(ctx: PromptContext): string {
}
if (ctx.skills)
sections.push(
fill(promptText("blocks/skills.md"), {
fillPrompt(promptText("blocks/skills.md"), {
skills: ctx.skills,
manage:
ctx.manageSkills && !ctx.subagent
@@ -223,13 +283,17 @@ export function assembleSystem(ctx: PromptContext): string {
}).replace(/\n{3,}/g, "\n\n"),
)
else if (ctx.manageSkills && !ctx.subagent) sections.push(promptText("blocks/skills-empty.md"))
// The library and prototypes (shared blocks): the library whenever its tools are offered, named
// to the bases in scope; prototypes only where they are offered, with where the page is seen.
if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
if (ctx.subagent)
sections.push(
`You are the "${ctx.subagent.name}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask anyone anything. ${ctx.subagent.instructions}\n\nOnly your final reply reaches the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.${ctx.mode === "plan" ? " You can read and search; you cannot change anything." : ""}`,
)
else sections.push(fill(promptText(`modes/${ctx.mode}.md`), vars))
if (ctx.unattended) sections.push(fill(promptText("modes/unattended.md"), { ...vars, refused: ctx.unattended }))
else sections.push(fillPrompt(promptText(`modes/${ctx.mode}.md`), vars))
if (ctx.unattended) sections.push(fillPrompt(promptText("modes/unattended.md"), { ...vars, refused: ctx.unattended }))
// What the person said about themselves goes last, under the web UI's headings and in its order.
// Not for a subagent: it answers the main agent, not the person.
if (ctx.userInstructions && !ctx.subagent) sections.push(`## How the person you are talking to wants to be helped\n\n${ctx.userInstructions}`)
@@ -246,7 +310,7 @@ const GIT_QUESTION_NO_REPO = "this folder is not a git repository yet — should
function agentsBlock(ctx: PromptContext, files: { path: string; text: string }[]): string {
const own = files.find((f) => f.path !== join(paths.config, "AGENTS.md"))
const vars = { root: ctx.root, git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO }
if (!own) return fill(promptText("blocks/agents-missing.md"), vars)
if (!own) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
const keep = promptText("blocks/agents-keep.md")
return /^#{1,3}\s*git\b/im.test(own.text) ? keep : `${fill(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
return /^#{1,3}\s*git\b/im.test(own.text) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
}