pi-plans 0.5.7 → 0.7.0

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 (112) hide show
  1. package/CONTRIBUTING.md +126 -0
  2. package/README.md +49 -39
  3. package/agents/ref-analyst.md +7 -4
  4. package/agents/reviewer.md +12 -3
  5. package/index.ts +74 -40
  6. package/package.json +2 -1
  7. package/references/pi-planning-workflow.md +45 -58
  8. package/references/plan-artifact-template.md +71 -60
  9. package/references/state-and-config.md +63 -47
  10. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  11. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  12. package/scripts/run-tests.ts +12 -1
  13. package/scripts/validate.ts +22 -10
  14. package/skills/debug-and-plan/SKILL.md +4 -4
  15. package/skills/plan-big/SKILL.md +5 -5
  16. package/skills/plan-normal/SKILL.md +5 -5
  17. package/skills/plan-small/SKILL.md +5 -5
  18. package/skills/plan-with-refs/SKILL.md +8 -8
  19. package/skills/planning/SKILL.md +1 -1
  20. package/src/ask-form.ts +4 -4
  21. package/src/auditor.ts +126 -0
  22. package/src/auto-approve.ts +1 -1
  23. package/src/autocomplete.ts +19 -17
  24. package/src/code-graph/commands.ts +2 -2
  25. package/src/code-graph/community.ts +1 -1
  26. package/src/code-graph/paths.ts +1 -1
  27. package/src/code-graph/watch.ts +2 -2
  28. package/src/compaction.ts +3 -3
  29. package/src/config-command.ts +154 -76
  30. package/src/dashboard.ts +257 -0
  31. package/src/exec.ts +709 -705
  32. package/src/global-state.ts +304 -0
  33. package/src/guard.ts +16 -3
  34. package/src/messaging.ts +44 -0
  35. package/src/plan.ts +421 -112
  36. package/src/query-hook.ts +4 -4
  37. package/src/refine-prompts.ts +14 -72
  38. package/src/refine-ui-helpers.ts +24 -5
  39. package/src/refine-ui-state.ts +1 -1
  40. package/src/refine-ui.ts +1 -1
  41. package/src/resume-command.ts +40 -130
  42. package/src/resume.ts +15 -17
  43. package/src/role-panels.ts +542 -0
  44. package/src/run-context.ts +5 -4
  45. package/src/run-picker.ts +98 -0
  46. package/src/state.ts +380 -77
  47. package/src/subagent.ts +32 -1
  48. package/src/task-tool.ts +100 -0
  49. package/src/tasks.ts +189 -0
  50. package/src/thinking-levels.ts +67 -0
  51. package/src/ui-language.ts +3 -54
  52. package/src/workflow-state.ts +78 -57
  53. package/tests/analyze-refs.test.ts +35 -18
  54. package/tests/ask-choice-pros-cons.test.ts +147 -0
  55. package/tests/ask-choice-schema.test.ts +0 -12
  56. package/tests/ask-choice.test.ts +2 -49
  57. package/tests/ask-form-tool.test.ts +4 -5
  58. package/tests/ask-form.test.ts +2 -2
  59. package/tests/auditor.test.ts +111 -0
  60. package/tests/auto-approve.test.ts +7 -10
  61. package/tests/autocomplete.test.ts +8 -11
  62. package/tests/code-graph-apply-action.test.ts +2 -2
  63. package/tests/code-graph-commands.test.ts +2 -2
  64. package/tests/code-graph-index.test.ts +2 -2
  65. package/tests/code-graph-loop.e2e.test.ts +1 -1
  66. package/tests/code-graph-mutations.test.ts +1 -1
  67. package/tests/code-graph-rollback.test.ts +1 -1
  68. package/tests/code-graph-v05.test.ts +2 -2
  69. package/tests/compaction.test.ts +1 -1
  70. package/tests/config-command.test.ts +103 -100
  71. package/tests/dashboard.test.ts +268 -0
  72. package/tests/exec-lifecycle.test.ts +181 -115
  73. package/tests/exec-panel-lifecycle.test.ts +106 -251
  74. package/tests/exec.test.ts +617 -1706
  75. package/tests/execute-plan.test.ts +44 -19
  76. package/tests/extension-load.test.ts +48 -0
  77. package/tests/global-state.test.ts +371 -0
  78. package/tests/graph-aware-file-tools.test.ts +5 -5
  79. package/tests/guard.test.ts +1 -1
  80. package/tests/multi-run.test.ts +184 -0
  81. package/tests/plan.test.ts +139 -62
  82. package/tests/plans.test.ts +7 -79
  83. package/tests/refine-prompts.test.ts +20 -71
  84. package/tests/refine-resume.test.ts +27 -22
  85. package/tests/refine-ui.test.ts +6 -15
  86. package/tests/resume-lifecycle.test.ts +37 -22
  87. package/tests/resume.test.ts +43 -88
  88. package/tests/role-panels.test.ts +391 -0
  89. package/tests/run-context.test.ts +1 -1
  90. package/tests/run-ownership.test.ts +1 -1
  91. package/tests/stale-ctx.test.ts +218 -0
  92. package/tests/state.test.ts +151 -32
  93. package/tests/subagent-thinking.test.ts +65 -0
  94. package/tests/subagent-usage.test.ts +1 -1
  95. package/tests/task-tool.test.ts +61 -0
  96. package/tests/thinking-levels.test.ts +77 -0
  97. package/tests/ui-language.test.ts +2 -17
  98. package/tests/workflow-state.test.ts +17 -99
  99. package/tools/analyze-refs.ts +67 -32
  100. package/tools/ask-choice.ts +19 -49
  101. package/tools/code-graph.ts +2 -2
  102. package/tools/execute-plan.ts +63 -33
  103. package/tools/graph-aware-file-tools.ts +6 -4
  104. package/tools/plans.ts +40 -66
  105. package/tools/refine.ts +101 -164
  106. package/agents/criticizer.md +0 -18
  107. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  108. package/src/panel.ts +0 -473
  109. package/src/termination-prompt.ts +0 -73
  110. package/tests/goal-wait.test.ts +0 -269
  111. package/tests/panel-i-zero.test.ts +0 -420
  112. package/tests/panel.test.ts +0 -355
