@maxiaochao/pi-toolkit 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/global/AGENTS.md +33 -50
  4. package/package.json +2 -2
  5. package/packages/herdr-subagents/CHANGELOG.md +14 -0
  6. package/packages/herdr-subagents/README.md +20 -18
  7. package/packages/herdr-subagents/TESTING.md +6 -4
  8. package/packages/herdr-subagents/docs/demo.webm +0 -0
  9. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  10. package/packages/herdr-subagents/extensions/herdr-subagents.ts +91 -51
  11. package/packages/herdr-subagents/package.json +1 -1
  12. package/packages/herdr-subagents/src/batch-runner.ts +5 -5
  13. package/packages/herdr-subagents/src/blocking.ts +4 -4
  14. package/packages/herdr-subagents/src/fleet-controller.ts +164 -16
  15. package/packages/herdr-subagents/src/fleet.ts +119 -8
  16. package/packages/herdr-subagents/src/history.ts +11 -5
  17. package/packages/herdr-subagents/src/parameters.ts +16 -13
  18. package/packages/herdr-subagents/src/queued-runs.ts +3 -3
  19. package/packages/herdr-subagents/src/records.ts +53 -1
  20. package/packages/herdr-subagents/src/tool.ts +40 -6
  21. package/packages/herdr-subagents/src/validation.ts +16 -29
  22. package/packages/herdr-subagents/tests/extension-contract.ts +27 -9
  23. package/packages/herdr-subagents/tests/run-tests.mjs +153 -18
  24. package/packages/todo/CHANGELOG.md +4 -0
  25. package/packages/todo/README.md +19 -9
  26. package/packages/todo/TESTING.md +9 -7
  27. package/packages/todo/docs/demo.gif +0 -0
  28. package/packages/todo/docs/screenshot.png +0 -0
  29. package/packages/todo/extensions/todo.ts +120 -90
  30. package/packages/todo/package.json +2 -2
  31. package/packages/todo/src/todo-state.ts +48 -49
  32. package/packages/todo/tests/todo-extension.mjs +134 -0
  33. package/packages/todo/tests/todo-state.mjs +80 -70
@@ -4,11 +4,27 @@ import { runHerdrSubagents, type HerdrSubagentsResult } from "./runner.ts";
4
4
  import { runHerdrSubagentsBatch, type BatchResult, type BatchTask } from "./batch-runner.ts";
5
5
  import { respondToBlockedTask } from "./blocking.ts";
6
6
  import { cleanSubagentRun, historyText, listSubagentHistory, resumeHistoricalTask } from "./history.ts";
7
- import { HERDR_ACTIONS, validateToolParams } from "./validation.ts";
7
+ import { HERDR_ACTIONS, validateControlParams, validateRunParams } from "./validation.ts";
8
8
  import type { OwnerIdentity } from "./ownership.ts";
9
9
 
10
10
  export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent?: string; model?: string; }
11
11
 
12
+ export interface HerdrSubagentsRunParams {
13
+ tasks: HerdrSubagentsTaskParams[];
14
+ label?: string;
15
+ concurrency?: number;
16
+ background?: boolean;
17
+ }
18
+
19
+ export interface HerdrSubagentsControlToolParams {
20
+ action: "list" | "respond" | "history" | "resume" | "cleanup";
21
+ runId?: string;
22
+ answer?: string;
23
+ task?: number;
24
+ prompt?: string;
25
+ }
26
+
27
+ /** Internal superset used by shared execution helpers. */
12
28
  export interface HerdrSubagentsToolParams {
13
29
  action?: "list" | "respond" | "history" | "resume" | "cleanup";
14
30
  prompt?: string;
@@ -113,6 +129,7 @@ const actionHandlers = {
113
129
  try {
114
130
  const result = await resumeHistoricalTask(ctx.cwd, params.runId as string, params.task as number, (params.prompt as string).trim(), {
115
131
  herdr: dependencies.herdr, signal, owner: dependencies.owner, stalledWarningSeconds: discovery.settings.stalledWarningSeconds,
132
+ onUpdate: (partial) => onUpdate?.({ content: [{ type: "text", text: partial.summary }], details: partial }),
116
133
  onStalled: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, stalled: true } }),
117
134
  onCleanupError: (text) => onUpdate?.({ content: [{ type: "text", text }], details: { action: "resume", runId: params.runId, cleanupError: text } }),
118
135
  });
