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
@@ -0,0 +1,184 @@
1
+ import * as assert from "node:assert/strict";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { after, before, describe, it } from "node:test";
6
+
7
+ import {
8
+ initState,
9
+ listRuns,
10
+ newestNonTerminalRun,
11
+ latestRun,
12
+ readActive,
13
+ setRunStatus,
14
+ startRun,
15
+ TERMINAL_RUN_STATUSES,
16
+ } from "../src/state.ts";
17
+ import { resolveActiveRun, resetRunBindingForTests } from "../src/run-context.ts";
18
+ import { planningWriteBlockReason } from "../src/guard.ts";
19
+ import { executionCandidates, abandonCandidates, runPickerLabel } from "../src/run-picker.ts";
20
+ import { subagentChildEnv } from "../src/subagent.ts";
21
+ import {
22
+ getExecution,
23
+ startExecution,
24
+ stopExecution,
25
+ } from "../src/exec.ts";
26
+
27
+ let tmpRoot: string;
28
+ let counter = 0;
29
+
30
+ before(() => {
31
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-multi-run-"));
32
+ });
33
+
34
+ after(() => {
35
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
36
+ resetRunBindingForTests();
37
+ delete process.env.PI_PLANS_EXECUTOR;
38
+ delete process.env.PI_PLANS_RUN_ID;
39
+ });
40
+
41
+ function freshWorkdir(): string {
42
+ counter += 1;
43
+ const workdir = path.join(tmpRoot, `repo-${counter}`);
44
+ fs.mkdirSync(workdir, { recursive: true });
45
+ return workdir;
46
+ }
47
+
48
+ function fakeCtx(workdir: string, extra: Record<string, unknown> = {}): any {
49
+ return {
50
+ cwd: workdir,
51
+ sessionManager: { id: `session-${counter}` },
52
+ ui: {
53
+ setStatus: () => {},
54
+ notify: () => {},
55
+ theme: { fg: (_k: string, t: string) => t, bold: (t: string) => t },
56
+ },
57
+ mode: "noninteractive",
58
+ ...extra,
59
+ };
60
+ }
61
+
62
+ describe("run registry (v0.6.0)", () => {
63
+ it("listRuns sorts newest-first, skips corrupt run dirs, and survives partial state", () => {
64
+ const workdir = freshWorkdir();
65
+ initState(workdir);
66
+ const first = startRun(workdir, { topic: "first", skill: "plan-small", requestText: "a" }).run;
67
+ const second = startRun(workdir, { topic: "second", skill: "plan-big", requestText: "b" }).run;
68
+ // Corrupt a third run's run.json: the scan must skip it, never throw.
69
+ const stateRoot = path.join(workdir, ".git", "pi-plans");
70
+ fs.mkdirSync(path.join(stateRoot, "runs", "corrupt-run-id"), { recursive: true });
71
+ fs.writeFileSync(path.join(stateRoot, "runs", "corrupt-run-id", "run.json"), "{ broken", "utf8");
72
+
73
+ const runs = listRuns(workdir);
74
+ const ids = runs.map((run) => run.run_id);
75
+ assert.equal(ids.includes(first.run_id), true);
76
+ assert.equal(ids.includes(second.run_id), true);
77
+ assert.equal(ids.includes("corrupt-run-id"), false);
78
+ assert.equal(runs.indexOf(runs.find((r) => r.run_id === second.run_id)!), 0, "newest first");
79
+ assert.equal(runs[0]!.skill, "plan-big");
80
+ });
81
+
82
+ it("start-run no longer writes the shared active.json pointer", () => {
83
+ const workdir = freshWorkdir();
84
+ initState(workdir);
85
+ startRun(workdir, { topic: "pointerless", skill: "plan-small", requestText: "x" });
86
+ const activePath = path.join(workdir, ".git", "pi-plans", "active.json");
87
+ assert.equal(fs.existsSync(activePath), false, "registry workdirs keep no shared pointer");
88
+ });
89
+
90
+ it("readActive = newest NON-terminal run; null when every run is terminal", () => {
91
+ const workdir = freshWorkdir();
92
+ initState(workdir);
93
+ const only = startRun(workdir, { topic: "only", skill: "plan-small", requestText: "x" }).run;
94
+ assert.equal(readActive(workdir)?.run_id, only.run_id);
95
+ setRunStatus(workdir, only.run_id, "done");
96
+ assert.equal(readActive(workdir), null, "terminal-only workdir resolves no active run");
97
+ assert.equal(newestNonTerminalRun(workdir), null);
98
+ assert.equal(latestRun(workdir)?.run_id, only.run_id, "display-only latest keeps terminal runs");
99
+ });
100
+
101
+ it("readActive falls back to a legacy active.json only when the scan finds nothing", () => {
102
+ const workdir = freshWorkdir();
103
+ initState(workdir);
104
+ const stateRoot = path.join(workdir, ".git", "pi-plans");
105
+ fs.mkdirSync(path.join(stateRoot, "runs", "legacy-run"), { recursive: true });
106
+ // No run.json at all → scan finds nothing → legacy pointer honored.
107
+ const activePath = path.join(stateRoot, "active.json");
108
+ fs.writeFileSync(
109
+ activePath,
110
+ JSON.stringify({ run_id: "legacy-run", run_dir: path.join(stateRoot, "runs", "legacy-run"), artifact_dir: path.join(workdir, "docs") }),
111
+ "utf8",
112
+ );
113
+ assert.equal(readActive(workdir)?.run_id, "legacy-run");
114
+ fs.rmSync(activePath);
115
+ assert.equal(readActive(workdir), null);
116
+ });
117
+
118
+ it("parallel start-runs in one workdir get distinct ids and dirs", () => {
119
+ const workdir = freshWorkdir();
120
+ initState(workdir);
121
+ const a = startRun(workdir, { topic: "same-topic", skill: "plan-small", requestText: "x" }).run;
122
+ const b = startRun(workdir, { topic: "same-topic", skill: "plan-small", requestText: "y" }).run;
123
+ assert.notEqual(a.run_id, b.run_id);
124
+ assert.notEqual(a.artifact_dir, b.artifact_dir);
125
+ assert.equal(listRuns(workdir).length, 2);
126
+ });
127
+ });
128
+
129
+ describe("multi-run resolution and guard", () => {
130
+
131
+
132
+ });
133
+
134
+ describe("run picker candidates", () => {
135
+ it("executionCandidates needs a plan file and non-terminal status; abandonCandidates takes all non-terminal", () => {
136
+ const workdir = freshWorkdir();
137
+ initState(workdir);
138
+ const withPlan = startRun(workdir, { topic: "with-plan", skill: "plan-small", requestText: "a" }).run;
139
+ fs.writeFileSync(path.join(withPlan.artifact_dir, "PLAN_v1.md"), "# p\n\n## Verifier Checklist\n\n- [ ] `VC-001` x\n");
140
+ startRun(workdir, { topic: "no-plan", skill: "plan-small", requestText: "b" });
141
+ const done = startRun(workdir, { topic: "done", skill: "plan-small", requestText: "c" }).run;
142
+ setRunStatus(workdir, done.run_id, "done");
143
+
144
+ const exec = executionCandidates(workdir);
145
+ assert.deepEqual(exec.map((run) => run.topic), ["with-plan"]);
146
+ const abandon = abandonCandidates(workdir);
147
+ assert.equal(abandon.length, 2, "with-plan + no-plan are abandonable; done is not");
148
+ assert.equal(abandon.every((run) => !TERMINAL_RUN_STATUSES.has(run.status)), true);
149
+ });
150
+
151
+ it("labels are descriptive, width-fitted, and mark the recommended run", () => {
152
+ const run = {
153
+ run_id: "20260926T000000Z-demo",
154
+ topic: "demo-topic",
155
+ skill: "plan-big",
156
+ status: "planning",
157
+ created_at: "2026-09-26T00:00:00Z",
158
+ updated_at: "2026-09-26T00:00:00Z",
159
+ artifact_dir: "/tmp/x",
160
+ };
161
+ const label = runPickerLabel(run, true);
162
+ assert.match(label, /^★ demo-topic · planning · plan-big · 2026-09-26T00:00:00Z/);
163
+ const long = { ...run, topic: "x".repeat(200) };
164
+ assert.ok(runPickerLabel(long, false).length <= 200, "long topics truncate");
165
+ });
166
+ });
167
+
168
+ describe("subagent child env markers", () => {
169
+ it("refiner (default) sets PI_PLANS_REFINER and strips executor keys", () => {
170
+ const env = subagentChildEnv({}, { PI_PLANS_EXECUTOR: "1", PI_PLANS_RUN_ID: "r", PATH: "/bin" });
171
+ assert.equal(env.PI_PLANS_REFINER, "1");
172
+ assert.equal(env.PI_PLANS_EXECUTOR, undefined);
173
+ assert.equal(env.PI_PLANS_RUN_ID, undefined);
174
+ });
175
+
176
+
177
+ it("none clears every marker", () => {
178
+ const env = subagentChildEnv({ envMarker: "none" }, { PI_PLANS_REFINER: "1", PI_PLANS_EXECUTOR: "1", PI_PLANS_RUN_ID: "r" });
179
+ assert.equal(env.PI_PLANS_REFINER, undefined);
180
+ assert.equal(env.PI_PLANS_EXECUTOR, undefined);
181
+ assert.equal(env.PI_PLANS_RUN_ID, undefined);
182
+ });
183
+ });
184
+
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { after, before, describe, it } from "node:test";
8
- import { latestPlanVersion, nextPlanVersionPath, parseChecklist, parseImplItems, resolveImplStatuses, scanCurrentIMarkers, scanDoneMarkers, scanImplMarkers, shortImplDescription, extractCoverage, resolveCurrentI, inferCurrentI } from "../src/plan.ts";
8
+ import { latestPlanVersion, nextPlanVersionPath, parseChecklist, parseImplItems, extractCoverage, parsePlanTasks, resolveTaskWaves, extractTaskCoverage, normalizeTaskId, checklistHeaderName, lintPlanTasks } from "../src/plan.ts";
9
9
 
