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
+47
-11
@@ -7,16 +7,19 @@ import type { Asker, AskReply, Bus, Event } from "../bus/index.ts"
|
||||
import type { Mode } from "../config/schema.ts"
|
||||
import type { Decision, PermissionRequest } from "../permission/evaluate.ts"
|
||||
import type { Message, Usage } from "../provider/types.ts"
|
||||
import type { PlanReply } from "../tool/plan_exit.ts"
|
||||
import type { QuestionReply, QuestionRequest } from "../tool/question.ts"
|
||||
import type { Todo } from "../tool/todo.ts"
|
||||
import type { Plan } from "../plan/plan.ts"
|
||||
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
||||
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
||||
import { CONTINUE_PROMPT, STEER_MARK } from "../session/engine.ts"
|
||||
import { splitPrototypes, titleFor, type PrototypeKind } from "../prototype/fence.ts"
|
||||
import { savePrototype } from "../prototype/store.ts"
|
||||
import { CHARS_PER_TOKEN } from "../provider/tokens.ts"
|
||||
|
||||
export type Item =
|
||||
/** `mid`: sent while the agent worked, and given to it at a step boundary. */
|
||||
| { kind: "user"; id: string; text: string; mid?: boolean }
|
||||
| { kind: "assistant"; id: string; text: string; streaming: boolean }
|
||||
/** `prototypes`: the prototype fences in the text that have been saved, by where each starts. */
|
||||
| { kind: "assistant"; id: string; text: string; streaming: boolean; prototypes?: Record<number, SavedPrototype> }
|
||||
| { kind: "reasoning"; id: string; text: string; streaming: boolean; started: number; ms?: number; tokens?: number }
|
||||
| {
|
||||
kind: "tool"
|
||||
@@ -37,6 +40,31 @@ export type Item =
|
||||
|
||||
export type Phase = "idle" | "waiting" | "thinking" | "writing" | "tools"
|
||||
|
||||
/** A prototype the model wrote, saved as a file (prototype/store.ts). */
|
||||
export interface SavedPrototype {
|
||||
kind: PrototypeKind
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Whether a reply might hold a fenced block at all, before it is split. */
|
||||
const mayFence = (text: string) => text.includes("```") || text.includes("~~~")
|
||||
|
||||
/** Every completed prototype fence in a reply, saved the first time it is seen — the model's
|
||||
* output as it is, under the state directory — and noted on the item, so the transcript draws
|
||||
* one line in its place. Nothing without a session to keep it under. */
|
||||
function keepPrototypes(sessionId: string | undefined, it: Extract<Item, { kind: "assistant" }>) {
|
||||
if (!sessionId || !mayFence(it.text)) return
|
||||
for (const seg of splitPrototypes(it.text)) {
|
||||
if (seg.kind !== "prototype" || !seg.closed || it.prototypes?.[seg.start]) continue
|
||||
const title = titleFor(seg.code, seg.info.title)
|
||||
try {
|
||||
const path = savePrototype(sessionId, seg.code, { kind: seg.info.kind, title })
|
||||
;(it.prototypes ??= {})[seg.start] = { kind: seg.info.kind, title, path }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Pending {
|
||||
tool: string
|
||||
args: unknown
|
||||
@@ -67,7 +95,8 @@ export interface ViewState {
|
||||
pending?: Pending
|
||||
question?: PendingQuestion
|
||||
plan?: import("./components/plancard.tsx").PendingPlan
|
||||
todos: Todo[]
|
||||
/** The session's plan (plan_write / plan_update), drawn above the prompt while work is open. */
|
||||
livePlan?: Plan
|
||||
/** Tool call being streamed by the model, for the spinner line. */
|
||||
preparing?: string
|
||||
showReasoning: boolean
|
||||
@@ -83,7 +112,7 @@ let seq = 0
|
||||
const nextId = () => `i${++seq}`
|
||||
|
||||
export function createView(bus: Bus) {
|
||||
const [state, set] = createStore<ViewState>({ items: [], phase: "idle", busySince: 0, showReasoning: false, expandTools: false, todos: [], queued: { texts: [], mode: "steer" } })
|
||||
const [state, set] = createStore<ViewState>({ items: [], phase: "idle", busySince: 0, showReasoning: false, expandTools: false, queued: { texts: [], mode: "steer" } })
|
||||
let stepStart = 0
|
||||
// The task's output tokens (every step, subagents included), and the reply
|
||||
// streaming now, counted as it arrives so the number moves before the step's usage comes in.
|
||||
@@ -116,6 +145,8 @@ export function createView(bus: Bus) {
|
||||
}
|
||||
if (last?.kind === "assistant" && last.streaming) last.text += c.text
|
||||
else s.items.push({ kind: "assistant", id: nextId(), text: c.text, streaming: true })
|
||||
const reply = s.items[s.items.length - 1]!
|
||||
if (reply.kind === "assistant") keepPrototypes(s.sessionId, reply)
|
||||
s.phase = "writing"
|
||||
} else {
|
||||
if (last?.kind === "reasoning" && last.streaming) last.text += c.text
|
||||
@@ -135,6 +166,7 @@ export function createView(bus: Bus) {
|
||||
if ((it.kind === "assistant" || it.kind === "reasoning") && it.streaming) {
|
||||
it.streaming = false
|
||||
if (it.kind === "reasoning") it.ms = Date.now() - it.started
|
||||
else keepPrototypes(s.sessionId, it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,8 +267,8 @@ export function createView(bus: Bus) {
|
||||
}),
|
||||
)
|
||||
break
|
||||
case "todos":
|
||||
set("todos", e.items.map((t) => ({ ...t })))
|
||||
case "plan":
|
||||
set("livePlan", e.plan ? structuredClone(e.plan) : undefined)
|
||||
break
|
||||
case "notice":
|
||||
set(produce((s) => void s.items.push({ kind: "notice", id: nextId(), text: e.message, level: "warn" })))
|
||||
@@ -317,7 +349,7 @@ export function createView(bus: Bus) {
|
||||
(done) => set("question", { req, resolve: done }),
|
||||
() => set("question", undefined),
|
||||
)
|
||||
const planLocal = (req: { path: string; text: string }) =>
|
||||
const planLocal = (req: PlanRequest) =>
|
||||
cardLocal<PlanReply>(
|
||||
(done) => set("plan", { ...req, resolve: done }),
|
||||
() => set("plan", undefined),
|
||||
@@ -393,7 +425,11 @@ export function createView(bus: Bus) {
|
||||
items.push({ kind: "user", id: nextId(), text: m.parts.filter((p) => p.type === "text" && !ATTACHMENT.test(p.text)).map((p) => (p.type === "text" ? p.text : "")).join("\n") })
|
||||
else if (m.role === "assistant") {
|
||||
for (const p of m.parts) {
|
||||
if (p.type === "text" && p.text.trim()) items.push({ kind: "assistant", id: nextId(), text: p.text, streaming: false })
|
||||
if (p.type === "text" && p.text.trim()) {
|
||||
const it: Extract<Item, { kind: "assistant" }> = { kind: "assistant", id: nextId(), text: p.text, streaming: false }
|
||||
keepPrototypes(state.sessionId, it)
|
||||
items.push(it)
|
||||
}
|
||||
else if (p.type === "reasoning" && p.text.trim()) items.push({ kind: "reasoning", id: nextId(), text: p.text, streaming: false, started: 0 })
|
||||
else if (p.type === "tool_call") {
|
||||
let args: unknown
|
||||
@@ -422,7 +458,7 @@ export function createView(bus: Bus) {
|
||||
export interface CardHooks {
|
||||
ask?: (req: Parameters<Asker["ask"]>[0], local: LocalAsk) => Promise<AskReply>
|
||||
question?: (req: QuestionRequest, callId: string | undefined, local: LocalAsk<QuestionReply>) => Promise<QuestionReply>
|
||||
plan?: (req: { path: string; text: string }, callId: string | undefined, local: LocalAsk<PlanReply>) => Promise<PlanReply>
|
||||
plan?: (req: PlanRequest, callId: string | undefined, local: LocalAsk<PlanReply>) => Promise<PlanReply>
|
||||
}
|
||||
|
||||
export type View = ReturnType<typeof createView>
|
||||
|
||||
Reference in new issue
Block a user