@@ -201,17 +218,34 @@ async function executeSingle({ params, signal, onUpdate, ctx, dependencies, disc
201
218
  }
202
219
 
203
220
  export async function executeHerdrSubagents(
204
- params: HerdrSubagentsToolParams,
221
+ params: HerdrSubagentsRunParams | HerdrSubagentsToolParams,
205
222
  signal: AbortSignal | undefined,
206
223
  onUpdate: ((response: ToolResponse) => void) | undefined,
207
224
  ctx: HerdrSubagentsToolContext,
208
225
  dependencies: HerdrSubagentsToolDependencies,
209
226
  ): Promise<ToolResponse> {
210
- const validationError = validateToolParams(params);
227
+ if (params.action) return executeHerdrSubagentsControl(params as HerdrSubagentsControlToolParams, signal, onUpdate, ctx, dependencies);
228
+ if (params.prompt && !params.tasks) {
229
+ const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
230
+ return executeSingle({ params, signal, onUpdate, ctx, dependencies, discovery });
231
+ }
232
+ const runParams = params as HerdrSubagentsRunParams;
233
+ const validationError = validateRunParams(runParams);
211
234
  if (validationError) return { content: [{ type: "text", text: validationError }], details: { errorMessage: validationError }, isError: true };
212
235
  const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
213
- const execution = { params, signal, onUpdate, ctx, dependencies, discovery };
214
- if (params.action) return actionHandlers[params.action](execution);
236
+ const execution = { params: runParams, signal, onUpdate, ctx, dependencies, discovery };
237
+ return executeBatch(execution);
238
+ }
215
239
 
216
- return params.tasks ? executeBatch(execution) : executeSingle(execution);
240
+ export async function executeHerdrSubagentsControl(
241
+ params: HerdrSubagentsControlToolParams,
242
+ signal: AbortSignal | undefined,
243
+ onUpdate: ((response: ToolResponse) => void) | undefined,
244
+ ctx: HerdrSubagentsToolContext,
245
+ dependencies: HerdrSubagentsToolDependencies,
246
+ ): Promise<ToolResponse> {
247
+ const validationError = validateControlParams(params);
248
+ if (validationError) return { content: [{ type: "text", text: validationError }], details: { errorMessage: validationError }, isError: true };
249
+ const discovery = loadSubagentConfiguration(ctx.cwd, dependencies.agentsDirectory, modelId(ctx), dependencies.configurationPaths);
250
+ return actionHandlers[params.action]({ params, signal, onUpdate, ctx, dependencies, discovery });
217
251
  }
@@ -1,6 +1,4 @@
1
- import type { HerdrSubagentsToolParams } from "./tool.ts";
2
-
3
- const executionFields = ["prompt", "agent", "label", "model", "tasks", "concurrency", "background"] as const;
1
+ import type { HerdrSubagentsControlToolParams, HerdrSubagentsRunParams } from "./tool.ts";
4
2
 
5
3
  export const HERDR_ACTIONS = {
6
4
  list: { allowed: ["action"], includeRunId: false },
@@ -8,31 +6,20 @@ export const HERDR_ACTIONS = {
8
6
  cleanup: { allowed: ["action", "runId"], includeRunId: true },
9
7
  respond: { allowed: ["action", "runId", "answer", "task"], includeRunId: true },
10
8
  resume: { allowed: ["action", "runId", "task", "prompt"], includeRunId: true },
11
- } as const satisfies Record<string, { allowed: readonly (keyof HerdrSubagentsToolParams)[]; includeRunId: boolean }>;
9
+ } as const satisfies Record<string, { allowed: readonly (keyof HerdrSubagentsControlToolParams)[]; includeRunId: boolean }>;
10
+
11
+ export function validateRunParams(params: HerdrSubagentsRunParams): string | undefined {
12
+ if (!params.tasks?.length) return "Provide a non-empty tasks list.";
13
+ return undefined;
14
+ }
12
15
 
13
- export function validateToolParams(params: HerdrSubagentsToolParams): string | undefined {
14
- const present = (key: keyof HerdrSubagentsToolParams) => params[key] !== undefined;
15
- const extraField = (allowed: readonly (keyof HerdrSubagentsToolParams)[]) => Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsToolParams));
16
- if (params.action) {
17
- const extra = extraField(HERDR_ACTIONS[params.action].allowed);
18
- if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
19
- if (params.action === "cleanup" && !params.runId?.trim()) return "runId is required for action 'cleanup'.";
20
- if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim())) return "runId and answer are required for action 'respond'.";
21
- if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
22
- return undefined;
23
- }
24
- if (params.prompt && params.tasks) return "Provide either prompt or tasks, not both.";
25
- if (params.prompt) {
26
- const extra = extraField(["prompt", "agent", "label", "model"]);
27
- if (extra) return `Field '${extra}' is not valid for single-task execution.`;
28
- return undefined;
29
- }
30
- if (params.tasks) {
31
- if (!params.tasks.length) return "tasks must contain at least one task.";
32
- const extra = extraField(["tasks", "label", "concurrency", "background"]);
33
- if (extra) return `Field '${extra}' is not valid for batch execution.`;
34
- return undefined;
35
- }
36
- const supplied = executionFields.find((key) => present(key));
37
- return supplied ? `Field '${supplied}' requires prompt or tasks.` : "Provide an action, prompt, or non-empty tasks list.";
16
+ export function validateControlParams(params: HerdrSubagentsControlToolParams): string | undefined {
17
+ const allowed = HERDR_ACTIONS[params.action]?.allowed;
18
+ if (!allowed) return "Provide a valid control action.";
19
+ const extra = Object.keys(params).find((key) => !allowed.includes(key as keyof HerdrSubagentsControlToolParams));
20
+ if (extra) return `Field '${extra}' is not valid with action '${params.action}'.`;
21
+ if (params.action === "cleanup" && !params.runId?.trim()) return "runId is required for action 'cleanup'.";
22
+ if (params.action === "respond" && (!params.runId?.trim() || !params.answer?.trim())) return "runId and answer are required for action 'respond'.";
23
+ if (params.action === "resume" && (!params.runId?.trim() || !params.task || !params.prompt?.trim())) return "runId, task, and prompt are required for action 'resume'.";
24
+ return undefined;
38
25
  }