10
10
  const PLAN = `# PLAN_v1 - demo
11
11
 
@@ -40,14 +40,6 @@ describe("plan parsing", () => {
40
40
  assert.equal(parseChecklist("# no checklist here\n\n- [ ] `VC-001` orphan\n").length, 0);
41
41
  });
42
42
 
43
- it("scans done markers", () => {
44
- assert.deepEqual(scanDoneMarkers("done [DONE:VC-001] and [DONE:VC-003], plus [DONE:VC-001] again"), [
45
- "VC-001",
46
- "VC-003",
47
- "VC-001",
48
- ]);
49
- assert.deepEqual(scanDoneMarkers("nothing here"), []);
50
- });
51
43
  });
52
44
 
53
45
  describe("latestPlanVersion", () => {
@@ -135,64 +127,149 @@ describe("implementation items", () => {
135
127
  assert.equal(parseImplItems("# no items here\n- `I-001`: orphan\n").length, 0);
136
128
  });
137
129
 
138
- it("shortens descriptions at sentence or semicolon boundaries and caps length", () => {
139
- assert.equal(shortImplDescription("Add helpers; evaluate percent. Then more."), "Add helpers");
140
- assert.equal(shortImplDescription("First sentence. Second one."), "First sentence.");
141
- const long = "x".repeat(120);
142
- assert.equal(shortImplDescription(long).length, 80);
143
- assert.match(shortImplDescription(long), /…$/);
144
- });
145
130
 
146
131
  it("extracts coverage refs before the first semicolon only", () => {
147
132
  assert.deepEqual(extractCoverage("`VC-001` covers `I-001` and `I-002`; pass: `I-003` mentioned late"), ["I-001", "I-002"]);
148
133
  assert.deepEqual(extractCoverage("no coverage clause here"), []);
149
134
  });
150
135
 
151
- it("parses and resolves current-I markers without changing progress marker states", () => {
152
- const implItems = [{ id: "I-001", text: "first" }, { id: "I-002", text: "second" }];
153
- assert.deepEqual(scanCurrentIMarkers("[I-001:current] [I-999:current] [I-002:current]"), [
154
- { id: "I-001" },
155
- { id: "I-999" },
156
- { id: "I-002" },
157
- ]);
158
- assert.equal(resolveCurrentI(implItems, scanCurrentIMarkers("[I-999:current] [I-002:current]")), "I-002");
159
- assert.equal(inferCurrentI(implItems, [], undefined), "I-001");
160
- });
161
- it("scans impl markers", () => {
162
- assert.deepEqual(scanImplMarkers("[I-001:implemented] then [I-002:validating] and [I-003:done]"), [
163
- { id: "I-001", state: "implemented" },
164
- { id: "I-002", state: "validating" },
165
- ]);
166
- });
167
-
168
- it("resolves the status matrix with vc-passed > marker > derivation", () => {
169
- const items = parseChecklist(IMPL_PLAN);
170
- const implItems = parseImplItems(IMPL_PLAN);
171
-
172
- // No markers, nothing done: frontier I-001 implementing, I-002 pending.
173
- assert.deepEqual(resolveImplStatuses(implItems, items, undefined), {
174
- "I-001": "implementing",
175
- "I-002": "pending",
176
- });
177
-
178
- // Explicit marker wins over derivation.
179
- assert.deepEqual(resolveImplStatuses(implItems, items, { "I-002": "implemented" }), {
180
- "I-001": "implementing",
181
- "I-002": "implemented",
182
- });
183
-
184
- // Some covering VC done without a marker: deriving validating.
185
- items[1]!.done = true;
186
- assert.deepEqual(resolveImplStatuses(implItems, items, {}), {
187
- "I-001": "implementing",
188
- "I-002": "validating",
189
- });
190
-
191
- // All covering VCs done → vc-passed beats the marker.
192
- items[0]!.done = true;
193
- assert.deepEqual(resolveImplStatuses(implItems, items, { "I-002": "validating" }), {
194
- "I-001": "vc-passed",
195
- "I-002": "vc-passed",
196
- });
136
+
137
+ });
138
+
139
+ const TASK_PLAN = `# PLAN_v1 - demo
140
+
141
+ ## Tasks
142
+
143
+ - Task-1: 解析器主体 — files: src/plan.ts; wave: 1
144
+ - Task-2: 单测 — files: tests/plan.test.ts; wave: 1
145
+ - Task-3: 执行核心 — deps: Task-1,Task-2; files: src/exec.ts、src/tasks.ts; wave: 2
146
+ - Task-3.1: 注入骨架 — deps: Task-1; files: src/exec.ts
147
+ - Task-3.2: 状态机 — files: src/tasks.ts
148
+ - Task-4: 文档 — deps: Task-3
149
+
150
+ ### Execution Waves
151
+
152
+ - wave 1: Task-1, Task-2 — 文件集不相交可并行
153
+ - wave 2: Task-3 — 依赖前波
154
+ - wave 3: Task-4 — 收尾
155
+
156
+ ## Verification Checks
157
+
158
+ - [ ] \`VC-001\` covers \`Task-1\` and \`Task-2\`; pass condition: 解析单测全绿; metric: 100%。
159
+ - [x] \`VC-002\` covers \`Task-3.1\`; pass condition: 注入正确; metric: 通过。
160
+
161
+ ## Risks And Mitigations
162
+ `;
163
+
164
+ describe("task-tree parsing (v0.6.1)", () => {
165
+ it("parses tasks with inline fields, tolerating full-width separators", () => {
166
+ const parsed = parsePlanTasks(TASK_PLAN);
167
+ assert.equal(parsed.legacy, false);
168
+ assert.equal(parsed.tasks.length, 4);
169
+ const t3 = parsed.tasks[2];
170
+ assert.equal(t3?.id, "Task-3");
171
+ assert.deepEqual(t3?.deps, ["Task-1", "Task-2"]);
172
+ assert.deepEqual(t3?.files, ["src/exec.ts", "src/tasks.ts"]);
173
+ assert.equal(t3?.wave, 2);
174
+ assert.equal(t3?.children.length, 2);
175
+ assert.equal(t3?.children[0]?.id, "Task-3.1");
176
+ assert.deepEqual(t3?.children[0]?.deps, ["Task-1"]);
177
+ const t4 = parsed.tasks[3];
178
+ assert.equal(t4?.deps.length, 1);
179
+ assert.equal(t4?.wave, undefined);
180
+ });
181
+
182
+ it("parses the Execution Waves subsection with rationale", () => {
183
+ const parsed = parsePlanTasks(TASK_PLAN);
184
+ assert.equal(parsed.waves.length, 3);
185
+ assert.deepEqual(parsed.waves[0], { wave: 1, taskIds: ["Task-1", "Task-2"], rationale: "文件集不相交可并行" });
186
+ assert.deepEqual(parsed.waves[1]?.taskIds, ["Task-3"]);
187
+ });
188
+
189
+ it("accepts the canonical and legacy checklist headers, and task coverage", () => {
190
+ assert.equal(checklistHeaderName(TASK_PLAN), "Verification Checks");
191
+ const items = parseChecklist(TASK_PLAN);
192
+ assert.equal(items.length, 2);
193
+ assert.equal(items[1]?.done, true);
194
+ assert.deepEqual(extractTaskCoverage(items[0]?.text ?? ""), ["Task-1", "Task-2"]);
195
+ assert.deepEqual(extractTaskCoverage("`VC-009` covers `I-002` and `I-003`; pass: ok"), ["Task-2", "Task-3"]);
196
+ assert.equal(checklistHeaderName("## Verifier Checklist\n"), "Verifier Checklist");
197
+ assert.equal(parseChecklist("## Verifier Checklist\n- [x] \`VC-001\` covers \`I-001\`; ok\n").length, 1);
198
+ });
199
+
200
+ it("falls back to legacy I-### parsing with normalized ids", () => {
201
+ const parsed = parsePlanTasks("## Implementation Items\n\n- \`I-001\`: First.\n- \`I-002\`:Second.\n");
202
+ assert.equal(parsed.legacy, true);
203
+ assert.deepEqual(parsed.tasks.map((t) => t.id), ["Task-1", "Task-2"]);
204
+ assert.equal(parsed.tasks[0]?.title, "First.");
205
+ assert.equal(parsed.waves.length, 0);
206
+ // No Tasks section and no Implementation Items → empty non-legacy model.
207
+ const empty = parsePlanTasks("# nothing\n");
208
+ assert.equal(empty.tasks.length, 0);
209
+ assert.equal(empty.legacy, false);
210
+ });
211
+
212
+ it("resolves effective waves: subsection > inline > derived from deps", () => {
213
+ const parsed = parsePlanTasks(TASK_PLAN);
214
+ const waves = resolveTaskWaves(parsed);
215
+ assert.equal(waves.get("Task-1"), 1);
216
+ assert.equal(waves.get("Task-2"), 1);
217
+ assert.equal(waves.get("Task-3"), 2);
218
+ // Task-4 has no inline wave; the subsection lists it under wave 3.
219
+ assert.equal(waves.get("Task-4"), 3);
220
+ // Subtasks inherit the parent's effective wave (Task-3 → 2).
221
+ assert.equal(waves.get("Task-3.1"), 2);
222
+ assert.equal(waves.get("Task-3.2"), 2);
223
+ });
224
+
225
+ it("normalizes task ids from messy input", () => {
226
+ assert.equal(normalizeTaskId("I-001"), "Task-1");
227
+ assert.equal(normalizeTaskId("task-03.1"), "Task-3.1");
228
+ assert.equal(normalizeTaskId("`Task-12`"), "Task-12");
229
+ assert.equal(normalizeTaskId("nope"), null);
230
+ });
231
+
232
+ it("lints clean plans as null and flags drift", () => {
233
+ assert.equal(lintPlanTasks(TASK_PLAN), null);
234
+ assert.equal(lintPlanTasks("# no tasks\n"), null);
235
+ const drifted = "## Tasks\n\n- nothing parseable here\n";
236
+ assert.ok(lintPlanTasks(drifted)?.includes("0 项"));
237
+ const deep = "## Tasks\n\n- Task-1: a\n - Task-1.1.1: too deep\n";
238
+ assert.ok(lintPlanTasks(deep)?.includes("层级过深"));
239
+ const unknownDep = "## Tasks\n\n- Task-2: b — deps: Task-9\n";
240
+ assert.ok(lintPlanTasks(unknownDep)?.includes("引用未知任务"));
241
+ const sharedFile = "## Tasks\n\n- Task-1: a — files: src/a.ts; wave: 1\n- Task-2: b — files: src/a.ts; wave: 1\n";
242
+ assert.ok(lintPlanTasks(sharedFile)?.includes("不相交"));
243
+ const lateDep = "## Tasks\n\n- Task-1: a; wave: 2\n- Task-2: b — deps: Task-1; wave: 1\n";
244
+ assert.ok(lintPlanTasks(lateDep)?.includes("不在更早的波次"));
245
+ const coversUnknown = "## Tasks\n\n- Task-1: a\n\n## Verification Checks\n\n- [ ] \`VC-001\` covers \`Task-7\`; pass: ok\n";
246
+ assert.ok(lintPlanTasks(coversUnknown)?.includes("covers 引用未知任务"));
247
+ const conflict = "## Tasks\n\n- Task-1: a — wave: 2\n\n### Execution Waves\n\n- wave 1: Task-1 — x\n";
248
+ assert.ok(lintPlanTasks(conflict)?.includes("以子节为准"));
249
+ const subWave = "## Tasks\n\n- Task-1: a\n - Task-1.1: s — wave: 3\n";
250
+ assert.ok(lintPlanTasks(subWave)?.includes("继承父任务波次"));
251
+ const gap = "## Tasks\n\n- Task-2: starts at two\n";
252
+ assert.ok(lintPlanTasks(gap)?.includes("不连续"));
253
+ const dup = "## Tasks\n\n- Task-1: first — files: src/a.ts; wave: 1\n- Task-1: shadow — files: src/b.ts; wave: 1\n";
254
+ assert.ok(lintPlanTasks(dup)?.includes("重复"));
255
+ const noCover = "## Tasks\n\n- Task-1: a\n\n## Verification Checks\n\n- [ ] \`VC-001\` pass condition: no covers clause\n- [ ] \`VC-002\` covers \`Task-1\`; pass condition: ok\n";
256
+ assert.ok(lintPlanTasks(noCover)?.includes("VC-001 无 covers"));
257
+ });
258
+
259
+ it("lintPlanIntoNotices surfaces task lint via state", () => {
260
+ // Direct unit probe: state lint joins impl + task notices.
261
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-task-lint-"));
262
+ try {
263
+ const planPath = path.join(tmp, "PLAN_v1.md");
264
+ fs.writeFileSync(
265
+ planPath,
266
+ "## Tasks\n\n- Task-2: gap and unknown dep — deps: Task-9\n",
267
+ "utf8",
268
+ );
269
+ const notices = lintPlanTasks(fs.readFileSync(planPath, "utf8"));
270
+ assert.ok(notices !== null && notices.includes("引用未知任务"));
271
+ } finally {
272
+ fs.rmSync(tmp, { recursive: true, force: true });
273
+ }
197
274
  });
198
275
  });