@@ -1,38 +1,59 @@
1
1
  /**
2
- * Fixed tasks' status panel lifecycle (0.4.0 feature 2, VC-006): the
3
- * aboveEditor widget registers when execution starts, refreshes from marker
4
- * updates, unregisters on complete/stop with the status line cleared, and
5
- * rebuilds after restoreFromSession. The panel never installs timers.
2
+ * Task dashboard lifecycle (v0.6.1): the aboveEditor widget registers when
3
+ * execution starts, reflects task-tool updates, renders the expanded tree on
4
+ * toggle, unregisters on stop/complete with the status line cleared, and
5
+ * rebuilds after restoreFromSession.
6
6
  */
7
7
 
8
8
  import * as assert from "node:assert/strict";
9
9
  import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
- import { spawnSync } from "node:child_process";
13
12
  import { after, before, describe, it } from "node:test";
14
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
14
  import {
16
15
  executionContextMessage,
17
- registerExecutionTurnHandlers,
16
+ getExecution,
17
+ isDashboardExpanded,
18
+ persistTaskProgress,
18
19
  restoreFromSession,
19
20
  startExecution,
20
21
  stopExecution,
22
+ toggleDashboardExpanded,
21
23
  updateStatusWidget,
22
24
  } from "../src/exec.ts";
25
+ import { applyTaskUpdate } from "../src/task-tool.ts";
26
+ import { parseChecklist, parsePlanTasks } from "../src/plan.ts";
23
27
  import { initState, startRun } from "../src/state.ts";
28
+ import { setMessagingApi } from "../src/messaging.ts";
24
29
 
25
30
  let root: string;
26
31
  let counter = 0;
27
32
 
28
33
  before(() => {
29
- root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-panel-life-"));
34
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-dash-life-"));
30
35
  });
31
36
 
32
37
  after(() => {
33
38
  fs.rmSync(root, { recursive: true, force: true });
34
39
  });
35
40
 