@@ -9,6 +9,7 @@ function harness(execute: (...args: any[]) => Promise<any>) {
9
9
  const handlers = new Map<string, (...args: any[]) => any>();
10
10
  const commands: string[] = [];
11
11
  let tool: Record<string, any> | undefined;
12
+ let controlTool: Record<string, any> | undefined;
12
13
  let terminalInput: ((data: string) => { consume?: boolean } | undefined) | undefined;
13
14
  let widgetCalls = 0;
14
15
  let renderedLines: string[] = [];
@@ -26,13 +27,17 @@ function harness(execute: (...args: any[]) => Promise<any>) {
26
27
  };
27
28
  const pi = {
28
29
  on(name: string, handler: (...args: any[]) => any) { handlers.set(name, handler); },
29
- registerTool(definition: Record<string, any>) { tool = definition; },
30
+ registerTool(definition: Record<string, any>) {
31
+ if (definition.name === "herdr_subagents") tool = definition;
32
+ if (definition.name === "herdr_subagents_control") controlTool = definition;
33
+ },
30
34
  registerCommand(name: string) { commands.push(name); },
31
35
  sendMessage() {},
32
36
  };
33
37
  registerHerdrSubagents(pi as never, {
34
38
  herdr: { async focusPane(paneId: string) { focusedPanes.push(paneId); } } as never,
35
39
  execute: execute as never,
40
+ executeControl: execute as never,
36
41
  isEditor: (value) => value === editor,
37
42
  createFleetWidget(lines) { renderedLines = lines; return new Container(); },
38
43
  });
@@ -40,6 +45,7 @@ function harness(execute: (...args: any[]) => Promise<any>) {
40
45
  handlers,
41
46
  commands,
42
47
  get tool() { return tool; },
48
+ get controlTool() { return controlTool; },
43
49
  get terminalInput() { return terminalInput; },
44
50
  get widgetCalls() { return widgetCalls; },
45
51
  get renderedLines() { return renderedLines; },
@@ -73,7 +79,8 @@ async function verifyPublicUiWiring() {
73
79
  if (!responding.tool) throw new Error("herdr_subagents was not registered for UI wiring test");
74
80
  const renderedBlocked = responding.tool.renderResult({ content: [{ type: "text", text: "needs input" }], details: { agent: "explorer", status: "blocked", ok: false, summary: "Need input.", documents: [] } }, { isPartial: false }, { fg: (_color: string, text: string) => text, bold: (text: string) => text }).render(100).join("\n");
75
81
  if (!renderedBlocked.includes("blocked") || renderedBlocked.includes("failed")) throw new Error("single-task blocked result did not render its authoritative status");
76
- await responding.tool.execute("call", { action: "respond", runId: "blocked-run", answer: "main" }, undefined, undefined, { cwd, ui: responding.ui });
82
+ if (!responding.controlTool) throw new Error("herdr_subagents_control was not registered for UI wiring test");
83
+ await responding.controlTool.execute("call", { action: "respond", runId: "blocked-run", answer: "main" }, undefined, undefined, { cwd, ui: responding.ui });
77
84
  if (!responding.renderedLines.some((line) => line.includes("second") && line.includes("blocked"))) throw new Error("blocked response did not refresh Fleet rows");
78
85
  if (!responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\u001b[B")?.consume || !responding.terminalInput?.("\r")?.consume) throw new Error("Fleet terminal input was not consumed while selecting");
79
86
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -167,7 +174,7 @@ try {
167
174
  if (inactiveHerdr.tool || inactiveHerdr.commands.length || inactiveHerdr.handlers.size) throw new Error("Subagents registered with HERDR_ENV=0");
168
175
  process.env.HERDR_ENV = "1";
169
176
  const inside = harness(async () => { throw new Error("unreachable"); });
170
- if (!inside.tool || !inside.commands.includes("herdr-subagents") || !inside.handlers.has("session_start")) throw new Error("Subagents did not register inside Herdr");
177
+ if (!inside.tool || !inside.controlTool || !inside.commands.includes("herdr-subagents") || !inside.handlers.has("session_start")) throw new Error("Subagents did not register both tools inside Herdr");
171
178
  process.env.PI_HERDR_SUBAGENTS_CHILD = "1";
172
179
  const child = harness(async () => { throw new Error("unreachable"); });
173
180
  if (child.tool || child.commands.length || child.handlers.size) throw new Error("Subagents registered inside a child Agent");
@@ -183,10 +190,10 @@ try {
183
190
  }
184
191
 
185
192
  export default function (_pi: ExtensionAPI) {
186
- let tool: Record<string, any> | undefined;
193
+ const tools = new Map<string, Record<string, any>>();
187
194
  const pi = {
188
195
  on() {},
189
- registerTool(definition: Record<string, any>) { tool = definition; },
196
+ registerTool(definition: Record<string, any>) { tools.set(definition.name, definition); },
190
197
  registerCommand() {},
191
198
  sendMessage() {},
192
199
  };
@@ -207,19 +214,30 @@ export default function (_pi: ExtensionAPI) {
207
214
  if (previousChild === undefined) delete process.env.PI_HERDR_SUBAGENTS_CHILD;
208
215
  else process.env.PI_HERDR_SUBAGENTS_CHILD = previousChild;
209
216
  }
210
- if (!tool) throw new Error("herdr_subagents was not registered");
217
+ const tool = tools.get("herdr_subagents");
218
+ const controlTool = tools.get("herdr_subagents_control");
219
+ if (!tool || !controlTool) throw new Error("Herdr execution and control tools were not both registered");
211
220
  const schema = tool.parameters as Record<string, any>;
212
221
  if (schema.type !== "object" || schema.anyOf || schema.oneOf) throw new Error("herdr_subagents parameters must be one flat object schema");
213
- const required = ["action", "runId", "answer", "task", "prompt", "agent", "label", "model", "tasks", "concurrency", "background"];
214
- for (const name of required) {
222
+ for (const name of ["tasks", "label", "concurrency", "background"]) {
215
223
  if (!schema.properties?.[name]) throw new Error(`schema is missing property '${name}'`);
216
224
  if (!schema.properties[name].description) throw new Error(`schema property '${name}' has no description`);
217
225
  }
226
+ if (!schema.required?.includes("tasks")) throw new Error("herdr_subagents tasks must be required");
227
+ const normalized = tool.prepareArguments?.({ prompt: "inspect", agent: "explorer", label: "probe", background: true });
228
+ if (normalized?.tasks?.length !== 1 || normalized.tasks[0].name !== "probe" || normalized.tasks[0].prompt !== "inspect" || normalized.tasks[0].agent !== "explorer" || normalized.background !== true) {
229
+ throw new Error("legacy single-task arguments were not normalized into a one-item background run");
230
+ }
218
231
  for (const name of ["name", "prompt", "agent", "model"]) {
219
232
  const property = schema.properties.tasks.items.properties[name];
220
233
  if (!property?.description) throw new Error(`nested task property '${name}' has no description`);
221
234
  }
222
- if (!/Foreground calls block/.test(tool.description) || !/background batches return a run ID immediately/.test(tool.description)) {
235
+ const controlSchema = controlTool.parameters as Record<string, any>;
236
+ for (const name of ["action", "runId", "answer", "task", "prompt"]) {
237
+ if (!controlSchema.properties?.[name]?.description) throw new Error(`control schema property '${name}' is missing or undocumented`);
238
+ }
239
+ if (!controlSchema.required?.includes("action")) throw new Error("herdr_subagents_control action must be required");
240
+ if (!/Foreground calls block/.test(tool.description) || !/background calls return a run ID immediately/.test(tool.description)) {
223
241
  throw new Error("tool description does not distinguish foreground and background execution");
224
242
  }
225
243
  }
@@ -2,6 +2,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSyn
2
2
  import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { getEventListeners } from "node:events";
5
+ import { visibleWidth } from "@earendil-works/pi-tui";
5
6
  import { discoverPackageAgents, parseAgentMarkdown } from "../src/personas.ts";
6
7
  import { loadSubagentConfiguration } from "../src/config.ts";
7
8
  import { runHerdrSubagents } from "../src/runner.ts";
@@ -14,10 +15,11 @@ import { cancelQueuedRuns, loadQueuedRuns, persistQueuedRun } from "../src/queue
14
15
  import { cancelActiveSubagentRuns, reconcileSubagentRuns } from "../src/reconcile.ts";
15
16
  import { continueQueuedRun } from "../src/continue-run.ts";
16
17
  import { CliHerdrAutomation } from "../src/herdr.ts";
17
- import { fleetEditorHasFocus, FleetSelection, fleetLines } from "../src/fleet.ts";
18
- import { HERDR_ACTIONS, validateToolParams } from "../src/validation.ts";
18
+ import { aggregateSessionTokens, fleetEditorHasFocus, FleetSelection, fleetLines, formatFleetDuration, formatFleetTokens } from "../src/fleet.ts";
19
+ import { FleetController, FleetWidget } from "../src/fleet-controller.ts";
20
+ import { HERDR_ACTIONS, validateControlParams, validateRunParams } from "../src/validation.ts";
19
21
  import { resultPathForAttempt } from "../src/result-path.ts";
20
- import { isSettledRunStatus, readTaskRecord } from "../src/records.ts";
22
+ import { applyTaskStatus, isSettledRunStatus, projectTaskActivity, readTaskRecord } from "../src/records.ts";
21
23
  import { RunPaneAllocator } from "../src/run-pane-allocator.ts";
22
24
  import { aggregateTaskStatus } from "../src/run-status.ts";
23
25
  import { isAbortError } from "../src/errors.ts";
@@ -136,22 +138,18 @@ function latestRunDirectory() {
136
138
  }
137
139
 
138
140
  try {
139
- check("flat mode validation accepts supported calls", [
141
+ check("run validation accepts one or more tasks", validateRunParams({ tasks: [{ name: "one", prompt: "do it" }], concurrency: 2, background: true }) === undefined);
142
+ check("control validation accepts supported calls", [
140
143
  { action: "list" }, { action: "history" }, { action: "cleanup", runId: "run" },
141
144
  { action: "respond", runId: "run", answer: "yes", task: 1 },
142
145
  { action: "resume", runId: "run", task: 1, prompt: "continue" },
143
- { prompt: "one task", agent: "worker", label: "one", model: "fake/model" },
144
- { tasks: [{ name: "one", prompt: "do it" }], concurrency: 2, background: true },
145
- ].every((params) => validateToolParams(params) === undefined));
146
+ ].every((params) => validateControlParams(params) === undefined));
146
147
  check("all supported actions have shared validation and rendering metadata", ["list", "history", "cleanup", "respond", "resume"].every((action) => action in HERDR_ACTIONS));
147
- check("flat mode validation rejects ambiguous and extraneous fields", [
148
- validateToolParams({ prompt: "one", tasks: [{ name: "two", prompt: "two" }] }),
149
- validateToolParams({ action: "list", prompt: "not allowed" }),
150
- validateToolParams({ action: "respond", runId: "run" }),
151
- validateToolParams({ tasks: [{ name: "one", prompt: "one" }], agent: "worker" }),
152
- validateToolParams({ prompt: "one", runId: "run" }),
153
- validateToolParams({ prompt: "one", task: 1 }),
154
- validateToolParams({ tasks: [{ name: "one", prompt: "one" }], answer: "extra" }),
148
+ check("split validation rejects missing tasks and invalid control fields", [
149
+ validateRunParams({ tasks: [] }),
150
+ validateControlParams({ action: "list", prompt: "not allowed" }),
151
+ validateControlParams({ action: "respond", runId: "run" }),
152
+ validateControlParams({ action: "resume", runId: "run", task: 1 }),
155
153
  ].every((message) => typeof message === "string" && message.length > 0));
156
154
  const fleet = new FleetSelection();
157
155
  const fleetBatch = { label: "reviews", activity: [
@@ -164,7 +162,137 @@ try {
164
162
  check("Fleet Escape exits selection", fleet.handle("\u001b[B", "", 2).consume && fleet.handle("\u001b", "", 2).consume && !fleet.isSelecting());
165
163
  const enhancedFleet = new FleetSelection((data, key) => data === `enhanced-${key}`);
166
164
  check("Fleet delegates enhanced keyboard decoding to Pi key matching", enhancedFleet.handle("enhanced-down", "", 2).consume && enhancedFleet.handle("enhanced-up", "", 2).consume && enhancedFleet.handle("enhanced-enter", "", 2).focusTask === 2);
165
+ const shrinkingFleet = new FleetSelection();
166
+ shrinkingFleet.handle("\u001b[B", "", 3);
167
+ shrinkingFleet.handle("\u001b[A", "", 3);
168
+ check("Fleet Enter focuses the row displayed after activity shrinks", shrinkingFleet.selectedIndex(2) === 1 && shrinkingFleet.handle("\r", "", 2).focusTask === 2);
167
169
  check("Fleet ignores unknown focus, dialogs, and overlays", fleetEditorHasFocus(undefined, () => false) === false && fleetEditorHasFocus({ kind: "dialog" }, () => false) === false && fleetEditorHasFocus({ kind: "editor" }, () => true) === true);
170
+ const usage = (input, output, cacheRead, cacheWrite) => ({ input, output, cacheRead, cacheWrite, totalTokens: input + output + cacheRead + cacheWrite, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
171
+ const usageJsonl = [
172
+ { type: "message", message: { role: "assistant", usage: usage(1, 2, 3, 4) } },
173
+ { type: "usage", usage: usage(5, 6, 7, 8) },
174
+ { type: "message", message: { role: "toolResult", usage: usage(9, 10, 11, 12) } },
175
+ { type: "compaction", usage: usage(1, 1, 1, 1) },
176
+ { type: "branch_summary", usage: usage(2, 2, 2, 2) },
177
+ { type: "message", message: { role: "user", usage: usage(99, 99, 99, 99) } },
178
+ ].map(JSON.stringify).join("\n");
179
+ check("Fleet aggregates every authoritative Pi usage-bearing entry", aggregateSessionTokens(usageJsonl) === 90);
180
+ check("Fleet ignores malformed and truncated JSONL while preserving valid usage", aggregateSessionTokens(`${usageJsonl.split("\n")[0]}\nnot-json\n{\"type\":\"message\",\"message\":`) === 10);
181
+ const elapsedStart = "2026-10-08T07:00:00.000Z";
182
+ check("Fleet elapsed formatting is live and blocked duration freezes", formatFleetDuration({ index: 0, name: "live", agent: "explorer", status: "running", startedAt: elapsedStart }, Date.parse(elapsedStart) + 65_000) === "1m 5s" && formatFleetDuration({ index: 0, name: "blocked", agent: "explorer", status: "blocked", startedAt: elapsedStart, updatedAt: "2026-10-08T07:00:05.000Z" }, Date.parse(elapsedStart) + 99_000) === "5s");
183
+ const timedTask = { id: "timed", runId: "run", order: 1, name: "timed", agent: "worker", status: "queued" };
184
+ applyTaskStatus(timedTask, "running", { at: elapsedStart });
185
+ applyTaskStatus(timedTask, "blocked", { at: "2026-10-08T07:00:05.000Z" });
186
+ applyTaskStatus(timedTask, "running", { at: "2026-10-08T07:01:00.000Z" });
187
+ check("Fleet duration accumulates active work without counting blocked wait", formatFleetDuration(projectTaskActivity(timedTask), Date.parse("2026-10-08T07:01:07.000Z")) === "12s");
188
+ const legacyTimedTask = { id: "legacy", runId: "run", order: 1, name: "legacy", agent: "worker", status: "blocked", startedAt: elapsedStart, updatedAt: "2026-10-08T07:00:05.000Z" };
189
+ applyTaskStatus(legacyTimedTask, "running", { at: "2026-10-08T07:01:00.000Z" });
190
+ check("Fleet timing migrates legacy blocked records without losing prior work", formatFleetDuration(projectTaskActivity(legacyTimedTask), Date.parse("2026-10-08T07:01:07.000Z")) === "12s");
191
+ const recoveredTimedTask = { id: "recovered", runId: "run", order: 1, name: "recovered", agent: "worker", status: "running", startedAt: elapsedStart };
192
+ applyTaskStatus(recoveredTimedTask, "cancelled", { at: "2026-10-08T07:00:09.000Z" });
193
+ check("Fleet timing settles legacy recovered-running records", formatFleetDuration(projectTaskActivity(recoveredTimedTask), Date.parse("2026-10-08T08:00:00.000Z")) === "9s");
194
+ check("Fleet formats true token totals without estimates", formatFleetTokens(999) === "999 tok" && formatFleetTokens(1_250) === "1.3k tok" && formatFleetTokens(12_400) === "12k tok");
195
+ const metricFleet = new FleetSelection();
196
+ const metricBatch = { label: "audit", activity: [
197
+ { index: 0, name: "inspect auth", agent: "explorer", status: "running", startedAt: elapsedStart, sessionFile: "/tmp/live.jsonl" },
198
+ { index: 1, name: "review patch", agent: "reviewer", status: "queued", sessionFile: "/tmp/queued.jsonl" },
199
+ ] };
200
+ const metricLines = fleetLines(metricBatch, metricFleet, { width: 80, now: Date.parse(elapsedStart) + 65_000, metrics: new Map([[0, { tokens: 1_250 }], [1, { tokens: 999 }]]) });
201
+ check("Fleet rows show persona task status elapsed and right-aligned true usage", metricLines[1].includes("explorer") && metricLines[1].includes("inspect auth") && metricLines[1].includes("running") && metricLines[1].endsWith("1m 5s 1.3k tok"));
202
+ const compactLines = fleetLines({ label: "compact", activity: [
203
+ { index: 0, name: "API", agent: "explorer", status: "running", startedAt: elapsedStart },
204
+ { index: 1, name: "Recovery", agent: "explorer", status: "completed", startedAt: elapsedStart, completedAt: "2026-10-08T07:00:09.000Z" },
205
+ ] }, new FleetSelection(), { width: 160, now: Date.parse(elapsedStart) + 21_000, metrics: new Map([[0, { tokens: 4_800 }], [1, { tokens: 900 }]]) });
206
+ check("wide Fleet rows align metrics without terminal-width whitespace", visibleWidth(compactLines[1]) < 80 && compactLines[1].indexOf("tok") === compactLines[2].indexOf("tok"));
207
+ const semanticColors = [];
208
+ fleetLines({ label: "colors", activity: [
209
+ { index: 0, name: "doing", agent: "worker", status: "running" },
210
+ { index: 1, name: "done", agent: "worker", status: "completed" },
211
+ ] }, new FleetSelection(), { theme: { fg: (color, text) => { semanticColors.push([color, text]); return text; }, bold: (text) => text } });
212
+ check("Fleet uses warning yellow for running work and success green for completed work", semanticColors.some(([color, text]) => color === "warning" && text === "●") && semanticColors.some(([color, text]) => color === "success" && text === "●"));
213
+ check("queued Fleet rows fabricate neither duration nor usage", metricLines[2].includes("reviewer") && metricLines[2].includes("queued") && !metricLines[2].includes("tok") && !metricLines[2].match(/\d+[smh]/));
214
+ check("Fleet rendering fits narrow terminal widths", [1, 8, 16, 24].every((width) => fleetLines(metricBatch, metricFleet, { width, metrics: new Map([[0, { tokens: 1_250 }]]) }).every((line) => visibleWidth(line) <= width)));
215
+ let liveTokens = 42;
216
+ let nextRuntimeId = 1;
217
+ const runtimeIntervals = new Map();
218
+ const runtimeTimeouts = new Map();
219
+ const runtimeWatchers = [];
220
+ let clearedIntervals = 0;
221
+ const fleetRuntime = {
222
+ watchSession(path, onChange, onError) { const entry = { path, onChange, onError, closed: false }; runtimeWatchers.push(entry); return { close() { entry.closed = true; } }; },
223
+ setInterval(callback) { const id = nextRuntimeId++; runtimeIntervals.set(id, callback); return id; },
224
+ clearInterval(id) { if (runtimeIntervals.delete(id)) clearedIntervals += 1; },
225
+ setTimeout(callback) { const id = nextRuntimeId++; runtimeTimeouts.set(id, callback); return id; },
226
+ clearTimeout(id) { runtimeTimeouts.delete(id); },
227
+ async readTokens() { return liveTokens; },
228
+ };
229
+ let requestedFleetRenders = 0;
230
+ const liveWidgetBatch = { runId: "live", label: "live", status: "running", activity: [{ index: 0, name: "inspect", agent: "explorer", status: "running", startedAt: elapsedStart, activeStartedAt: elapsedStart, sessionFile: "/tmp/fleet-live/session.jsonl", paneId: "p1" }], prompts: [], tasks: [], documents: [], summary: "", ok: false, requestedConcurrency: 1, effectiveConcurrency: 1, recordDirectory: "/tmp/fleet-live" };
231
+ const liveWidget = new FleetWidget({ requestRender() { requestedFleetRenders += 1; } }, { fg: (_color, text) => text, bold: (text) => text }, liveWidgetBatch, new FleetSelection(), fleetRuntime);
232
+ for (const callback of [...runtimeTimeouts.values()]) callback();
233
+ runtimeTimeouts.clear();
234
+ await new Promise((resolve) => setTimeout(resolve, 0));
235
+ check("Fleet widget reads usage on demand and uses one running repaint timer", liveWidget.render(80)[1].includes("42 tok") && runtimeWatchers.length === 1 && runtimeIntervals.size === 1 && requestedFleetRenders > 0);
236
+ liveTokens = 84;
237
+ runtimeWatchers[0].onChange();
238
+ for (const callback of [...runtimeTimeouts.values()]) callback();
239
+ runtimeTimeouts.clear();
240
+ await new Promise((resolve) => setTimeout(resolve, 0));
241
+ check("Fleet widget refreshes token usage from file events", liveWidget.render(80)[1].includes("84 tok"));
242
+ liveWidget.update({ ...liveWidgetBatch, status: "blocked", activity: [{ ...liveWidgetBatch.activity[0], status: "blocked", updatedAt: "2026-10-08T07:00:05.000Z", activeStartedAt: undefined, elapsedMs: 5_000 }] });
243
+ check("Fleet widget stops elapsed repainting when work blocks", runtimeIntervals.size === 0 && clearedIntervals === 1);
244
+ liveWidget.dispose();
245
+ const watcherClosed = runtimeWatchers.every((watcher) => watcher.closed);
246
+ liveWidget.update(liveWidgetBatch);
247
+ check("Fleet widget disposal closes watchers and prevents timer restart", watcherClosed && runtimeIntervals.size === 0 && runtimeTimeouts.size === 0);
248
+ const makeFleetUi = () => {
249
+ let component;
250
+ let factoryInstalls = 0;
251
+ return {
252
+ ui: {
253
+ setWidget(_key, content) { component?.dispose?.(); component = undefined; if (typeof content === "function") { factoryInstalls += 1; component = content({ requestRender() {}, focusedComponent: undefined }, { fg: (_color, text) => text, bold: (text) => text }); } },
254
+ onTerminalInput() { return () => {}; },
255
+ getEditorText() { return ""; },
256
+ },
257
+ get component() { return component; },
258
+ get factoryInstalls() { return factoryInstalls; },
259
+ };
260
+ };
261
+ const firstFleetUi = makeFleetUi();
262
+ const secondFleetUi = makeFleetUi();
263
+ const fleetController = new FleetController({}, () => false, undefined, fleetRuntime);
264
+ fleetController.bind(firstFleetUi.ui, true);
265
+ fleetController.setActive(liveWidgetBatch);
266
+ const firstUiWidget = firstFleetUi.component;
267
+ fleetController.bind(secondFleetUi.ui, true);
268
+ fleetController.sync(liveWidgetBatch);
269
+ check("Fleet UI rebinding replaces the disposed widget in the new context", firstUiWidget !== secondFleetUi.component && secondFleetUi.factoryInstalls === 1);
270
+ fleetController.sync({ ...liveWidgetBatch, runId: "foreign", label: "foreign" });
271
+ check("foreign run updates do not replace the active Fleet owner", fleetController.active?.runId === "live" && secondFleetUi.factoryInstalls === 1);
272
+ const installsBeforeShutdown = secondFleetUi.factoryInstalls;
273
+ fleetController.shutdown();
274
+ fleetController.sync(liveWidgetBatch);
275
+ check("late updates after shutdown cannot recreate Fleet resources", secondFleetUi.factoryInstalls === installsBeforeShutdown && runtimeIntervals.size === 0 && !fleetController.active);
276
+ let resolveStaleTokens;
277
+ const staleTimeouts = new Map();
278
+ const staleWatchers = [];
279
+ let staleId = 1;
280
+ const staleRuntime = {
281
+ watchSession(path, onChange) { const watcher = { path, onChange, closed: false }; staleWatchers.push(watcher); return { close() { watcher.closed = true; } }; },
282
+ setInterval() { return staleId++; },
283
+ clearInterval() {},
284
+ setTimeout(callback) { const id = staleId++; staleTimeouts.set(id, callback); return id; },
285
+ clearTimeout(id) { staleTimeouts.delete(id); },
286
+ readTokens() { return new Promise((resolve) => { resolveStaleTokens = resolve; }); },
287
+ };
288
+ const staleWidget = new FleetWidget({ requestRender() {} }, { fg: (_color, text) => text, bold: (text) => text }, liveWidgetBatch, new FleetSelection(), staleRuntime);
289
+ for (const callback of [...staleTimeouts.values()]) callback();
290
+ staleTimeouts.clear();
291
+ staleWidget.update({ ...liveWidgetBatch, runId: "replacement", activity: [{ ...liveWidgetBatch.activity[0], sessionFile: "/tmp/fleet-replacement/session.jsonl" }] });
292
+ resolveStaleTokens(999);
293
+ await new Promise((resolve) => setTimeout(resolve, 0));
294
+ check("Fleet ignores stale token reads after session sources change", !staleWidget.render(80)[1].includes("999 tok"));
295
+ staleWidget.dispose();
168
296
  let unknownStatusRejected = false;
169
297
  try { aggregateTaskStatus(["unknown"]); } catch { unknownStatusRejected = true; }
170
298
  check("task status aggregation rejects unknown states", unknownStatusRejected);
@@ -192,6 +320,7 @@ try {
192
320
  const createRequest = herdr.calls.find(([name]) => name === "createTab")[1];
193
321
  const startRequest = herdr.calls.find(([name]) => name === "startAgent")[1];
194
322
  check("named unfocused tab in parent workspace", createRequest.workspaceId === "w1" && createRequest.cwd === root && createRequest.label === "SA · auth-review" && createRequest.focus === false);
323
+ check("ordinary execution never steals pane focus", !herdr.calls.some(([name]) => name === "focusPane"));
195
324
  check("child environment points at durable task", createRequest.env.PI_HERDR_SUBAGENTS_CHILD === "1" && createRequest.env.PI_HERDR_SUBAGENTS_TASK_DIR.includes(join(".pi", "herdr-subagents", "runs")));
196
325
  check("persistent isolated child Pi", startRequest.kind === "pi" && startRequest.paneId === "w1:p2" && startRequest.args.includes("--session") && startRequest.args.includes("--no-extensions") && startRequest.args.includes("--extension") && startRequest.args.includes("--no-skills") && startRequest.args.includes("--model") && startRequest.args.includes("fake/model") && startRequest.args.includes("--tools") && startRequest.args.includes("read,grep,subagent_report"));
197
326
  const promptRequest = herdr.calls.find(([name]) => name === "promptAgent")[1];
@@ -313,6 +442,7 @@ try {
313
442
  const counts = activityCounts(activeUpdate);
314
443
  const focusHerdr = fakeHerdr();
315
444
  const runningTask = activeUpdate.activity.find((task) => task.status === "running");
445
+ check("activity projections expose true persona task timing and session sources", runningTask.agent === "explorer" && runningTask.name === "one" && runningTask.prompt === "one slow" && typeof runningTask.startedAt === "string" && runningTask.sessionFile.endsWith("session.jsonl"));
316
446
  const focused = await focusActiveTask(activeUpdate, runningTask.index + 1, focusHerdr);
317
447
  check("active progress exposes widget counts and exact pane navigation", counts.running > 0 && counts.queued > 0 && focused && focusHerdr.calls.at(-1)[0] === "focusPane" && focusHerdr.calls.at(-1)[1].paneId === runningTask.paneId);
318
448
  const settledFocused = await focusActiveTask({ activity: [{ index: 0, name: "settled", status: "completed", paneId: "w1:p-settled" }] }, 1, focusHerdr);
@@ -552,9 +682,11 @@ try {
552
682
  const stableHistory = history.find((run) => run.id === backgroundId);
553
683
  check("history lists compact project-local runs and tasks", Boolean(stableHistory?.tasks.length) && historyText(history).includes("stable") && !historyText(history).includes("FULL stable"));
554
684
  const historyHerdr = fakeHerdr();
555
- const historicalResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "follow up" }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: historyHerdr });
685
+ const historicalUpdates = [];
686
+ const historicalResume = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "follow up" }, undefined, (update) => historicalUpdates.push(update.details), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: historyHerdr });
556
687
  const historyStartRequest = historyHerdr.calls.find(([name]) => name === "startAgent")[1];
557
- check("historical resume preserves the saved Pi session and persona prompt", !historicalResume.isError && historyHerdr.calls[0][0] === "createTab" && historyStartRequest.args.includes(stableHistory.tasks[0].sessionFile) && historyStartRequest.args.includes("fake/model") && historyStartRequest.args.some((value) => String(value).includes("subagent_report")) && existsSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json")) && readFileSync(join(stableHistory.tasks[0].recordDirectory, "followup-system-prompt.md"), "utf8").includes("Investigate the assigned question without modifying files"));
688
+ check("historical resume preserves the saved Pi session and persona prompt", !historicalResume.isError && historyHerdr.calls[0][0] === "createTab" && historyStartRequest.args.includes(stableHistory.tasks[0].sessionFile) && historyStartRequest.args.includes(stableHistory.tasks[0].model) && historyStartRequest.args.some((value) => String(value).includes("subagent_report")) && existsSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json")) && readFileSync(join(stableHistory.tasks[0].recordDirectory, "followup-system-prompt.md"), "utf8").includes("Investigate the assigned question without modifying files"));
689
+ check("historical resume streams authoritative Fleet activity", historicalUpdates.some((update) => update.status === "running" && update.activity?.[0]?.agent === "explorer" && update.activity[0].sessionFile === stableHistory.tasks[0].sessionFile));
558
690
  const historicalTaskRecord = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "task.json"), "utf8"));
559
691
  const historicalTurnRecord = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "turns", "02.json"), "utf8"));
560
692
  check("historical task and turn retain their tab identity", historicalTaskRecord.tabId === historicalResume.details.tabId && historicalTurnRecord.tabId === historicalResume.details.tabId);
@@ -622,9 +754,11 @@ try {
622
754
  check("historical pre-prompt failure replaces stale state and report", prePromptFailure.isError && prePromptFailedTask.status === "failed" && !("question" in prePromptFailedTask) && prePromptFailedTask.error.includes("historical startup failed") && prePromptFailedTask.completedAt !== "2000-01-01T00:00:00.000Z" && prePromptFailureReport.status === "failed" && prePromptFailureReport.error.includes("historical startup failed"));
623
755
  check("historical resume releases the run lock after failure", !existsSync(join(stableHistory.recordDirectory, ".resume.lock")));
624
756
  const postPromptFailureHerdr = fakeHerdr("post-fail");
625
- const postPromptFailure = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "fails after submit" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: postPromptFailureHerdr });
757
+ const postPromptFailureUpdates = [];
758
+ const postPromptFailure = await executeHerdrSubagents({ action: "resume", runId: backgroundId, task: 1, prompt: "fails after submit" }, undefined, (update) => postPromptFailureUpdates.push(update.details), { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: postPromptFailureHerdr });
626
759
  const postPromptFailureReport = JSON.parse(readFileSync(join(stableHistory.tasks[0].recordDirectory, "report.json"), "utf8"));
627
760
  check("historical post-prompt failure persists its authoritative report", postPromptFailure.isError && postPromptFailureReport.status === "failed" && postPromptFailureReport.error.includes("connection lost after prompt submission"));
761
+ check("historical resume failure emits terminal Fleet activity", postPromptFailureUpdates.some((update) => update.status === "running") && postPromptFailureUpdates.at(-1).status === "failed" && postPromptFailureUpdates.at(-1).activity.every((task) => task.status !== "running"));
628
762
  const shutdownBlockedHerdr = fakeHerdr("blocked");
629
763
  const shutdownBlocked = await executeHerdrSubagents({ tasks: [{ name: "blocked-at-shutdown", prompt: "need input", agent: "explorer" }] }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: shutdownBlockedHerdr });
630
764
  await cancelActiveSubagentRuns(root, shutdownBlockedHerdr);
@@ -678,6 +812,7 @@ try {
678
812
  const authoritativeReport = JSON.parse(readFileSync(join(authoritativeTaskDirectory, "report.json"), "utf8"));
679
813
  check("valid persisted report remains authoritative after Herdr prompt transport failure", !authoritativeAfterPromptFailure.isError && authoritativeTask.status === "completed" && authoritativeReport.status === "completed" && authoritativeAfterPromptFailure.details.tasks[0].summary === authoritativeReport.summary);
680
814
  check("production batch path never passes print mode", batchStarts.every((request) => !request.args.includes("--print")));
815
+ check("production batch path never steals pane focus", !batchHerdr.calls.some(([name]) => name === "focusPane"));
681
816
 
682
817
  const atomicFile = join(root, "atomic.json");
683
818
  await Promise.all(Array.from({ length: 12 }, (_, index) => writeJsonAtomic(atomicFile, { index, payload: "x".repeat(1000) })));
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - replace generic Todo CRUD with serialized execution-plan lifecycle commands, guarded single-step advancement, preserved replan history, canonical current-plan results, branch reconstruction tests, and a live rerendering current-plan widget
6
+
3
7
  ## 2026-09-30 - v0.1.0
4
8
 
5
9
  - 从 `@maxiaochao/pi-toolkit` 提取 todo 权威源码,支持主包捆绑和独立安装。
@@ -1,24 +1,24 @@
1
1
  # @maxiaochao/pi-todo
2
2
 
3
- Pi 的会话清单扩展,提供 `todo` 工具、自动推进的生命周期、活跃任务 widget 和 `/todos` 历史视图。
3
+ Pi 的分支本地执行计划扩展,提供 `todo` 生命周期工具、只显示当前计划的 widget 和 `/todos` 完整历史视图。
4
4
 
5
5
  ![Todo 真实状态机运行截图](docs/screenshot.png)
6
6
 
7
7
  ## 为什么有这个包
8
8
 
9
- 这是 pi-toolkit 自研的计划执行模块,用明确状态替代只存在于对话文本里的计划。它解决计划替换后旧任务残留、多个任务同时 doing、完成后不自动推进,以及 UI 序号与持久 ID 混用的问题。
9
+ 这是 pi-toolkit 自研的计划执行模块,用明确状态替代只存在于对话文本里的计划。Todo 是用户可见的后续工作顺序,不是隐藏推理、完成报告或通用任务管理器。它解决改向后历史丢失、并发调用误完成、多个步骤同时 doing、完成后不推进,以及 UI 序号与持久 ID 混用的问题。
10
10
 
11
11
  ## 演示
12
12
 
13
13
  ![Todo 生命周期演示](docs/demo.gif)
14
14
 
15
15
  ```text
16
- todo({ action: "replace", items: ["Inspect callers", "Implement", "Verify"] })
17
- todo({ action: "update", id: 1, status: "done" })
16
+ todo({ action: "plan", items: ["Inspect callers", "Implement", "Verify"] })
17
+ todo({ action: "complete_current", expected_current_id: 1 })
18
18
  /todos
19
19
  ```
20
20
 
21
- 第一项完成后下一项自动进入 `doing`;`/todos` 同时展示活跃队列和关闭历史。
21
+ 第一项实际完成后,下一项自动进入 `doing`。工具结果和 widget 只展示 `CURRENT plan`;`/todos` 展示当前分支的全部完成、取消和未完成历史。
22
22
 
23
23
  ## 安装
24
24
 
@@ -47,10 +47,20 @@ pi update npm:@maxiaochao/pi-todo # 更新独立子包
47
47
 
48
48
  ## 行为
49
49
 
50
- - `replace` 用新计划替换旧计划,并将未完成旧任务标记为取消。
51
- - `add`、`update`、`remove`、`clear` 管理任务。
52
- - 同一时间最多一个任务为 `doing`,完成后自动推进下一项。
53
- - widget 使用连续队列位置;持久记录保留稳定内部 ID。
50
+ - `plan(items)` 创建或重新规划。非空计划取消并保留全部旧 `doing`/`todo` 历史,保留已关闭历史,使用新的单调递增 ID,并启动第一步。
51
+ - `plan([])` 取消全部未完成工作,不留下当前计划;ID 不重置。
52
+ - `add_step(text)` 只追加一步。`complete_current` 和 `cancel_current` 必须携带匹配唯一 `doing` 步骤的 `expected_current_id`,否则原子失败;每次调用只推进一步。
53
+ - 调用共享可变状态并串行执行。同轮多个 sibling completion 不是支持的顺序机制。
54
+ - `list` 与每个变更都向模型返回简洁、规范的 `CURRENT plan`,包含稳定内部 ID 和状态;只有 `/todos` 显示完整历史。
55
+ - widget 从实时状态读取当前计划,使用连续显示位置、隐藏内部 ID 和关闭历史,并在计划结束时消失。
56
+
57
+ ## 使用准则
58
+
59
+ - 只为明确的多步工作、确实分阶段的工作或跨模型交接创建计划;不要为简单单步任务创建计划。
60
+ - 仅在工作实际完成后调用 `complete_current`。仍然需要但被阻塞的步骤保持当前状态。
61
+ - `cancel_current` 表示当前步骤不再需要且后续计划仍有效;方向改变时使用 `plan`。
62
+ - 保留用户给出的步骤意图和顺序,只在可执行性要求下拆分步骤。
63
+ - 协议不提供任意更新、删除、清空、撤销、批量完成或 marker 文本解析。
54
64
 
55
65
  ## 开发与测试
56
66
 
@@ -2,19 +2,21 @@
2
2
 
3
3
  ## 流程
4
4
 
5
- 1. `npm --prefix packages/todo test`:运行纯状态机确定性测试。
5
+ 1. `npm --prefix packages/todo test`:运行状态机确定性测试和扩展/widget 集成测试。
6
6
  2. `npm --prefix packages/todo run load-test`:让 Pi 加载真实扩展入口。
7
7
  3. `npm pack ./packages/todo --dry-run`:确认发布文件完整。
8
8
  4. 确认由真实状态机输出生成的 `docs/screenshot.png` 和 `docs/demo.gif` 可打开且进入 tarball。
9
9
 
10
10
  ## 用例数据
11
11
 
12
- - 恢复旧格式记录并修复非法生命周期。
13
- - `replace` 取消旧的活跃任务并建立新队列。
14
- - 完成当前任务后自动推进下一项。
15
- - 删除、清空、非法 ID 和非法状态处理。
16
- - widget 队列位置连续,内部 ID 保持稳定。
12
+ - 恢复旧格式记录、修复非法生命周期,并只从当前分支快照重建。
13
+ - 非空 `plan` 保留关闭历史、取消旧未完成步骤并使用单调新 ID;`plan([])` 留下空当前计划。
14
+ - `expected_current_id` 的 stale、重复和缺失调用原子失败;顺序完成与取消每次只推进一步。
15
+ - `add_step` 只追加,步骤文本不作为 marker 或唯一键解释。
16
+ - 并发发起的调用按提交顺序串行化,同轮重复 completion 不会跨越两个步骤。
17
+ - 已挂载 widget 从实时状态重渲染、主动请求 TUI render、隐藏内部 ID 和关闭历史,并在计划结束时消失。
18
+ - `/todos` 展示完整当前分支历史,而所有工具结果只返回规范 `CURRENT plan`。
17
19
 
18
20
  ## 最近验证结果
19
21
 
20
- 迁移基线:状态机测试全部通过,扩展可由 Pi 独立加载。发布前必须重新运行上述三步;跳过的检查不算通过。
22
+ 迁移基线:状态机和扩展集成测试全部通过,扩展可由 Pi 独立加载。发布前必须重新运行上述三步;跳过的检查不算通过。
Binary file
Binary file