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
package/src/plan.ts CHANGED
@@ -1,4 +1,5 @@
1
- /** Plan artifact parsing: verifier checklist extraction and [DONE:VC-xxx] markers. */
1
+ /** Plan artifact parsing: verifier checklist extraction and [DONE:VC-xxx] markers,
2
+ * plus the v0.6.1 task-tree model (`## Tasks` + `## Verification Checks`). */
2
3
 
3
4
  import * as fs from "node:fs";
4
5
 
@@ -8,10 +9,15 @@ export interface CheckItem {
8
9
  done: boolean;
9
10
  }
10
11
 
11
- /** Parse the `## Verifier Checklist` section of a PLAN_vN.md into items. */
12
+ /** Checklist section header names: the v0.6.1 canonical name and the
13
+ * legacy alias accepted for fallback parsing of older artifacts. */
14
+ export const CHECKLIST_HEADERS = ["Verification Checks", "Verifier Checklist"] as const;
15
+
16
+ /** Parse the `## Verification Checks` (v0.6.1) or legacy `## Verifier
17
+ * Checklist` section of a PLAN_vN.md into items. */
12
18
  export function parseChecklist(planText: string): CheckItem[] {
13
19
  const lines = planText.split("\n");
14
- const headerIndex = lines.findIndex((line) => /^##\s+Verifier Checklist\s*$/.test(line.trim()));
20
+ const headerIndex = lines.findIndex((line) => /^##\s+(?:Verification Checks|Verifier Checklist)\s*$/.test(line.trim()));
15
21
  if (headerIndex < 0) return [];
16
22
  const items: CheckItem[] = [];
17
23
  const seen = new Set<string>();
@@ -30,63 +36,6 @@ export function parseChecklist(planText: string): CheckItem[] {
30
36
  return items;
31
37
  }
32
38
 
33
- /** Extract every [DONE:VC-xxx] marker from an assistant message. */
34
- export function scanDoneMarkers(text: string): string[] {
35
- return [...text.matchAll(/\[DONE:(VC-\d+)\]/g)].map((match) => match[1]);
36
- }
37
-
38
- export interface CurrentIMarker {
39
- id: string;
40
- }
41
-
42
- /** Extract current implementation-item anchors from an assistant message. */
43
- export function scanCurrentIMarkers(text: string): CurrentIMarker[] {
44
- return [...text.matchAll(/\[(I-\d+):current\]/g)].map((match) => ({ id: match[1] }));
45
- }
46
-
47
- export const scanCurrentMarkers = scanCurrentIMarkers;
48
-
49
- /** Resolve the last known current-I marker, ignoring unknown ids. */
50
- export function resolveCurrentI(
51
- implItems: ImplItem[],
52
- markers: CurrentIMarker[] | string[],
53
- fallback?: string,
54
- ): string | undefined {
55
- const known = new Set(implItems.map((item) => item.id));
56
- let current: string | undefined;
57
- for (const marker of markers) {
58
- const id = typeof marker === "string" ? marker : marker.id;
59
- if (known.has(id)) current = id;
60
- }
61
- return current ?? fallback;
62
- }
63
-
64
- /** Deterministic frontier used by snapshots written before currentI existed. */
65
- export function inferCurrentI(
66
- implItems: ImplItem[] | undefined,
67
- items: CheckItem[],
68
- implStatus: Record<string, ImplMarkerState> | undefined,
69
- ): string | undefined {
70
- if (!implItems?.length) return undefined;
71
- const statuses = resolveImplStatuses(implItems, items, implStatus);
72
- return implItems.find((item) => statuses[item.id] !== "vc-passed")?.id ?? implItems.at(-1)?.id;
73
- }
74
-
75
- export type ImplMarkerState = "implemented" | "validating";
76
-
77
- export interface ImplMarker {
78
- id: string;
79
- state: ImplMarkerState;
80
- }
81
-
82
- /** Extract every [I-xxx:implemented|validating] marker from an assistant message. */
83
- export function scanImplMarkers(text: string): ImplMarker[] {
84
- return [...text.matchAll(/\[(I-\d+):(implemented|validating)\]/g)].map((match) => ({
85
- id: match[1],
86
- state: match[2] as ImplMarkerState,
87
- }));
88
- }
89
-
90
39
  export interface ImplItem {
91
40
  id: string;
92
41
  text: string;
@@ -132,18 +81,6 @@ export function lintImplItems(planText: string): string | null {
132
81
  );
133
82
  }
134
83
 
135
- /**
136
- * One-sentence description: cut at the first sentence boundary (。/.) or
137
- * semicolon (;/;), then cap at 80 characters with an ellipsis.
138
- */
139
- export function shortImplDescription(text: string): string {
140
- const clipped = text.split(/[。;;]/)[0] ?? text;
141
- const sentence = clipped.split(/(?<=[.])\s/)[0] ?? clipped;
142
- const trimmed = sentence.trim();
143
- if (trimmed.length <= 80) return trimmed;
144
- return `${trimmed.slice(0, 79)}…`;
145
- }
146
-
147
84
  /**
148
85
  * Extract the covered I-ids from a VC checklist line's coverage clause
149
86
  * ("`VC-001` covers `I-002` and `I-003`; pass condition: ..." → ["I-002",
@@ -154,46 +91,6 @@ export function extractCoverage(vcText: string): string[] {
154
91
  return [...clause.matchAll(/\bI-\d+\b/g)].map((match) => match[0]);
155
92
  }
156
93
 
157
- export type ImplDisplayState = "pending" | "implementing" | "implemented" | "validating" | "vc-passed";
158
-
159
- /**
160
- * Resolve the display state for every I-item. Precedence: vc-passed (all
161
- * covering VCs done, final) > explicit marker (validating / implemented) >
162
- * derivation (some covering VC done → validating; first I with no covering
163
- * VC done → implementing; rest → pending).
164
- */
165
- export function resolveImplStatuses(
166
- implItems: ImplItem[],
167
- items: CheckItem[],
168
- implStatus: Record<string, ImplMarkerState> | undefined,
169
- ): Record<string, ImplDisplayState> {
170
- const result: Record<string, ImplDisplayState> = {};
171
- let frontierAssigned = false;
172
- for (const impl of implItems) {
173
- const coverage = items.filter((item) => extractCoverage(item.text).includes(impl.id));
174
- if (coverage.length > 0 && coverage.every((item) => item.done)) {
175
- result[impl.id] = "vc-passed";
176
- continue;
177
- }
178
- const marker = implStatus?.[impl.id];
179
- if (marker === "validating" || marker === "implemented") {
180
- result[impl.id] = marker;
181
- continue;
182
- }
183
- if (coverage.some((item) => item.done)) {
184
- result[impl.id] = "validating";
185
- continue;
186
- }
187
- if (!frontierAssigned) {
188
- result[impl.id] = "implementing";
189
- frontierAssigned = true;
190
- continue;
191
- }
192
- result[impl.id] = "pending";
193
- }
194
- return result;
195
- }
196
-
197
94
  export interface PlanVersionFile {
198
95
  path: string;
199
96
  version: number;
@@ -220,3 +117,415 @@ export function nextPlanVersionPath(artifactDir: string): PlanVersionFile {
220
117
  const version = (latest?.version ?? 0) + 1;
221
118
  return { path: `${artifactDir}/PLAN_v${version}.md`, version };
222
119
  }
120
+
121
+ // ============================================================================
122
+ // v0.6.1 task-tree model: `## Tasks` (+ `### Execution Waves`) parsing.
123
+ // ============================================================================
124
+
125
+ /** One task from the `## Tasks` section. Subtasks nest one level
126
+ * (`Task-3.1`); the executor treats a parent as complete only when all of
127
+ * its children are complete or skipped. */
128
+ export interface TaskNode {
129
+ id: string;
130
+ title: string;
131
+ /** Normalized task ids this task depends on (`I-001` → `Task-1`). */
132
+ deps: string[];
133
+ /** Worktree-relative paths the task is expected to touch. */
134
+ files: string[];
135
+ /** Inline `wave:` field, when present. The `### Execution Waves`
136
+ * subsection wins on conflict (linted). */
137
+ wave?: number;
138
+ children: TaskNode[];
139
+ }
140
+
141
+ /** One row of the `### Execution Waves` subsection. */
142
+ export interface WaveEntry {
143
+ wave: number;
144
+ taskIds: string[];
145
+ rationale?: string;
146
+ }
147
+
148
+ /** The parsed task model of a plan: the task forest, the explicit waves
149
+ * table, and whether it came from the legacy `I-###` fallback. */
150
+ export interface PlanTasks {
151
+ tasks: TaskNode[];
152
+ waves: WaveEntry[];
153
+ legacy: boolean;
154
+ }
155
+
156
+ const TASK_ID_RE = /\b(?:task-\d+(?:\.\d+)?|i-\d+)\b/gi;
157
+
158
+ /** Normalize a task reference to canonical form: `I-001`→`Task-1`,
159
+ * `task-03.1`→`Task-3.1`. Returns null when the text is not a task id. */
160
+ export function normalizeTaskId(raw: string): string | null {
161
+ const t = raw.trim().replace(/^`+|`+$/g, "");
162
+ let m = t.match(/^task-(\d+)(?:\.(\d+))?$/i);
163
+ if (m) {
164
+ return `Task-${Number(m[1])}${m[2] !== undefined ? `.${Number(m[2])}` : ""}`;
165
+ }
166
+ m = t.match(/^i-(\d+)$/i);
167
+ if (m) return `Task-${Number(m[1])}`;
168
+ return null;
169
+ }
170
+
171
+ /** Split a multi-value field on half/full-width commas and enumeration
172
+ * commas, tolerating surrounding backticks. */
173
+ function splitFieldValues(raw: string): string[] {
174
+ return raw
175
+ .split(/[,,、]\s*/)
176
+ .map((v) => v.trim().replace(/^`+|`+$/g, ""))
177
+ .filter((v) => v.length > 0);
178
+ }
179
+
180
+ /** Parse the inline metadata fields of a task tail. Field clauses are
181
+ * separated by `;`/`;`; each starts with `deps:`/`files:`/`wave:`. */
182
+ function parseTaskFields(tail: string): { deps: string[]; files: string[]; wave?: number; leftovers: string[] } {
183
+ const deps: string[] = [];
184
+ const files: string[] = [];
185
+ let wave: number | undefined;
186
+ const leftovers: string[] = [];
187
+ for (const clause of tail.split(/[;;]/)) {
188
+ const m = clause.trim().match(/^(deps|files|wave)\s*[::]\s*(.+)$/i);
189
+ if (!m) {
190
+ if (clause.trim().length > 0) leftovers.push(clause.trim());
191
+ continue;
192
+ }
193
+ const value = m[2];
194
+ if (m[1].toLowerCase() === "wave") {
195
+ const n = Number(value.trim().replace(/^`+|`+$/g, ""));
196
+ if (Number.isFinite(n) && n >= 1) wave = Math.floor(n);
197
+ } else {
198
+ const values = splitFieldValues(value);
199
+ const target = m[1].toLowerCase() === "deps" ? deps : files;
200
+ for (const v of values) {
201
+ if (m[1].toLowerCase() === "deps") {
202
+ const id = normalizeTaskId(v);
203
+ target.push(id ?? v);
204
+ } else {
205
+ target.push(v);
206
+ }
207
+ }
208
+ }
209
+ }
210
+ return { deps, files, wave, leftovers };
211
+ }
212
+
213
+ /** Split a task line body into title and metadata tail at the dash
214
+ * separator (`—`/`——`/`--`/`–`). No separator → title only. */
215
+ function splitTaskBody(body: string): { title: string; tail: string } {
216
+ const m = body.match(/^(.*?)\s+(?:——|—|--|–)\s+(.+)$/);
217
+ if (!m) return { title: body.trim(), tail: "" };
218
+ return { title: m[1].trim(), tail: m[2].trim() };
219
+ }
220
+
221
+ interface RawTaskLine {
222
+ id: string;
223
+ body: string;
224
+ indent: boolean;
225
+ }
226
+
227
+ function matchTaskBullet(line: string): { id: string; body: string } | null {
228
+ const m = line.match(/^\s*(?:-|\*)\s+`?(task-\d+(?:\.\d+)?)`?(?:\s*[::]\s*|\s+)(.*)$/i);
229
+ if (!m) return null;
230
+ const id = normalizeTaskId(m[1]);
231
+ if (id === null) return null;
232
+ return { id, body: m[2].trim() };
233
+ }
234
+
235
+ /** Parse the `### Execution Waves` subsection rows. */
236
+ function parseWaveLine(line: string): WaveEntry | null {
237
+ const m = line.match(/^\s*-\s+wave\s+(\d+)\s*[::]\s*(.+)$/i);
238
+ if (!m) return null;
239
+ const wave = Number(m[1]);
240
+ const rest = m[2];
241
+ const dash = rest.match(/^(.*?)\s+(?:——|—|--|–)\s+(.+)$/);
242
+ const idsRaw = dash ? dash[1] : rest;
243
+ const rationale = dash ? dash[2].trim() : undefined;
244
+ const taskIds: string[] = [];
245
+ for (const hit of idsRaw.matchAll(TASK_ID_RE)) {
246
+ const id = normalizeTaskId(hit[0]);
247
+ if (id !== null && !taskIds.includes(id)) taskIds.push(id);
248
+ }
249
+ return { wave, taskIds, rationale };
250
+ }
251
+
252
+ /** Map legacy plans onto the task model. `## Implementation Items` entries
253
+ * map directly (`I-001` → `Task-1`, no deps/files/waves). A checklist-only
254
+ * plan (no item sections at all, pre-0.5 artifacts) synthesizes one serial
255
+ * task per verification check — `VC-003` → `Task-3` — so legacy in-flight
256
+ * executions stay resumable; the covers clauses (`covers \`I-003\``)
257
+ * normalize onto the same ids. */
258
+ function legacyFallback(planText: string): PlanTasks {
259
+ const impl = parseImplItems(planText);
260
+ if (impl.length > 0) {
261
+ return {
262
+ tasks: impl.map((item) => {
263
+ const id = normalizeTaskId(item.id) ?? item.id;
264
+ return { id, title: item.text, deps: [], files: [], children: [] };
265
+ }),
266
+ waves: [],
267
+ legacy: true,
268
+ };
269
+ }
270
+ const checklist = parseChecklist(planText);
271
+ if (checklist.length === 0) return { tasks: [], waves: [], legacy: false };
272
+ return {
273
+ tasks: checklist.map((item) => {
274
+ const num = Number(item.id.replace(/^VC-/, ""));
275
+ return {
276
+ id: `Task-${Number.isFinite(num) ? num : 1}`,
277
+ title: item.text.split(/[;;]/)[0]?.slice(0, 80) ?? item.id,
278
+ deps: [],
279
+ files: [],
280
+ children: [],
281
+ };
282
+ }),
283
+ waves: [],
284
+ legacy: true,
285
+ };
286
+ }
287
+
288
+ /** Parse the `## Tasks` section into the task forest + waves table. When
289
+ * the section is absent, falls back to legacy `## Implementation Items`
290
+ * parsing mapped onto task ids. */
291
+ export function parsePlanTasks(planText: string): PlanTasks {
292
+ const lines = planText.split("\n");
293
+ const headerIndex = lines.findIndex((line) => /^##\s+Tasks\s*$/.test(line.trim()));
294
+ if (headerIndex < 0) return legacyFallback(planText);
295
+
296
+ const tasks: TaskNode[] = [];
297
+ const seen = new Set<string>();
298
+ let lastTop: TaskNode | null = null;
299
+ const waves: WaveEntry[] = [];
300
+ let inWaves = false;
301
+
302
+ for (let i = headerIndex + 1; i < lines.length; i++) {
303
+ const line = lines[i];
304
+ const trimmed = line.trim();
305
+ if (/^##\s/.test(trimmed)) break; // next section ends the tasks
306
+ if (/^###\s+Execution Waves\s*$/.test(trimmed)) {
307
+ inWaves = true;
308
+ continue;
309
+ }
310
+ if (/^###\s/.test(trimmed)) {
311
+ inWaves = false;
312
+ continue;
313
+ }
314
+ if (inWaves) {
315
+ const entry = parseWaveLine(trimmed);
316
+ if (entry) waves.push(entry);
317
+ continue;
318
+ }
319
+ const bullet = matchTaskBullet(line);
320
+ if (!bullet) continue;
321
+ const isIndented = /^\s+/.test(line);
322
+ if (seen.has(bullet.id)) continue;
323
+ seen.add(bullet.id);
324
+ const { title, tail } = splitTaskBody(bullet.body);
325
+ const fields = parseTaskFields(tail);
326
+ const node: TaskNode = { id: bullet.id, title, deps: fields.deps, files: fields.files, wave: fields.wave, children: [] };
327
+ if (isIndented && lastTop !== null && /\./.test(bullet.id)) {
328
+ lastTop.children.push(node);
329
+ } else {
330
+ tasks.push(node);
331
+ lastTop = node;
332
+ }
333
+ }
334
+ return { tasks, waves, legacy: false };
335
+ }
336
+
337
+ /** Flatten a task forest in document order. */
338
+ export function flattenTasks(tasks: TaskNode[]): TaskNode[] {
339
+ const out: TaskNode[] = [];
340
+ for (const t of tasks) {
341
+ out.push(t);
342
+ out.push(...flattenTasks(t.children));
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /** Effective wave per task id: the waves subsection wins, then the inline
348
+ * `wave:` field, then derivation from deps (1 + max(dep wave), floor 1).
349
+ * Subtasks inherit their parent's effective wave. */
350
+ export function resolveTaskWaves(planTasks: PlanTasks): Map<string, number> {
351
+ const resolved = new Map<string, number>();
352
+ const flat = flattenTasks(planTasks.tasks);
353
+ const byId = new Map(flat.map((t) => [t.id, t]));
354
+ for (const entry of planTasks.waves) {
355
+ for (const id of entry.taskIds) resolved.set(id, entry.wave);
356
+ }
357
+ for (const t of planTasks.tasks) {
358
+ if (t.wave !== undefined && !resolved.has(t.id)) resolved.set(t.id, t.wave);
359
+ }
360
+ const derive = (id: string, guard: Set<string>): number => {
361
+ const known = resolved.get(id);
362
+ if (known !== undefined) return known;
363
+ if (guard.has(id)) return 1; // dependency cycle: floor at 1
364
+ guard.add(id);
365
+ const t = byId.get(id);
366
+ let wave = 1;
367
+ if (t) {
368
+ for (const dep of t.deps) {
369
+ const dw = derive(dep, guard);
370
+ if (dw + 1 > wave) wave = dw + 1;
371
+ }
372
+ }
373
+ resolved.set(id, wave);
374
+ return wave;
375
+ };
376
+ for (const t of planTasks.tasks) derive(t.id, new Set());
377
+ // Subtasks inherit their parent's effective wave (subsection entries for
378
+ // subtask ids still win — they were set before derivation).
379
+ const inherit = (parent: TaskNode): void => {
380
+ const pw = resolved.get(parent.id) ?? 1;
381
+ for (const child of parent.children) {
382
+ if (!resolved.has(child.id)) resolved.set(child.id, pw);
383
+ inherit(child);
384
+ }
385
+ };
386
+ for (const t of planTasks.tasks) inherit(t);
387
+ return resolved;
388
+ }
389
+
390
+ /** Extract normalized task ids from a VC line's coverage clause
391
+ * ("`VC-001` covers `Task-2` and `Task-3`; …" → ["Task-2", "Task-3"];
392
+ * legacy `I-###` targets normalize too). Only references before the first
393
+ * `;`/`;` count. */
394
+ export function extractTaskCoverage(vcText: string): string[] {
395
+ const clause = vcText.split(/[;;]/)[0] ?? "";
396
+ const ids: string[] = [];
397
+ for (const hit of clause.matchAll(TASK_ID_RE)) {
398
+ const id = normalizeTaskId(hit[0]);
399
+ if (id !== null && !ids.includes(id)) ids.push(id);
400
+ }
401
+ return ids;
402
+ }
403
+
404
+ /** Lint the `## Tasks` model: returns a warning string (one notice per
405
+ * line) when the section exists but has consistency problems, null when
406
+ * clean or absent. The waves subsection is authoritative; every check is
407
+ * advisory at planning time and enforced again at the execution gate. */
408
+ export function lintPlanTasks(planText: string): string | null {
409
+ const lines = planText.split("\n");
410
+ const headerIndex = lines.findIndex((line) => /^##\s+Tasks\s*$/.test(line.trim()));
411
+ if (headerIndex < 0) return null;
412
+ const notices: string[] = [];
413
+ const parsed = parsePlanTasks(planText);
414
+ if (!parsed.legacy && parsed.tasks.length === 0) {
415
+ return "Tasks 节存在但解析出 0 项:条目需为顶层无缩进行,形如 `- `Task-1`: 标题 — deps: …; files: …; wave: 1`";
416
+ }
417
+ // Over-deep subtask ids anywhere in the section are a format drift.
418
+ const section = lines.slice(headerIndex + 1).join("\n");
419
+ const deep = section.match(/\btask-\d+\.\d+\.\d+\b/i);
420
+ if (deep) notices.push(`子任务层级过深(仅支持 1 级,如 Task-3.1):${deep[0]}`);
421
+ const flat = flattenTasks(parsed.tasks);
422
+ const byId = new Map(flat.map((t) => [t.id, t]));
423
+ // Duplicate ids: the parser dedupes silently, so lint scans the raw
424
+ // bullet ids from the section to surface duplicates (a second bullet may
425
+ // carry different files/deps and silently widen a wave).
426
+ const rawIds = [...section.matchAll(/^\s*(?:-|\*)\s+`?(task-\d+(?:\.\d+)?)`?/gim)].map((m) => normalizeTaskId(m[1]) ?? m[1]);
427
+ const seenIds = new Set<string>();
428
+ for (const id of rawIds) {
429
+ if (seenIds.has(id)) {
430
+ notices.push(`任务 id 重复出现(后者被忽略):${id}`);
431
+ break;
432
+ }
433
+ seenIds.add(id);
434
+ }
435
+ // Numbering continuity among top-level tasks.
436
+ const topNums = parsed.tasks.map((t) => Number(t.id.replace(/^Task-/, "")));
437
+ for (let i = 0; i < topNums.length; i++) {
438
+ const expect = i + 1;
439
+ if (topNums[i] !== expect) {
440
+ notices.push(`顶层任务编号不连续:第 ${i + 1} 项为 ${parsed.tasks[i]?.id},应为 Task-${expect}`);
441
+ break;
442
+ }
443
+ }
444
+ // Subtasks with an inline wave field inherit the parent's wave.
445
+ for (const t of flat) {
446
+ if (/\./.test(t.id) && t.wave !== undefined) {
447
+ notices.push(`${t.id} 行内 wave 字段被忽略(子任务继承父任务波次)`);
448
+ }
449
+ }
450
+ // Unknown dep targets.
451
+ for (const t of flat) {
452
+ for (const dep of t.deps) {
453
+ if (!byId.has(dep)) notices.push(`${t.id} 的 deps 引用未知任务:${dep}`);
454
+ }
455
+ }
456
+ // Wave consistency: inline vs subsection, dep ordering, file disjointness.
457
+ const effective = resolveTaskWaves(parsed);
458
+ const subsection = new Map<string, number>();
459
+ for (const entry of parsed.waves) {
460
+ for (const id of entry.taskIds) {
461
+ if (subsection.has(id)) notices.push(`Waves 子节中 ${id} 出现在多个 wave`);
462
+ subsection.set(id, entry.wave);
463
+ }
464
+ }
465
+ for (const t of flat) {
466
+ const sub = subsection.get(t.id);
467
+ if (sub !== undefined && t.wave !== undefined && sub !== t.wave) {
468
+ notices.push(`${t.id} 行内 wave=${t.wave} 与 Waves 子节 wave=${sub} 冲突,以子节为准`);
469
+ }
470
+ }
471
+ for (const t of flat) {
472
+ const tw = effective.get(t.id) ?? 1;
473
+ for (const dep of t.deps) {
474
+ const dw = effective.get(dep);
475
+ if (dw !== undefined && dw >= tw) {
476
+ notices.push(`${t.id}(wave ${tw}) 的依赖 ${dep}(wave ${dw}) 不在更早的波次`);
477
+ }
478
+ }
479
+ }
480
+ const filesByWave = new Map<number, Map<string, string>>();
481
+ // File disjointness is checked between top-level tasks per wave: a
482
+ // parent naturally shares files with its own children, so children's
483
+ // files roll up to their top-level ancestor's bucket.
484
+ const topLevelOf = new Map<string, string>();
485
+ for (const top of parsed.tasks) {
486
+ for (const node of flattenTasks([top])) topLevelOf.set(node.id, top.id);
487
+ }
488
+ for (const t of flat) {
489
+ const owner = topLevelOf.get(t.id) ?? t.id;
490
+ const tw = effective.get(owner) ?? 1;
491
+ let bucket = filesByWave.get(tw);
492
+ if (!bucket) {
493
+ bucket = new Map();
494
+ filesByWave.set(tw, bucket);
495
+ }
496
+ for (const f of t.files) {
497
+ const existing = bucket.get(f);
498
+ if (existing !== undefined && existing !== owner) {
499
+ notices.push(`wave ${tw} 内 ${existing} 与 ${owner} 同时涉及文件 ${f}(波内文件集需不相交)`);
500
+ } else {
501
+ bucket.set(f, owner);
502
+ }
503
+ }
504
+ }
505
+ // Covers references must point at known tasks; a check with zero covers
506
+ // never enters the completion audit — surface that exemption loudly.
507
+ for (const item of parseChecklist(planText)) {
508
+ const covered = extractTaskCoverage(item.text);
509
+ if (covered.length === 0) {
510
+ notices.push(`${item.id} 无 covers 任务:不参与完成审计(补 covers 或删除该检查)`);
511
+ continue;
512
+ }
513
+ for (const id of covered) {
514
+ if (!byId.has(id)) notices.push(`${item.id} covers 引用未知任务:${id}`);
515
+ }
516
+ }
517
+ if (notices.length === 0) return null;
518
+ return notices.join("\n");
519
+ }
520
+
521
+ /** Which checklist header name the plan uses (canonical or legacy), for
522
+ * execution-gate upgrade hints. */
523
+ export function checklistHeaderName(planText: string): "Verification Checks" | "Verifier Checklist" | null {
524
+ const lines = planText.split("\n");
525
+ for (const line of lines) {
526
+ const t = line.trim();
527
+ if (/^##\s+Verification Checks\s*$/.test(t)) return "Verification Checks";
528
+ if (/^##\s+Verifier Checklist\s*$/.test(t)) return "Verifier Checklist";
529
+ }
530
+ return null;
531
+ }
package/src/query-hook.ts CHANGED
@@ -58,14 +58,14 @@ export function resetOrdinaryQueryState(ctx: ExtensionContext): void {
58
58
  }
59
59
 
60
60
  export function registerQueryInterviewHooks(
61
- pi: ExtensionAPI,
61
+ ext: ExtensionAPI,
62
62
  suppressForWorkflow: (ctx: ExtensionContext) => boolean,
63
63
  ): void {
64
- pi.on("input", async (event, ctx) => {
64
+ ext.on("input", async (event, ctx) => {
65
65
  recordOrdinaryQuery(ctx, event);
66
66
  });
67
67
 
68
- pi.on("before_agent_start", async (_event, ctx) => {
68
+ ext.on("before_agent_start", async (_event, ctx) => {
69
69
  if (!consumeOrdinaryQuery(ctx) || suppressForWorkflow(ctx)) return;
70
70
  return {
71
71
  message: {
@@ -76,7 +76,7 @@ export function registerQueryInterviewHooks(
76
76
  };
77
77
  });
78
78
 
79
- pi.on("session_start", async (_event, ctx) => {
79
+ ext.on("session_start", async (_event, ctx) => {
80
80
  resetOrdinaryQueryState(ctx);
81
81
  });
82
82
  }