pi-plans 0.1.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.
@@ -0,0 +1,497 @@
1
+ import * as fs from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import * as path from "node:path";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { CheckItem } from "./plan.ts";
6
+
7
+ /** SGR runs plus OSC hyperlinks: escaped bytes, no display columns. */
8
+ const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
9
+
10
+ /** Code points rendered as two terminal columns (East-Asian Wide/Fullwidth). */
11
+ function isWideCodePoint(codePoint: number): boolean {
12
+ return (
13
+ (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
14
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK radicals/symbols
15
+ (codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana…CJK compatibility
16
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Ext A
17
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified
18
+ (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi
19
+ (codePoint >= 0xa960 && codePoint <= 0xa97f) || // Hangul Jamo Ext-A
20
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
21
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compat ideographs
22
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) || // vertical forms
23
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compat forms
24
+ (codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
25
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
26
+ (codePoint >= 0x1f300 && codePoint <= 0x1faff) || // emoji pictographs
27
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Ext B…plan 3
28
+ );
29
+ }
30
+
31
+ /** Zero-advance code points: combining marks, selectors, joiners, controls. */
32
+ function isZeroWidthCodePoint(codePoint: number): boolean {
33
+ return (
34
+ codePoint < 0x20 ||
35
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
36
+ (codePoint >= 0x0300 && codePoint <= 0x036f) || // combining diacritics
37
+ (codePoint >= 0x20d0 && codePoint <= 0x20f0) || // combining symbols
38
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || // variation selectors
39
+ (codePoint >= 0xe0100 && codePoint <= 0xe01ef) || // variation selectors ext
40
+ codePoint === 0x200b || codePoint === 0x200d || codePoint === 0xfeff
41
+ );
42
+ }
43
+
44
+ function codePointColumns(codePoint: number): number {
45
+ if (isZeroWidthCodePoint(codePoint)) return 0;
46
+ if (isWideCodePoint(codePoint)) return 2;
47
+ return 1;
48
+ }
49
+
50
+ /** Displayed terminal columns of `text`, ignoring escape sequences. */
51
+ export function visibleWidth(text: string): number {
52
+ let total = 0;
53
+ for (const ch of text.replace(ANSI_PATTERN, "")) {
54
+ total += codePointColumns(ch.codePointAt(0) ?? 0);
55
+ }
56
+ return total;
57
+ }
58
+
59
+ /**
60
+ * Truncate styled text to fit within `width` terminal columns.
61
+ *
62
+ * Reserves one safety column against host/TUI measurement differences so a
63
+ * truncated line can never re-trigger the renderer's width assertion.
64
+ */
65
+ export function truncateAnsi(text: string, width: number): string {
66
+ if (width <= 0) return "";
67
+ const cap = width - 1;
68
+ if (cap < 1) return visibleWidth(text) <= width ? text : "";
69
+ const total = visibleWidth(text);
70
+ if (total <= cap) return text;
71
+ const budget = cap - 1; // keep room for the trailing ellipsis
72
+ let out = "";
73
+ let used = 0;
74
+ let i = 0;
75
+ while (i < text.length) {
76
+ if (text[i] === "\x1b") {
77
+ const rest = text.slice(i);
78
+ const sgr = rest.match(/^\x1b\[[0-9;]*[A-Za-z]/);
79
+ if (sgr) {
80
+ out += sgr[0];
81
+ i += sgr[0].length;
82
+ continue;
83
+ }
84
+ const osc = rest.match(/^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/);
85
+ if (osc) {
86
+ i += osc[0].length;
87
+ continue;
88
+ }
89
+ }
90
+ const codePoint = text.codePointAt(i);
91
+ if (codePoint === undefined) break;
92
+ const ch = String.fromCodePoint(codePoint);
93
+ const columns = codePointColumns(codePoint);
94
+ if (used + columns > budget) break;
95
+ used += columns;
96
+ out += ch;
97
+ i += ch.length;
98
+ }
99
+ return `${out}…\x1b[0m`;
100
+ }
101
+
102
+ export interface RepoSnapshot {
103
+ added: number;
104
+ removed: number;
105
+ files: number;
106
+ }
107
+
108
+ export interface ItemDiffSummary extends RepoSnapshot {
109
+ paths: string[];
110
+ shared?: boolean;
111
+ }
112
+
113
+ export interface ExecutionPanelItemState {
114
+ summary?: ItemDiffSummary;
115
+ }
116
+
117
+ export interface ExecutionPanelState {
118
+ expanded: boolean;
119
+ baseline: RepoSnapshot | null;
120
+ lastSnapshot: RepoSnapshot | null;
121
+ touchedPaths: string[];
122
+ itemSummaries: Record<string, ExecutionPanelItemState>;
123
+ }
124
+
125
+ export interface ExecutionPanelExecutionLike {
126
+ planPath: string;
127
+ items: CheckItem[];
128
+ panel?: ExecutionPanelState;
129
+ }
130
+
131
+ interface ThemeLike {
132
+ fg(color: string, text: string): string;
133
+ strikethrough(text: string): string;
134
+ }
135
+
136
+ interface WidgetLike {
137
+ render(width: number): string[];
138
+ invalidate(): void;
139
+ }
140
+
141
+ const WIDGET_ID = "pi-plans-execution";
142
+ const PANEL_STATE_ENTRY = "pi-plans-exec-panel";
143
+
144
+ function runGit(cwd: string, args: string[]): string {
145
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
146
+ if (result.error) {
147
+ return "";
148
+ }
149
+ return String(result.stdout ?? "").trim();
150
+ }
151
+
152
+ function normalizePath(cwd: string, raw: string): string {
153
+ const trimmed = raw.trim().replace(/[\u0000]+/g, "");
154
+ if (!trimmed) return "";
155
+ if (trimmed.startsWith("-") || trimmed === "." || trimmed === "..") return "";
156
+ const absolute = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
157
+ const relative = path.relative(cwd, absolute);
158
+ const safe = relative.startsWith("..") ? path.basename(absolute) : relative;
159
+ return safe.split(path.sep).join("/");
160
+ }
161
+
162
+ function extractPathsFromBash(command: string, cwd: string): string[] {
163
+ const values = new Set<string>();
164
+ for (const token of command.split(/\s+/)) {
165
+ const cleaned = token.replace(/^["'`(<[{]+|["'`)>}\],;]+$/g, "");
166
+ if (!cleaned || cleaned.startsWith("-") || cleaned === "." || cleaned === "..") continue;
167
+ const looksLikePath =
168
+ cleaned.includes("/") ||
169
+ cleaned.startsWith(".") ||
170
+ cleaned.startsWith("~") ||
171
+ /^[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+$/.test(cleaned);
172
+ if (!looksLikePath) continue;
173
+ const normalized = normalizePath(cwd, cleaned);
174
+ if (normalized) values.add(normalized);
175
+ }
176
+ return [...values];
177
+ }
178
+
179
+ function parseNumstat(output: string, workdir: string): RepoSnapshot {
180
+ let added = 0;
181
+ let removed = 0;
182
+ let files = 0;
183
+ for (const line of output.split("\n")) {
184
+ const trimmed = line.trim();
185
+ if (!trimmed) continue;
186
+ const parts = line.split("\t");
187
+ if (parts.length < 3) continue;
188
+ const add = Number(parts[0] === "-" ? 0 : parts[0]);
189
+ const del = Number(parts[1] === "-" ? 0 : parts[1]);
190
+ const filePath = normalizePath(workdir, parts.slice(2).join("\t"));
191
+ if (!filePath) continue;
192
+ added += Number.isFinite(add) ? add : 0;
193
+ removed += Number.isFinite(del) ? del : 0;
194
+ files += 1;
195
+ }
196
+ return { added, removed, files };
197
+ }
198
+
199
+ export function captureRepoSnapshot(workdir: string): RepoSnapshot {
200
+ const tracked = parseNumstat(runGit(workdir, ["diff", "--numstat", "--find-renames=0", "HEAD", "--", "."]), workdir);
201
+ const untracked = runGit(workdir, ["ls-files", "-o", "--exclude-standard"]);
202
+ let untrackedAdded = 0;
203
+ let untrackedFiles = 0;
204
+ if (untracked) {
205
+ for (const raw of untracked.split("\n")) {
206
+ const filePath = normalizePath(workdir, raw);
207
+ if (!filePath) continue;
208
+ const stats = parseNumstat(runGit(workdir, ["diff", "--numstat", "--no-index", "/dev/null", filePath]), workdir);
209
+ untrackedAdded += stats.added;
210
+ untrackedFiles += stats.files || 1;
211
+ }
212
+ }
213
+ return {
214
+ added: tracked.added + untrackedAdded,
215
+ removed: tracked.removed,
216
+ files: tracked.files + untrackedFiles,
217
+ };
218
+ }
219
+
220
+ export function subtractSnapshots(previous: RepoSnapshot, current: RepoSnapshot): RepoSnapshot {
221
+ return {
222
+ added: current.added - previous.added,
223
+ removed: current.removed - previous.removed,
224
+ files: current.files - previous.files,
225
+ };
226
+ }
227
+
228
+ export function createExecutionPanelState(): ExecutionPanelState {
229
+ return {
230
+ expanded: false,
231
+ baseline: null,
232
+ lastSnapshot: null,
233
+ touchedPaths: [],
234
+ itemSummaries: {},
235
+ };
236
+ }
237
+
238
+ export function cloneExecutionPanelState(state: ExecutionPanelState): ExecutionPanelState {
239
+ return {
240
+ expanded: state.expanded,
241
+ baseline: state.baseline ? { ...state.baseline } : null,
242
+ lastSnapshot: state.lastSnapshot ? { ...state.lastSnapshot } : null,
243
+ touchedPaths: [...state.touchedPaths],
244
+ itemSummaries: Object.fromEntries(
245
+ Object.entries(state.itemSummaries).map(([id, item]) => [
246
+ id,
247
+ item.summary
248
+ ? {
249
+ summary: {
250
+ added: item.summary.added,
251
+ removed: item.summary.removed,
252
+ files: item.summary.files,
253
+ paths: [...item.summary.paths],
254
+ shared: item.summary.shared,
255
+ },
256
+ }
257
+ : {},
258
+ ]),
259
+ ),
260
+ };
261
+ }
262
+
263
+ export function ensurePanelState(execution: ExecutionPanelExecutionLike): ExecutionPanelState {
264
+ if (!execution.panel) {
265
+ execution.panel = createExecutionPanelState();
266
+ }
267
+ return execution.panel;
268
+ }
269
+
270
+ export function attachPanelBaseline(execution: ExecutionPanelExecutionLike, workdir: string): ExecutionPanelState {
271
+ const panel = ensurePanelState(execution);
272
+ const snapshot = captureRepoSnapshot(workdir);
273
+ panel.baseline = snapshot;
274
+ panel.lastSnapshot = snapshot;
275
+ panel.touchedPaths = [];
276
+ panel.itemSummaries = {};
277
+ return panel;
278
+ }
279
+
280
+ export function restorePanelState(raw: unknown): ExecutionPanelState | null {
281
+ if (!raw || typeof raw !== "object") return null;
282
+ const candidate = raw as Partial<ExecutionPanelState>;
283
+ return {
284
+ expanded: Boolean(candidate.expanded),
285
+ baseline: candidate.baseline && typeof candidate.baseline.added === "number" ? { ...candidate.baseline } : null,
286
+ lastSnapshot:
287
+ candidate.lastSnapshot && typeof candidate.lastSnapshot.added === "number"
288
+ ? { ...candidate.lastSnapshot }
289
+ : null,
290
+ touchedPaths: Array.isArray(candidate.touchedPaths)
291
+ ? candidate.touchedPaths.map((item) => String(item)).filter(Boolean)
292
+ : [],
293
+ itemSummaries:
294
+ candidate.itemSummaries && typeof candidate.itemSummaries === "object"
295
+ ? Object.fromEntries(
296
+ Object.entries(candidate.itemSummaries).map(([id, item]) => {
297
+ const summary = (item as ExecutionPanelItemState | undefined)?.summary;
298
+ return [
299
+ id,
300
+ summary
301
+ ? {
302
+ summary: {
303
+ added: summary.added ?? 0,
304
+ removed: summary.removed ?? 0,
305
+ files: summary.files ?? 0,
306
+ paths: Array.isArray(summary.paths) ? summary.paths.map((pathValue) => String(pathValue)) : [],
307
+ shared: summary.shared,
308
+ },
309
+ }
310
+ : {},
311
+ ];
312
+ }),
313
+ )
314
+ : {},
315
+ };
316
+ }
317
+
318
+ export function snapshotPanelState(execution: ExecutionPanelExecutionLike): ExecutionPanelState {
319
+ return cloneExecutionPanelState(ensurePanelState(execution));
320
+ }
321
+
322
+ export function setExpanded(execution: ExecutionPanelExecutionLike, expanded: boolean): void {
323
+ ensurePanelState(execution).expanded = expanded;
324
+ }
325
+
326
+ export function toggleExpanded(execution: ExecutionPanelExecutionLike): boolean {
327
+ const panel = ensurePanelState(execution);
328
+ panel.expanded = !panel.expanded;
329
+ return panel.expanded;
330
+ }
331
+
332
+ export function recordTouchedPaths(execution: ExecutionPanelExecutionLike, paths: string[]): void {
333
+ if (!paths.length) return;
334
+ const panel = ensurePanelState(execution);
335
+ const merged = new Set(panel.touchedPaths);
336
+ for (const raw of paths) {
337
+ const normalized = raw.trim().replace(/[\\/]+/g, "/").replace(/[\u0000]+/g, "");
338
+ if (normalized && normalized !== "." && normalized !== "..") merged.add(normalized);
339
+ }
340
+ panel.touchedPaths = [...merged];
341
+ }
342
+
343
+ export function completeCompletedItems(
344
+ execution: ExecutionPanelExecutionLike,
345
+ workdir: string,
346
+ completedIds: string[],
347
+ ): ItemDiffSummary | null {
348
+ if (!completedIds.length) return null;
349
+ const panel = ensurePanelState(execution);
350
+ const current = captureRepoSnapshot(workdir);
351
+ const previous = panel.lastSnapshot ?? panel.baseline ?? current;
352
+ const delta = subtractSnapshots(previous, current);
353
+ const summary: ItemDiffSummary = {
354
+ added: delta.added,
355
+ removed: delta.removed,
356
+ files: delta.files,
357
+ paths: [...new Set(panel.touchedPaths)],
358
+ shared: completedIds.length > 1,
359
+ };
360
+ for (const id of completedIds) {
361
+ panel.itemSummaries[id] = { summary };
362
+ }
363
+ panel.lastSnapshot = current;
364
+ panel.touchedPaths = [];
365
+ return summary;
366
+ }
367
+
368
+ export function clearPanelState(execution: ExecutionPanelExecutionLike): void {
369
+ execution.panel = createExecutionPanelState();
370
+ }
371
+
372
+ function formatSummaryLine(summary: ItemDiffSummary, theme: ThemeLike, width: number): string {
373
+ const plus = theme.fg("success", `+${Math.max(summary.added, 0)}`);
374
+ const minus = theme.fg("error", `-${Math.max(summary.removed, 0)}`);
375
+ const files = theme.fg("muted", `${Math.max(summary.files, 0)} file${Math.max(summary.files, 0) === 1 ? "" : "s"}`);
376
+ const marker = summary.shared ? theme.fg("dim", " shared") : "";
377
+ return truncateAnsi(` ${plus} ${minus} ${files}${marker}`, width);
378
+ }
379
+
380
+ function formatPathLine(paths: string[], theme: ThemeLike, width: number): string | null {
381
+ if (!paths.length) return null;
382
+ return truncateAnsi(` ${theme.fg("dim", paths.join(", "))}`, width);
383
+ }
384
+
385
+ function renderItemLines(item: CheckItem, summary: ItemDiffSummary | undefined, theme: ThemeLike, width: number): string[] {
386
+ const done = item.done;
387
+ const checkbox = done ? theme.fg("success", "☑ ") : theme.fg("muted", "☐ ");
388
+ const text = done ? theme.strikethrough(item.text) : item.text;
389
+ const lines = [truncateAnsi(`${checkbox}${text}`, width)];
390
+ if (summary) {
391
+ lines.push(formatSummaryLine(summary, theme, width));
392
+ const pathLine = formatPathLine(summary.paths, theme, width);
393
+ if (pathLine) lines.push(pathLine);
394
+ }
395
+ return lines;
396
+ }
397
+
398
+ function renderPanelLines(execution: ExecutionPanelExecutionLike, theme: ThemeLike, width: number): string[] {
399
+ const panel = ensurePanelState(execution);
400
+ const done = execution.items.filter((item) => item.done).length;
401
+ const hint = panel.expanded ? "alt+o /plans-list hide" : "alt+o /plans-list details";
402
+ const header = truncateAnsi(theme.fg("accent", `📋 plans ${done}/${execution.items.length} · ${hint}`), width);
403
+ if (!panel.expanded) {
404
+ return [header];
405
+ }
406
+ const lines = [header];
407
+ for (const item of execution.items) {
408
+ const summary = panel.itemSummaries[item.id]?.summary;
409
+ lines.push(...renderItemLines(item, summary, theme, width));
410
+ }
411
+ return lines;
412
+ }
413
+
414
+ interface WidgetThemeSource {
415
+ theme: ThemeLike | null;
416
+ }
417
+
418
+ let renderCacheWidth: number | null = null;
419
+ let renderCacheLines: string[] | null = null;
420
+
421
+ function invalidateRenderCache(): void {
422
+ renderCacheWidth = null;
423
+ renderCacheLines = null;
424
+ }
425
+
426
+ class ExecutionPanelWidget implements WidgetLike {
427
+ private readonly source: WidgetThemeSource;
428
+
429
+ constructor(source: WidgetThemeSource) {
430
+ this.source = source;
431
+ }
432
+
433
+ invalidate(): void {
434
+ invalidateRenderCache();
435
+ }
436
+
437
+ render(width: number): string[] {
438
+ if (renderCacheWidth === width && renderCacheLines) return renderCacheLines;
439
+ const execution = panelRef.current;
440
+ const theme = this.source.theme ?? ({ fg: (_c: string, t: string) => t, strikethrough: (t: string) => t } as ThemeLike);
441
+ renderCacheLines = execution ? renderPanelLines(execution, theme, width) : [""];
442
+ renderCacheWidth = width;
443
+ return renderCacheLines;
444
+ }
445
+ }
446
+
447
+ // One live widget instance per session: refreshing invalidates its cache
448
+ // instead of tearing down and re-registering the whole widget (which forced
449
+ // a full TUI relayout mid-stream). Registration is tied to the host `ctx.ui`
450
+ // instance so replacement hosts (extension reload, tests) register afresh.
451
+ let panelRef: { current: ExecutionPanelExecutionLike | null } = { current: null };
452
+ const themeSource: WidgetThemeSource = { theme: null };
453
+ let registeredUi: unknown = null;
454
+
455
+ export function refreshExecutionPanel(ctx: ExtensionContext, execution: ExecutionPanelExecutionLike | null): void {
456
+ if (!execution || !execution.items.length) {
457
+ clearExecutionPanel(ctx);
458
+ return;
459
+ }
460
+ panelRef.current = execution;
461
+ invalidateRenderCache(); // next render always reflects the latest state
462
+ if (registeredUi === ctx.ui) {
463
+ // Same host, same widget slot: nothing to re-register.
464
+ return;
465
+ }
466
+ registeredUi = ctx.ui;
467
+ ctx.ui.setWidget(
468
+ WIDGET_ID,
469
+ (_tui, theme) => {
470
+ themeSource.theme = theme as ThemeLike;
471
+ return new ExecutionPanelWidget(themeSource);
472
+ },
473
+ { placement: "belowEditor" },
474
+ );
475
+ }
476
+
477
+ export function clearExecutionPanel(ctx: ExtensionContext): void {
478
+ panelRef.current = null;
479
+ registeredUi = null;
480
+ invalidateRenderCache();
481
+ ctx.ui.setWidget(WIDGET_ID, undefined);
482
+ }
483
+
484
+ export function executionPanelEntryData(execution: ExecutionPanelExecutionLike): unknown {
485
+ const panel = execution.panel ?? createExecutionPanelState();
486
+ return {
487
+ expanded: panel.expanded,
488
+ baseline: panel.baseline,
489
+ lastSnapshot: panel.lastSnapshot,
490
+ touchedPaths: [...panel.touchedPaths],
491
+ itemSummaries: panel.itemSummaries,
492
+ };
493
+ }
494
+
495
+ export function executionPanelFromEntryData(data: unknown): ExecutionPanelState | null {
496
+ return restorePanelState(data);
497
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Planning write guard: while a planning run is active (status planning or
3
+ * accepted) and execution has not been approved, edit/write may only target
4
+ * planning artifacts — the pi-plans state dir, the active run's artifact
5
+ * directory, and the pi-plans reference cache.
6
+ */
7
+
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import { getRun, readActive, resolveStateRootOrNull } from "./state.ts";
11
+
12
+ const GUARDED_TOOLS = new Set(["write", "edit"]);
13
+ const GUARDED_STATUSES = new Set(["planning", "accepted"]);
14
+
15
+ export interface GuardInput {
16
+ workdir: string;
17
+ toolName: string;
18
+ rawPath: string;
19
+ }
20
+
21
+ /** Returns a block reason when the write must be blocked, or null when allowed. */
22
+ export function planningWriteBlockReason(input: GuardInput): string | null {
23
+ if (!GUARDED_TOOLS.has(input.toolName)) return null;
24
+ const active = readActive(input.workdir);
25
+ if (!active) return null;
26
+ const run = getRun(input.workdir, active.run_id);
27
+ if (!run || !GUARDED_STATUSES.has(run.status)) return null;
28
+
29
+ const target = path.resolve(input.workdir, input.rawPath.replace(/^@/, ""));
30
+ const stateRoot = resolveStateRootOrNull(input.workdir);
31
+ const allowedRoots = [stateRoot, active.artifact_dir, path.join(os.homedir(), ".cache", "pi-plans")].filter(
32
+ (root): root is string => root !== null,
33
+ );
34
+ const allowed = allowedRoots.some((root) => target === root || target.startsWith(`${root}${path.sep}`));
35
+ if (allowed) return null;
36
+
37
+ return `pi-plans: active planning run "${active.run_id}" is read-only outside planning artifacts. Allowed write roots: ${allowedRoots.join(", ")}. Finish planning and get execution approval (execute_plan tool or /plans-execute), or abandon the run (/plans-abandon).`;
38
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,63 @@
1
+ /** Plan artifact parsing: verifier checklist extraction and [DONE:VC-xxx] markers. */
2
+
3
+ import * as fs from "node:fs";
4
+
5
+ export interface CheckItem {
6
+ id: string;
7
+ text: string;
8
+ done: boolean;
9
+ }
10
+
11
+ /** Parse the `## Verifier Checklist` section of a PLAN_vN.md into items. */
12
+ export function parseChecklist(planText: string): CheckItem[] {
13
+ const lines = planText.split("\n");
14
+ const headerIndex = lines.findIndex((line) => /^##\s+Verifier Checklist\s*$/.test(line.trim()));
15
+ if (headerIndex < 0) return [];
16
+ const items: CheckItem[] = [];
17
+ const seen = new Set<string>();
18
+ for (let i = headerIndex + 1; i < lines.length; i++) {
19
+ const line = lines[i];
20
+ if (/^##\s/.test(line.trim())) break; // next section ends the checklist
21
+ const match = line.match(/^\s*-\s+\[( |x|X)\]\s+(.*)$/);
22
+ if (!match) continue;
23
+ const idMatch = match[2].match(/`(VC-\d+)`/) ?? match[2].match(/\b(VC-\d+)\b/);
24
+ if (!idMatch) continue;
25
+ const id = idMatch[1];
26
+ if (seen.has(id)) continue;
27
+ seen.add(id);
28
+ items.push({ id, text: match[2].trim(), done: match[1].toLowerCase() === "x" });
29
+ }
30
+ return items;
31
+ }
32
+
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 PlanVersionFile {
39
+ path: string;
40
+ version: number;
41
+ }
42
+
43
+ /** Find the highest PLAN_vN.md in an artifact directory. */
44
+ export function latestPlanVersion(artifactDir: string): PlanVersionFile | null {
45
+ if (!fs.existsSync(artifactDir)) return null;
46
+ let best: PlanVersionFile | null = null;
47
+ for (const name of fs.readdirSync(artifactDir)) {
48
+ const match = name.match(/^PLAN_v(\d+)\.(md|markdown)$/i);
49
+ if (!match) continue;
50
+ const version = Number(match[1]);
51
+ if (best === null || version > best.version) {
52
+ best = { path: `${artifactDir}/${name}`, version };
53
+ }
54
+ }
55
+ return best;
56
+ }
57
+
58
+ /** Path for the next plan revision (PLAN_vN+1.md) in an artifact directory. */
59
+ export function nextPlanVersionPath(artifactDir: string): PlanVersionFile {
60
+ const latest = latestPlanVersion(artifactDir);
61
+ const version = (latest?.version ?? 0) + 1;
62
+ return { path: `${artifactDir}/PLAN_v${version}.md`, version };
63
+ }
@@ -0,0 +1,70 @@
1
+ export interface RefinePromptInput {
2
+ planText: string;
3
+ planPath: string;
4
+ focus?: string;
5
+ context?: string;
6
+ lens?: string | null;
7
+ }
8
+
9
+ export interface ReviewerLane {
10
+ id: string;
11
+ lens: string | null;
12
+ }
13
+
14
+ export const REVIEWER_LENSES: readonly ReviewerLane[] = [
15
+ { id: "correctness", lens: "requirements fit and correctness of claims against the repository" },
16
+ { id: "ordering", lens: "architecture, sequencing, and dependency ordering" },
17
+ { id: "verification", lens: "verification rigor, risks, and evidence gaps" },
18
+ ] as const;
19
+
20
+ function buildSharedHeader(role: "reviewer" | "criticizer", opts: RefinePromptInput): string {
21
+ const lensLine = role === "reviewer" && opts.lens ? `\nReview lens: ${opts.lens}.` : "";
22
+ const focusLine = opts.focus ? `\n\nSpecific concerns from the main agent: ${opts.focus}` : "";
23
+ const contextLine = opts.context ? `\n\nContext: ${opts.context}` : "";
24
+ return `Goal: ${role === "reviewer" ? "review the plan against the repository" : "stress-test the plan's assumptions"}.
25
+
26
+ Target: ${opts.planPath}
27
+
28
+ Authority boundary: read-only analysis only. Do not edit, write, delete, commit, push, or spawn subagents.
29
+
30
+ Evidence: inspect the repository with read, grep, find, and ls before judging the plan.${lensLine}${focusLine}${contextLine}`;
31
+ }
32
+
33
+ export function reviewerLanes(count: number): ReviewerLane[] {
34
+ if (count === 3) return [...REVIEWER_LENSES];
35
+ if (count === 2) return [...REVIEWER_LENSES.slice(0, 2)];
36
+ return [{ id: "general", lens: null }];
37
+ }
38
+
39
+ export function buildReviewerTask(opts: RefinePromptInput): string {
40
+ return `${buildSharedHeader("reviewer", opts)}
41
+
42
+ Success criteria: return evidence-backed findings or explicitly say the plan holds up.
43
+
44
+ Output: Markdown, highest severity first. For each finding use this shape:
45
+ - \`F-###\` — severity: high | medium | low; affected plan IDs (e.g. R-001, I-003); evidence: repo path/command or external source that proves it; impact; recommended fix; suggested disposition (accept | reject | needs-discussion).
46
+
47
+ Surface at most five high-priority findings; list lower-severity findings after them. If the plan holds up, say so explicitly and list what you checked.
48
+
49
+ Plan file: ${opts.planPath}
50
+
51
+ ---8<--- PLAN CONTENT ---8<---
52
+ ${opts.planText}
53
+ ---8<--- END PLAN CONTENT ---8<---`;
54
+ }
55
+
56
+ export function buildCriticizerTask(opts: RefinePromptInput): string {
57
+ return `${buildSharedHeader("criticizer", opts)}
58
+
59
+ Success criteria: return concrete, answerable questions only; never rewrite the plan.
60
+
61
+ Output: Markdown in exactly this shape:
62
+ 1. A summary of your core criticism in at most three sentences, highlighting the single most important point.
63
+ 2. Then at most five adaptive questions, numbered, each with one line of why it matters. Questions must be answerable by a user with repo access — never rhetorical. Stop earlier if the plan genuinely holds.
64
+
65
+ Plan file: ${opts.planPath}
66
+
67
+ ---8<--- PLAN CONTENT ---8<---
68
+ ${opts.planText}
69
+ ---8<--- END PLAN CONTENT ---8<---`;
70
+ }