41
+ const PLAN = `## Tasks
42
+
43
+ - Task-1: parser — files: src/a.ts; wave: 1
44
+ - Task-2: tool — files: src/b.ts; wave: 2
45
+
46
+ ### Execution Waves
47
+
48
+ - wave 1: Task-1 — first
49
+ - wave 2: Task-2 — second
50
+
51
+ ## Verification Checks
52
+
53
+ - [ ] \`VC-001\` covers \`Task-1\`; pass condition: parser
54
+ - [ ] \`VC-002\` covers \`Task-2\`; pass condition: tool
55
+ `;
56
+
36
57
  function freshWorkdir(): string {
37
58
  counter += 1;
38
59
  const workdir = path.join(root, `repo-${counter}`);
@@ -49,55 +70,38 @@ interface PanelCall {
49
70
 
50
71
  function makeHarness(workdir: string) {
51
72
  const recorded: {
52
- status: string | undefined;
53
- statusCalls: number;
54
73
  widgets: PanelCall[];
55
- entries: Array<{ customType: string; data: unknown }>;
56
- messages: Array<{ customType: string }>;
57
- notifies: Array<{ message: string }>;
58
- } = { status: undefined, statusCalls: 0, widgets: [], entries: [], messages: [], notifies: [] };
59
- const handlers = new Map<string, Array<(event: unknown, ctx: unknown) => unknown>>();
60
- const pi = {
61
- on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => {
62
- const list = handlers.get(name) ?? [];
63
- list.push(handler);
64
- handlers.set(name, list);
65
- },
66
- registerTool: () => {},
67
- registerCommand: () => {},
68
- appendEntry: (customType: string, data: unknown) => {
69
- recorded.entries.push({ customType, data });
70
- },
71
- sendMessage: (message: { customType: string }) => {
72
- recorded.messages.push({ customType: message.customType });
73
- },
74
- } as unknown as ExtensionAPI;
74
+ status: string | undefined;
75
+ entries: Array<{ customType: string; data?: unknown; content?: string }>;
76
+ messages: string[];
77
+ } = { widgets: [], status: undefined, entries: [], messages: [] };
75
78
  const ctx = {
76
79
  cwd: workdir,
77
- hasUI: true,
78
80
  sessionManager: {},
81
+ hasUI: true,
82
+ mode: "print" as const,
79
83
  ui: {
80
- setStatus: (_key: string, value: string | undefined) => {
81
- recorded.status = value;
82
- recorded.statusCalls += 1;
84
+ setStatus: (_key: string, text: string | undefined) => {
85
+ recorded.status = text;
83
86
  },
84
87
  setWidget: (key: string, content: unknown, options?: { placement?: string }) => {
88
+ if (content === undefined) {
89
+ recorded.widgets.push({ key, content: undefined });
90
+ return;
91
+ }
85
92
  recorded.widgets.push({ key, content, placement: options?.placement });
86
93
  },
87
- notify: (message: string) => {
88
- recorded.notifies.push({ message });
89
- },
90
- theme: { fg: (_c: string, s: string) => s },
94
+ theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
91
95
  },
96
+ isIdle: () => true,
97
+ hasPendingMessages: () => false,
92
98
  } as unknown as ExtensionContext;
93
- const emit = async (name: string, event: unknown): Promise<void> => {
94
- for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
95
- };
96
- return { pi, ctx, recorded, emit };
97
- }
98
-
99
- function items(...ids: string[]) {
100
- return ids.map((id) => ({ id, text: `\`${id}\` demo item`, done: false }));
99
+ setMessagingApi({
100
+ appendEntry: (customType: string, data: unknown) => recorded.entries.push({ customType, data }),
101
+ sendMessage: (message: { customType: string; content: string }) => recorded.messages.push(message.content),
102
+ sendUserMessage: async () => {},
103
+ });
104
+ return { ctx, recorded };
101
105
  }
102
106
 
103
107
  function lastWidget(h: ReturnType<typeof makeHarness>): PanelCall | undefined {
@@ -110,238 +114,89 @@ function renderWidget(call: PanelCall | undefined, width: number): string[] {
110
114
  return factory({ theme: { fg: (_c: string, s: string) => s } }, { fg: (_c: string, s: string) => s }).render(width);
111
115
  }
112
116
 
113
- describe("exec panel lifecycle", () => {
114
- it("registers the panel above the editor when execution starts", async () => {
117
+ async function start(h: ReturnType<typeof makeHarness>, workdir: string, name = "PLAN_v1.md") {
118
+ const planPath = path.join(workdir, name);
119
+ fs.writeFileSync(planPath, PLAN, "utf8");
120
+ await startExecution(h.ctx, { planPath, planTasks: parsePlanTasks(PLAN), items: parseChecklist(PLAN) });
121
+ }
122
+
123
+ describe("dashboard lifecycle", () => {
124
+ it("registers the dashboard above the editor when execution starts", async () => {
115
125
  const workdir = freshWorkdir();
116
126
  const h = makeHarness(workdir);
117
- await startExecution(h.pi, h.ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"), [
118
- { id: "I-001", text: "First item." },
119
- { id: "I-002", text: "Second item." },
120
- ]);
127
+ await start(h, workdir);
121
128
  const call = lastWidget(h);
122
129
  assert.ok(call, "widget registered");
123
- assert.equal(call!.key, "pi-plans");
130
+ assert.equal(call!.key, "pi-plans-dashboard");
124
131
  assert.equal(call!.placement, "aboveEditor");
125
132
  const lines = renderWidget(call, 100);
126
- assert.equal(lines.length, 7);
127
- assert.ok(lines[0].includes("pi-plans"));
128
- assert.ok(lines.join("\n").includes("Next: Implement I-001"));
129
- assert.match(h.recorded.status ?? "", /plans: .* ▸ exec/);
130
- await stopExecution(h.pi, h.ctx, "done");
133
+ assert.ok(lines.join("\n").includes("Task-1"));
134
+ assert.match(h.recorded.status ?? "", /tasks 0\/2/);
135
+ await stopExecution(h.ctx, "done");
131
136
  });
132
137
 
133
- it("activity row shows run status · since-time on live execution", async () => {
138
+ it("task updates refresh the widget content and status line", async () => {
134
139
  const workdir = freshWorkdir();
135
- startRun(workdir, { topic: "live-activity", skill: "plan-small", requestText: "x" });
136
140
  const h = makeHarness(workdir);
137
- await startExecution(h.pi, h.ctx, path.join(workdir, "PLAN_vA.md"), items("VC-001", "VC-002"), [
138
- { id: "I-001", text: "First item." },
139
- ]);
141
+ await start(h, workdir);
142
+ applyTaskUpdate(getExecution()!.tasks, "Task-1", "complete", "a-tests green");
143
+ persistTaskProgress(h.ctx);
144
+ updateStatusWidget(h.ctx);
145
+ assert.match(h.recorded.status ?? "", /tasks 1\/2/);
140
146
  const lines = renderWidget(lastWidget(h), 100);
141
- assert.equal(lines.length, 7);
142
- assert.match(lines[5] ?? "", /executing · since \d{2}-\d{2} \d{2}:\d{2}/, "activity row wired from run.json");
143
- assert.ok((lines[6] ?? "").startsWith("╰"));
144
- assert.ok(!lines.join("\n").includes("markers:"), "no duplicated legend rows");
145
- await stopExecution(h.pi, h.ctx, "done");
147
+ assert.ok(lines.join("\n").includes("Task-2"), "current task advances");
148
+ await stopExecution(h.ctx, "done");
146
149
  });
147
150
 
148
- it("activity row falls back to the phase word when the run record is missing", async () => {
151
+ it("toggle switches the widget to the expanded tree view", async () => {
149
152
  const workdir = freshWorkdir();
150
- startRun(workdir, { topic: "missing-activity", skill: "plan-small", requestText: "x" });
151
153
  const h = makeHarness(workdir);
152
- await startExecution(h.pi, h.ctx, path.join(workdir, "PLAN_vB.md"), items("VC-001", "VC-002"), [
153
- { id: "I-001", text: "First item." },
154
- ]);
155
- // Simulate a lost/stale run record: stash run.json, render, restore.
156
- const runsDir = path.join(workdir, ".git", "pi_plans", "runs");
157
- const stash = path.join(os.tmpdir(), `pi-plans-runs-stash-${Date.now()}-${counter}`);
158
- fs.renameSync(runsDir, stash);
159
- try {
160
- const lines = renderWidget(lastWidget(h), 100);
161
- assert.match(lines[5] ?? "", /executing/);
162
- assert.ok(!(lines[5] ?? "").includes("since"), "no fabricated since-time without a run record");
163
- assert.ok(!(lines[5] ?? "").includes("undefined"));
164
- } finally {
165
- fs.renameSync(stash, runsDir);
166
- }
167
- await stopExecution(h.pi, h.ctx, "done");
154
+ await start(h, workdir);
155
+ toggleDashboardExpanded(h.ctx);
156
+ assert.equal(isDashboardExpanded(), true);
157
+ const lines = renderWidget(lastWidget(h), 120);
158
+ assert.ok(lines.join("\n").includes("Verification checks:"), "tree view lists checks");
159
+ toggleDashboardExpanded(h.ctx);
160
+ assert.equal(isDashboardExpanded(), false);
161
+ const compact = renderWidget(lastWidget(h), 100);
162
+ assert.ok(!compact.join("\n").includes("Verification checks:"), "compact view returns");
163
+ await stopExecution(h.ctx, "done");
168
164
  });
169
165
 
170
- it("activity row degrades to the phase word when the active pointer moves to another run", async () => {
166
+ it("clears the widget and status line on stop", async () => {
171
167
  const workdir = freshWorkdir();
172
- startRun(workdir, { topic: "first-run", skill: "plan-small", requestText: "x" });
173
168
  const h = makeHarness(workdir);
174
- await startExecution(h.pi, h.ctx, path.join(workdir, "PLAN_vC.md"), items("VC-001", "VC-002"), [
175
- { id: "I-001", text: "First item." },
176
- ]);
177
- const before = renderWidget(lastWidget(h), 100);
178
- assert.match(before[5] ?? "", /executing · since \d{2}-\d{2} \d{2}:\d{2}/, "activity resolves while the pointer agrees");
179
- // A second run claims the shared disk pointer mid-execution, and the
180
- // session is replaced (binding identity mismatch → resolve falls to the
181
- // disk pointer): the activity row must degrade instead of mixing the
182
- // new run's topic with the old run's status (impl-review r1 F-001).
183
- startRun(workdir, { topic: "second-run", skill: "plan-small", requestText: "y" });
184
- const prevSessionManager = (h.ctx as { sessionManager: unknown }).sessionManager;
185
- (h.ctx as { sessionManager: unknown }).sessionManager = {};
186
- try {
187
- const after = renderWidget(lastWidget(h), 100);
188
- assert.match(after[5] ?? "", /executing/);
189
- assert.ok(!(after[5] ?? "").includes("since"), "no mixed-run status when the pointer moved");
190
- } finally {
191
- (h.ctx as { sessionManager: unknown }).sessionManager = prevSessionManager;
192
- }
193
- await stopExecution(h.pi, h.ctx, "done");
194
- });
195
-
196
- it("refreshes panel content from marker updates without re-registering", async () => {
197
- const workdir = freshWorkdir();
198
- const h = makeHarness(workdir);
199
- registerExecutionTurnHandlers(h.pi);
200
- await startExecution(h.pi, h.ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"), [
201
- { id: "I-001", text: "First item." },
202
- { id: "I-002", text: "Second item." },
203
- ]);
204
- await h.emit("turn_end", {
205
- message: { role: "assistant", content: [{ type: "text", text: "starting [I-002:current]" }] },
206
- });
207
- // The factory closure reads live state: render picks up the new focus.
208
- const lines = renderWidget(lastWidget(h), 100);
209
- assert.ok(lines.join("\n").includes("I-002"), "panel content follows the current-I marker");
210
- assert.equal(h.recorded.widgets.length, 1, "no duplicate registrations across updates");
211
- await stopExecution(h.pi, h.ctx, "done");
212
- });
213
-
214
- it("unregisters the panel and clears the execution summary on stop", async () => {
215
- const workdir = freshWorkdir();
216
- const h = makeHarness(workdir);
217
- await startExecution(h.pi, h.ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001"));
218
- await stopExecution(h.pi, h.ctx, "test-stop");
169
+ await start(h, workdir);
170
+ await stopExecution(h.ctx, "user stop");
219
171
  const call = lastWidget(h);
220
- assert.ok(call, "clear call recorded");
221
- assert.equal(call!.key, "pi-plans");
222
- assert.equal(call!.content, undefined, "widget cleared");
223
- });
224
-
225
- it("rebuilds the panel from a session snapshot on restore", async () => {
226
- const workdir = freshWorkdir();
227
- startRun(workdir, { topic: "restore-topic", skill: "plan-small", requestText: "x" });
228
- fs.writeFileSync(path.join(workdir, "PLAN_v9.md"), "# plan");
229
- const h = makeHarness(workdir);
230
- // A prior session persisted an exec snapshot; restore must rebind the UI.
231
- await restoreFromSession(
232
- h.pi,
233
- h.ctx,
234
- [
235
- { type: "custom", customType: "pi-plans-exec", data: {
236
- planPath: path.join(workdir, "PLAN_v9.md"),
237
- items: items("VC-001", "VC-002"),
238
- implItems: [{ id: "I-001", text: "First." }],
239
- startedAt: new Date().toISOString(),
240
- usage: { inToks: 0, outToks: 0 },
241
- } },
242
- ] as never,
243
- );
244
- const lines = renderWidget(lastWidget(h), 100);
245
- assert.equal(lines.length, 7, "panel rebuilt after restore");
246
- assert.match(h.recorded.status ?? "", /plans: restore-topic ▸ exec/);
247
- assert.match(lines[5] ?? "", /· since \d{2}-\d{2} \d{2}:\d{2}/, "restored panel rebinds the activity row to run.json");
248
- await stopExecution(h.pi, h.ctx, "done");
172
+ assert.ok(call && call.content === undefined, "widget unregistered");
249
173
  });
250
174
 
251
- it("injection text shares the panel's next action (VC-007 same-source)", async () => {
175
+ it("rebuilds the dashboard after restoreFromSession", async () => {
252
176
  const workdir = freshWorkdir();
177
+ startRun(workdir, { topic: "restore", skill: "plan-small", requestText: "x" });
253
178
  const h = makeHarness(workdir);
254
- registerExecutionTurnHandlers(h.pi);
255
- await startExecution(h.pi, h.ctx, path.join(workdir, "PLAN_v5.md"), items("VC-001", "VC-002"), [
256
- { id: "I-001", text: "First item." },
257
- { id: "I-002", text: "Second item." },
258
- ]);
259
- await h.emit("turn_end", {
260
- message: { role: "assistant", content: [{ type: "text", text: "starting [I-002:current]" }] },
261
- });
262
- const rules = executionContextMessage(h.ctx)!;
263
- assert.ok(rules.includes("Suggested next action"), "injection carries the next-action line");
264
- assert.ok(rules.includes("Implement I-002"), "injection matches the panel's derived action");
265
- const panelLines = renderWidget(lastWidget(h), 100).join("\n");
266
- assert.ok(panelLines.includes("Next: Implement I-002"), "panel shows the same action");
267
- await stopExecution(h.pi, h.ctx, "done");
179
+ await start(h, workdir);
180
+ applyTaskUpdate(getExecution()!.tasks, "Task-1", "complete", "e1");
181
+ persistTaskProgress(h.ctx);
182
+ const snapshot = getExecution();
183
+ await stopExecution(h.ctx, "restart");
184
+ assert.equal(getExecution(), null);
185
+ const h2 = makeHarness(workdir);
186
+ await restoreFromSession(h2.ctx, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
187
+ assert.ok(getExecution(), "execution restored");
188
+ const lines = renderWidget(lastWidget(h2), 100);
189
+ assert.ok(lines.join("\n").includes("Task-2"), "restored view shows the open task");
190
+ await stopExecution(h2.ctx, "done");
268
191
  });
269
192
 
270
- it("ignores widget work entirely when the host lacks setWidget", async () => {
193
+ it("injection names the current wave and the task tool", async () => {
271
194
  const workdir = freshWorkdir();
272
195
  const h = makeHarness(workdir);
273
- (h.ctx as unknown as { ui: Record<string, unknown> }).ui.setWidget = undefined;
274
- await startExecution(h.pi, h.ctx, path.join(workdir, "PLAN_v4.md"), items("VC-001"));
275
- assert.equal(h.recorded.widgets.length, 0, "capability guard skips widget registration");
276
- assert.match(h.recorded.status ?? "", /plans: .* ▸ exec/);
277
- });
278
- });
279
- describe("implementation-review loop widget (0.5.4, D-3/D-8)", () => {
280
- it("stays alive after completion while the loop is live, then unregisters on completed", async () => {
281
- const workdir = freshWorkdir();
282
- const h = makeHarness(workdir);
283
- // A previous test leaves a stray execution alive (module state); clear
284
- // it so the loop widget's null-execution branch is reachable.
285
- await stopExecution(h.pi, h.ctx, "cleanup-before-loop-test");
286
- spawnSync("git", ["init"], { cwd: workdir });
287
- const { startRun, setRunStatus } = await import("../src/state.ts");
288
- const { run } = startRun(workdir, { topic: "loop-widget", skill: "plan-big", requestText: "t" });
289
- const { createCheckpoint, mutateCheckpoint, applyImplementationReviewConfigured, applyCompleted } = await import(
290
- "../src/workflow-state.ts"
291
- );
292
- createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
293
- mutateCheckpoint(workdir, run.run_id, (cp) =>
294
- applyImplementationReviewConfigured(
295
- { ...cp, phase: "implementation-review", nextAction: "ask-question" },
296
- "1 round",
297
- 3,
298
- ),
299
- );
300
- setRunStatus(workdir, run.run_id, "done");
301
-
302
- updateStatusWidget(h.ctx);
303
- const call = lastWidget(h);
304
- assert.ok(call && call.content !== undefined, "loop widget registered while the loop is live");
305
- assert.equal(call!.key, "pi-plans");
306
- const lines = renderWidget(call, 80);
307
- assert.equal(lines.length, 4, "compact 4-line loop box");
308
- assert.match(lines[1], /impl-review . round 1 . 3 reviewers/);
309
- assert.match(lines[2], /until: 1 round/);
310
- assert.match(h.recorded.status ?? "", /plans: impl-review round 1 . 3 reviewers/);
311
-
312
- // Round progress: completedRounds bump renders on the next refresh
313
- // (D-8 live reads).
314
- const { applyReviewRoundStarted, applyLaneResult, writeReviewOutput, applyReviewConsolidated, applyImplementationRoundFinished } =
315
- await import("../src/workflow-state.ts");
316
- mutateCheckpoint(workdir, run.run_id, (cp) =>
317
- applyReviewRoundStarted(cp, {
318
- roundId: "impl-reviewer-r1",
319
- role: "reviewer",
320
- target: "implementation",
321
- reviewers: 3,
322
- lanes: [{ laneId: "l1" }, { laneId: "l2" }, { laneId: "l3" }],
323
- }),
324
- );
325
- const loadNow = await import("../src/workflow-state.ts");
326
- const loadedNow = loadNow.loadCheckpoint(workdir, run.run_id);
327
- assert.ok(loadedNow.status === "ok");
328
- let cpNow = loadedNow.checkpoint;
329
- for (const lane of ["l1", "l2", "l3"]) {
330
- const file = writeReviewOutput(workdir, run.run_id, "impl-reviewer-r1", lane, "out");
331
- cpNow = applyLaneResult(cpNow, "impl-reviewer-r1", lane, { ok: true, resultFile: file });
332
- }
333
- mutateCheckpoint(workdir, run.run_id, () =>
334
- applyImplementationRoundFinished(applyReviewConsolidated(cpNow, "impl-reviewer-r1")),
335
- );
336
- updateStatusWidget(h.ctx);
337
- const refreshed = renderWidget(lastWidget(h), 80);
338
- assert.match(refreshed[1], /round 2/, "live checkpoint reads advance the round");
339
-
340
- // Completion unregisters the widget and restores the (done) line.
341
- mutateCheckpoint(workdir, run.run_id, (cp) => applyCompleted({ ...cp, nextAction: "finish-review" }, "1 round disposed; termination condition met"));
342
- updateStatusWidget(h.ctx);
343
- const lastCall = lastWidget(h);
344
- assert.ok(lastCall && lastCall.content === undefined, "widget unregistered after completion");
345
- assert.match(h.recorded.status ?? "", /\(done\)/);
196
+ await start(h, workdir);
197
+ const injection = executionContextMessage(h.ctx)!;
198
+ assert.match(injection, /Current wave 1 open tasks:/);
199
+ assert.match(injection, /plans_update_task/);
200
+ await stopExecution(h.ctx, "done");
346
201
  });
347
202
  });