@@ -44,7 +44,7 @@ describe("plans tool source", () => {
44
44
  assert.match(source, /import \{[\s\S]*setRefsRoot,[\s\S]*\} from "\.\.\/src\/state\.ts";/);
45
45
  assert.match(source, /case "set-refs-root"/);
46
46
  assert.match(source, /params\.refsRootSource/);
47
- assert.match(source, /"reviewer", "criticizer", "ref-analyst"/);
47
+ assert.match(source, /"reviewer", "ref-analyst"/);
48
48
  });
49
49
  });
50
50
 
@@ -75,15 +75,11 @@ describe("record-checkpoint transitions (I-003)", () => {
75
75
  assert.equal(loaded.status, "ok");
76
76
  assert.equal(loaded.checkpoint.plan?.path, planPath);
77
77
 
78
- // completed without evidence is rejected (F-004).
79
- assert.throws(
80
- () => recordCheckpointTransition(ctx, workdir, run.run_id, { transition: "completed" }),
81
- /evidence/,
82
- );
83
- // completed from the planning phase is rejected even with evidence.
84
- assert.throws(
85
- () => recordCheckpointTransition(ctx, workdir, run.run_id, { transition: "completed", evidence: "done" }),
86
- /cannot complete from phase/,
78
+ // v0.6.1: the implementation-review transitions are gone from the
79
+ // schema — the union no longer accepts them at compile time, and
80
+ // the handler switch exhausts on the two remaining names.
81
+ assert.ok(
82
+ !"completed".includes("plan-written") && !"completed".includes("review-consolidated"),
87
83
  );
88
84
  // planPath is required.
89
85
  assert.throws(
@@ -112,76 +108,8 @@ describe("pre-plan compaction wiring", () => {
112
108
  const source = fs.readFileSync(path.join(ROOT, "index.ts"), "utf8");
113
109
  assert.match(source, /consumePrePlanCompactPending/);
114
110
  assert.match(source, /customInstructions: PLANNING_PREPLAN_COMPACT_HINT/);
115
- assert.match(source, /sendPrePlanCompactResume\(pi\)/);
111
+ assert.match(source, /sendPrePlanCompactResume\(ctx\)/);
116
112
  assert.match(source, /pre-plan compaction skipped; continuing planning\./);
117
113
  });
118
114
  });
119
115
 
120
- describe("record-checkpoint reviewerCount (0.5.4)", () => {
121
- it("implementation-review-configured carries reviewerCount through schema and state", async () => {
122
- const { recordCheckpointTransition } = await import("../tools/plans.ts");
123
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-rcp-rc-"));
124
- try {
125
- spawnSync("git", ["init"], { cwd: workdir });
126
- const { initState, startRun } = await import("../src/state.ts");
127
- initState(workdir);
128
- const { run } = startRun(workdir, { topic: "rcp-rc", skill: "plan-big", requestText: "t" });
129
- const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
130
- const { mutateCheckpoint } = await import("../src/workflow-state.ts");
131
- createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
132
- mutateCheckpoint(workdir, run.run_id, (cp) => ({ ...cp, phase: "implementation-review", nextAction: "ask-question" }));
133
- const ctx = { sessionManager: { id: "s" } };
134
-
135
- const updated = recordCheckpointTransition(ctx, workdir, run.run_id, {
136
- transition: "implementation-review-configured",
137
- terminationCondition: "until no high-severity finding (hard cap 5 rounds)",
138
- reviewerCount: 3,
139
- });
140
- assert.equal(updated.implementationReview?.reviewerCount, 3);
141
- assert.equal(updated.nextAction, "run-review");
142
- const loaded = loadCheckpoint(workdir, run.run_id);
143
- assert.ok(loaded.status === "ok");
144
- assert.equal(loaded.checkpoint.implementationReview?.reviewerCount, 3);
145
-
146
- // Omitted reviewerCount still configures (skill default applies later).
147
- const workdir2 = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-rcp-rc2-"));
148
- spawnSync("git", ["init"], { cwd: workdir2 });
149
- initState(workdir2);
150
- const { run: run2 } = startRun(workdir2, { topic: "rcp-rc2", skill: "plan-normal", requestText: "t" });
151
- createCheckpoint(workdir2, { runId: run2.run_id, originWorkdir: workdir2, workdir: workdir2 });
152
- mutateCheckpoint(workdir2, run2.run_id, (cp) => ({ ...cp, phase: "implementation-review", nextAction: "ask-question" }));
153
- const updated2 = recordCheckpointTransition(ctx, workdir2, run2.run_id, {
154
- transition: "implementation-review-configured",
155
- terminationCondition: "1 round",
156
- });
157
- assert.equal(updated2.implementationReview?.reviewerCount, undefined);
158
-
159
- // Boundary: reviewerCount outside 1-3 fails checkpoint validation on
160
- // write (the tool's TypeBox schema rejects the same range earlier).
161
- const workdir3 = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-rcp-rc3-"));
162
- try {
163
- spawnSync("git", ["init"], { cwd: workdir3 });
164
- initState(workdir3);
165
- const { run: run3 } = startRun(workdir3, { topic: "rcp-rc3", skill: "plan-small", requestText: "t" });
166
- createCheckpoint(workdir3, { runId: run3.run_id, originWorkdir: workdir3, workdir: workdir3 });
167
- mutateCheckpoint(workdir3, run3.run_id, (cp) => ({ ...cp, phase: "implementation-review", nextAction: "ask-question" }));
168
- for (const bad of [0, 4]) {
169
- assert.throws(
170
- () =>
171
- recordCheckpointTransition(ctx, workdir3, run3.run_id, {
172
- transition: "implementation-review-configured",
173
- terminationCondition: "1 round",
174
- reviewerCount: bad,
175
- }),
176
- /reviewerCount|1-3/,
177
- `reviewerCount ${bad} rejected`,
178
- );
179
- }
180
- } finally {
181
- fs.rmSync(workdir3, { recursive: true, force: true });
182
- }
183
- } finally {
184
- fs.rmSync(workdir, { recursive: true, force: true });
185
- }
186
- });
187
- });
@@ -2,7 +2,7 @@ import * as assert from "node:assert/strict";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { describe, it } from "node:test";
5
- import { buildCriticizerTask, buildImplementationCriticizerTask, buildImplementationReviewerTask, buildRefAnalystTask, buildReviewerTask, refAnalystSections, reviewerLanes } from "../src/refine-prompts.ts";
5
+ import { buildRefAnalystTask, buildReviewerTask, refAnalystSections, reviewerLanes } from "../src/refine-prompts.ts";
6
6
 
7
7
  describe("reviewerLanes", () => {
8
8
  it("uses stable lane ids for the big-plan fanout", () => {
@@ -24,7 +24,7 @@ describe("buildReviewerTask", () => {
24
24
  context: "repo evidence",
25
25
  });
26
26
 
27
- assert.match(text, /Goal: review the plan against the repository\./);
27
+ assert.match(text, /Goal: review the plan against the repository and surface what needs the user's judgment\./);
28
28
  assert.match(text, /Target: \/tmp\/PLAN_v1\.md/);
29
29
  assert.match(text, /Authority boundary: read-only analysis only\./);
30
30
  assert.match(text, /Review lens: verification rigor\./);
@@ -68,81 +68,30 @@ describe("buildRefAnalystTask", () => {
68
68
  });
69
69
  });
70
70
 
71
- describe("buildCriticizerTask", () => {
72
- it("asks for short adversarial questions only", () => {
73
- const text = buildCriticizerTask({
74
- planText: "# plan",
75
- planPath: "/tmp/PLAN_v1.md",
76
- focus: "challenge the deployment step",
77
- });
78
-
79
- assert.match(text, /Goal: stress-test the plan's assumptions\./);
80
- assert.match(text, /Authority boundary: read-only analysis only\./);
81
- assert.match(text, /Specific concerns from the main agent: challenge the deployment step/);
82
- assert.match(text, /at most five adaptive questions/);
83
- assert.match(text, /never rewrite the plan/);
84
- });
85
- });
86
-
87
- describe("buildImplementationReviewerTask", () => {
88
- it("anchors findings to the plan and explicitly assesses delivery maturity", () => {
89
- const text = buildImplementationReviewerTask({
90
- planText: "# plan",
91
- planPath: "/tmp/PLAN_v1.md",
92
- lens: "correctness",
93
- });
94
-
95
- assert.match(text, /Goal: review the implemented result in the worktree against the plan\./);
96
- assert.match(text, /the IMPLEMENTATION in the worktree is under review/);
97
- assert.match(text, /Judge the implementation against the plan's goals/);
98
- assert.match(text, /did the executor ship a minimal MVP only, or refine for long-term growth/);
99
- assert.match(text, /Out-of-scope improvement ideas are low severity by default/);
100
- assert.match(text, /Review lens: correctness\./);
101
- assert.match(text, /Surface at most five high-priority findings/);
102
- });
103
- });
104
-
105
- describe("buildImplementationCriticizerTask", () => {
106
- it("asks implementation-focused adversarial questions without rewriting the implementation", () => {
107
- const text = buildImplementationCriticizerTask({
108
- planText: "# plan",
109
- planPath: "/tmp/PLAN_v1.md",
110
- });
111
-
112
- assert.match(text, /Goal: stress-test the implemented result's assumptions\./);
113
- assert.match(text, /the IMPLEMENTATION in the worktree is under review/);
114
- assert.match(text, /never rewrite the plan or the implementation/);
115
- assert.match(text, /at most five adaptive questions/);
116
- });
117
- });
118
-
119
- describe("plan-mode builders are unchanged by the implementation-mode addition", () => {
120
- it("buildReviewerTask output is byte-identical to its prior contract", () => {
121
- // Snapshot regression guard: changing the plan-mode brief would silently
122
- // break existing reviewer subagents. Keep this stable.
123
- const before = buildReviewerTask({ planText: "PLAN", planPath: "/p/PLAN_v1.md" });
124
- assert.match(before, /Goal: review the plan against the repository\./);
125
- assert.doesNotMatch(before, /IMPLEMENTATION in the worktree/);
71
+ describe("plan-mode builder carries the merged findings+questions contract", () => {
72
+ it("buildReviewerTask outputs Findings and Questions sections", () => {
73
+ // v0.6.1: the reviewer absorbed the criticizer's questioning duty.
74
+ const brief = buildReviewerTask({ planText: "PLAN", planPath: "/p/PLAN_v1.md" });
75
+ assert.match(brief, /## Findings[\s\S]*## Questions/);
76
+ assert.match(brief, /`Q-1`/);
77
+ assert.match(brief, /at most five/i);
78
+ assert.doesNotMatch(brief, /criticizer/i);
126
79
  });
127
80
  });
128
81
 
129
- describe("refine tool wires target to the right builder", () => {
130
- it("forwards target=implementation to the implementation builders", () => {
82
+ describe("refine tool wires the single reviewer role", () => {
83
+ it("has no role/target params and mandates ask_choice for questions", () => {
131
84
  const source = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
132
- assert.match(source, /buildImplementationReviewerTask/);
133
- assert.match(source, /buildImplementationCriticizerTask/);
134
- assert.match(source, /target\s*===\s*"implementation"/);
135
- assert.match(source, /params\.target\s*\?\?\s*"plan"/);
85
+ assert.doesNotMatch(source, /buildCriticizerTask/);
86
+ assert.doesNotMatch(source, /buildImplementation/);
87
+ assert.doesNotMatch(source, /StringEnum\(\["reviewer", "criticizer"\]/);
88
+ assert.match(source, /MUST ask every question with ask_choice/);
89
+ assert.match(source, /role: "reviewer"/);
136
90
  });
137
91
 
138
- it("criticizer spawn gets the graph tools and prompt like the reviewer", () => {
92
+ it("reviewer spawn gets the graph tools and prompt", () => {
139
93
  const source = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
140
- const criticizerBlock = source.slice(
141
- source.indexOf('params.role === "criticizer"'),
142
- source.indexOf("const count = Math.min"),
143
- );
144
- assert.ok(criticizerBlock.length > 0, "criticizer block not found");
145
- assert.match(criticizerBlock, /tools: subagentTools/);
146
- assert.match(criticizerBlock, /graphPrompt/);
94
+ assert.match(source, /tools: subagentTools/);
95
+ assert.match(source, /graphPrompt/);
147
96
  });
148
97
  });