ultimate-pi 0.13.1 → 0.14.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 (31) hide show
  1. package/.agents/skills/harness-debate-plan/SKILL.md +61 -21
  2. package/.agents/skills/harness-orchestration/SKILL.md +1 -1
  3. package/.pi/agents/harness/planning/plan-adversary.md +2 -2
  4. package/.pi/agents/harness/planning/plan-evaluator.md +3 -1
  5. package/.pi/agents/harness/planning/review-integrator.md +4 -2
  6. package/.pi/extensions/debate-orchestrator.ts +39 -435
  7. package/.pi/extensions/harness-debate-tools.ts +519 -0
  8. package/.pi/extensions/harness-plan-approval.ts +41 -17
  9. package/.pi/extensions/harness-run-context.ts +18 -0
  10. package/.pi/extensions/lib/debate-bus-core.ts +434 -0
  11. package/.pi/extensions/lib/debate-bus-state.ts +58 -0
  12. package/.pi/extensions/lib/harness-spawn-budget.ts +5 -25
  13. package/.pi/extensions/lib/plan-approval/dialog.ts +33 -272
  14. package/.pi/extensions/lib/plan-approval/format-plan.ts +12 -85
  15. package/.pi/extensions/lib/plan-approval/plan-review.ts +6 -6
  16. package/.pi/extensions/lib/plan-approval/render.ts +6 -0
  17. package/.pi/extensions/lib/plan-approval/validate.ts +1 -1
  18. package/.pi/extensions/lib/plan-debate-envelope.ts +2 -0
  19. package/.pi/extensions/lib/plan-debate-gate.ts +155 -0
  20. package/.pi/extensions/lib/plan-debate-id.ts +39 -0
  21. package/.pi/extensions/lib/plan-debate-lane.ts +220 -0
  22. package/.pi/extensions/lib/plan-debate-round-status.ts +94 -0
  23. package/.pi/extensions/lib/plan-debate-write-guard.ts +20 -0
  24. package/.pi/extensions/lib/plan-messenger.ts +276 -0
  25. package/.pi/extensions/lib/plan-review-integrator-rules.ts +119 -0
  26. package/.pi/extensions/lib/plan-scope-guard.ts +89 -0
  27. package/.pi/harness/agents.manifest.json +7 -7
  28. package/.pi/prompts/harness-plan.md +22 -12
  29. package/CHANGELOG.md +12 -0
  30. package/package.json +3 -3
  31. package/.pi/extensions/lib/plan-approval/fallback.ts +0 -50
@@ -1,291 +1,52 @@
1
1
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
- import { formatPlanPacketLines } from "./format-plan.js";
2
+ import { runAskDialog } from "../ask-user/dialog.js";
3
+ import { runAskFallback } from "../ask-user/fallback.js";
4
+ import type { ValidatedAskParams } from "../ask-user/types.js";
5
+ import { formatPlanPacketMarkdown } from "./plan-review.js";
4
6
  import type {
5
7
  PlanApprovalDialogResult,
6
8
  ValidatedApprovePlanParams,
7
9
  } from "./types.js";
8
10
 
9
- type FocusRegion = "plan" | "options";
10
-
11
- interface CustomAnswer {
12
- response: { kind: "selection"; selections: string[] };
13
- }
14
-
15
- /** Lines reserved below overlay: harness-live widget + editor + footer. */
16
- export const PLAN_APPROVAL_BOTTOM_RESERVE_LINES = 11;
17
- /** Estimate agents widget height when stacking above harness live. */
18
- export const PLAN_APPROVAL_AGENTS_TOP_RESERVE_LINES = 12;
19
- export const PLAN_APPROVAL_MIN_VIEWPORT = 6;
20
-
21
- export function computePlanViewport(
22
- availableHeight: number,
23
- chromeLines: number,
24
- ): number {
25
- return Math.max(PLAN_APPROVAL_MIN_VIEWPORT, availableHeight - chromeLines);
26
- }
27
-
28
- export function computePlanOverlayMaxHeight(termHeight: number): number {
29
- return Math.max(
30
- PLAN_APPROVAL_MIN_VIEWPORT + 8,
31
- termHeight -
32
- PLAN_APPROVAL_BOTTOM_RESERVE_LINES -
33
- PLAN_APPROVAL_AGENTS_TOP_RESERVE_LINES,
34
- );
35
- }
11
+ export type RunPlanApprovalDialogOptions = {
12
+ onMounted?: () => void;
13
+ hasUI?: boolean;
14
+ };
36
15
 
37
- function countPlanChromeLines(
16
+ /** Full plan body shown in the transcript before the approval prompt. */
17
+ export function buildPlanApprovalMarkdown(
38
18
  validated: ValidatedApprovePlanParams,
39
- displayOptions: ValidatedApprovePlanParams["options"],
40
- useOverlay: boolean,
41
- ): number {
42
- let chrome = useOverlay ? 2 : 0; // borders
43
- chrome += 1; // title
44
- if (validated.human_summary) {
45
- chrome += validated.human_summary.split("\n").length;
46
- }
47
- chrome += 1; // blank before plan
48
- chrome += 1; // plan label
49
- chrome += 1; // blank before options
50
- chrome += 1; // options label
51
- for (const opt of displayOptions) {
52
- chrome += 1;
53
- if (opt.description) chrome += 1;
54
- }
55
- chrome += 1; // blank before hints
56
- chrome += 1; // hints
57
- return chrome;
19
+ ): string {
20
+ return formatPlanPacketMarkdown(validated.plan_packet, {
21
+ human_summary: validated.human_summary,
22
+ status: "draft",
23
+ research_brief: validated.research_brief,
24
+ }).trim();
58
25
  }
59
26
 
60
- function withTimeout<T>(
61
- promise: Promise<T | null>,
62
- ms: number | undefined,
63
- ): Promise<T | null> {
64
- if (!ms) return promise;
65
- return Promise.race([
66
- promise,
67
- new Promise<null>((resolve) => {
68
- setTimeout(() => resolve(null), ms);
69
- }),
70
- ]);
27
+ function toAskParams(
28
+ validated: ValidatedApprovePlanParams,
29
+ ): ValidatedAskParams {
30
+ return {
31
+ question: "How would you like to proceed with this harness plan?",
32
+ context: buildPlanApprovalMarkdown(validated),
33
+ options: validated.options,
34
+ allowMultiple: false,
35
+ allowFreeform: false,
36
+ // Inline prompt below the plan — no full-screen overlay.
37
+ displayMode: "inline",
38
+ };
71
39
  }
72
40
 
73
- export type RunPlanApprovalDialogOptions = {
74
- onMounted?: () => void;
75
- };
76
-
77
41
  export async function runPlanApprovalDialog(
78
42
  ui: ExtensionUIContext,
79
43
  validated: ValidatedApprovePlanParams,
80
44
  options?: RunPlanApprovalDialogOptions,
81
45
  ): Promise<PlanApprovalDialogResult> {
82
- const planLines = formatPlanPacketLines(validated.plan_packet, 100);
83
- const displayOptions = validated.options;
84
- const useOverlay = validated.displayMode !== "inline";
85
- let overlayTermHeight = 24;
86
-
87
- const result = await withTimeout(
88
- ui.custom<CustomAnswer | null>(
89
- (tui, theme, _kb, done) => {
90
- const tuiHeight = (tui as unknown as { height?: number }).height;
91
- overlayTermHeight =
92
- typeof tuiHeight === "number" && tuiHeight > 10 ? tuiHeight : 24;
93
- options?.onMounted?.();
94
-
95
- let scrollOffset = 0;
96
- let optionIndex = 0;
97
- let focus: FocusRegion = "plan";
98
- let cachedLines: string[] | undefined;
99
-
100
- function refresh() {
101
- cachedLines = undefined;
102
- tui.requestRender();
103
- }
104
-
105
- function submitSelection() {
106
- const opt = displayOptions[optionIndex];
107
- done({
108
- response: { kind: "selection", selections: [opt.title] },
109
- });
110
- }
111
-
112
- function handleInput(data: string) {
113
- if (focus === "plan") {
114
- if (matchesKey(data, Key.up) || data === "k") {
115
- scrollOffset = Math.max(0, scrollOffset - 1);
116
- refresh();
117
- return;
118
- }
119
- if (matchesKey(data, Key.down) || data === "j") {
120
- scrollOffset += 1;
121
- refresh();
122
- return;
123
- }
124
- if (matchesKey(data, Key.pageUp)) {
125
- scrollOffset = Math.max(0, scrollOffset - 8);
126
- refresh();
127
- return;
128
- }
129
- if (matchesKey(data, Key.pageDown)) {
130
- scrollOffset += 8;
131
- refresh();
132
- return;
133
- }
134
- if (matchesKey(data, Key.tab)) {
135
- focus = "options";
136
- refresh();
137
- return;
138
- }
139
- }
140
-
141
- if (focus === "options") {
142
- if (matchesKey(data, Key.up)) {
143
- optionIndex = Math.max(0, optionIndex - 1);
144
- refresh();
145
- return;
146
- }
147
- if (matchesKey(data, Key.down)) {
148
- optionIndex = Math.min(
149
- displayOptions.length - 1,
150
- optionIndex + 1,
151
- );
152
- refresh();
153
- return;
154
- }
155
- if (matchesKey(data, Key.tab)) {
156
- focus = "plan";
157
- refresh();
158
- return;
159
- }
160
- if (matchesKey(data, Key.enter)) {
161
- submitSelection();
162
- return;
163
- }
164
- }
165
-
166
- if (matchesKey(data, Key.escape)) {
167
- done(null);
168
- }
169
- }
170
-
171
- function render(width: number): string[] {
172
- if (cachedLines) return cachedLines;
173
-
174
- const lines: string[] = [];
175
- const add = (s: string) => lines.push(truncateToWidth(s, width));
176
- const dims = (tui as { height?: number }).height;
177
- const termHeight = typeof dims === "number" && dims > 10 ? dims : 24;
178
- const chromeLines = countPlanChromeLines(
179
- validated,
180
- displayOptions,
181
- useOverlay,
182
- );
183
-
184
- let availableHeight: number;
185
- if (useOverlay) {
186
- const overlayMax = computePlanOverlayMaxHeight(termHeight);
187
- availableHeight = overlayMax;
188
- } else {
189
- availableHeight = termHeight - PLAN_APPROVAL_BOTTOM_RESERVE_LINES;
190
- }
191
- const planViewport = computePlanViewport(
192
- availableHeight,
193
- chromeLines,
194
- );
195
-
196
- if (useOverlay) {
197
- add(theme.fg("accent", "─".repeat(width)));
198
- }
199
-
200
- add(theme.fg("accent", " Plan approval"));
201
- if (validated.human_summary) {
202
- for (const line of validated.human_summary.split("\n")) {
203
- add(theme.fg("muted", ` ${line}`));
204
- }
205
- }
206
- lines.push("");
207
-
208
- const maxScroll = Math.max(0, planLines.length - planViewport);
209
- scrollOffset = Math.min(scrollOffset, maxScroll);
210
- const visible = planLines.slice(
211
- scrollOffset,
212
- scrollOffset + planViewport,
213
- );
214
- const planLabel =
215
- focus === "plan"
216
- ? theme.fg("accent", " [plan — ↑↓/Pg scroll, Tab → options]")
217
- : theme.fg("dim", " [plan]");
218
- add(planLabel);
219
- for (const line of visible) {
220
- add(theme.fg("text", ` ${line}`));
221
- }
222
- if (planLines.length > planViewport) {
223
- add(
224
- theme.fg(
225
- "dim",
226
- ` … ${scrollOffset + 1}-${scrollOffset + visible.length} of ${planLines.length}`,
227
- ),
228
- );
229
- }
230
- lines.push("");
231
-
232
- const optLabel =
233
- focus === "options"
234
- ? theme.fg("accent", " Options (↑↓, Enter, Tab → plan):")
235
- : theme.fg("dim", " Options (Tab to focus):");
236
- add(optLabel);
237
- for (let i = 0; i < displayOptions.length; i++) {
238
- const opt = displayOptions[i];
239
- const focused = focus === "options" && i === optionIndex;
240
- const prefix = focused ? theme.fg("accent", "> ") : " ";
241
- const num = `${i + 1}. `;
242
- if (focused) {
243
- add(prefix + theme.fg("accent", `${num}${opt.title}`));
244
- } else {
245
- add(`${prefix}${theme.fg("text", `${num}${opt.title}`)}`);
246
- }
247
- if (opt.description) {
248
- add(` ${theme.fg("muted", opt.description)}`);
249
- }
250
- }
251
-
252
- lines.push("");
253
- add(theme.fg("dim", " Tab: plan ↔ options • Esc: cancel"));
254
-
255
- if (useOverlay) {
256
- add(theme.fg("accent", "─".repeat(width)));
257
- }
258
-
259
- cachedLines = lines;
260
- return lines;
261
- }
262
-
263
- return {
264
- render,
265
- invalidate: () => {
266
- cachedLines = undefined;
267
- },
268
- handleInput,
269
- };
270
- },
271
- useOverlay
272
- ? {
273
- overlay: true,
274
- overlayOptions: () => ({
275
- anchor: "bottom-center",
276
- width: "100%",
277
- margin: { bottom: PLAN_APPROVAL_BOTTOM_RESERVE_LINES },
278
- maxHeight: computePlanOverlayMaxHeight(overlayTermHeight),
279
- }),
280
- }
281
- : undefined,
282
- ),
283
- undefined,
284
- );
285
-
286
- if (!result) {
287
- return { response: null, cancelled: true };
46
+ options?.onMounted?.();
47
+ const askParams = toAskParams(validated);
48
+ if (options?.hasUI === false) {
49
+ return runAskFallback(ui, askParams);
288
50
  }
289
-
290
- return { response: result.response, cancelled: false };
51
+ return runAskDialog(ui, askParams);
291
52
  }
@@ -1,94 +1,21 @@
1
1
  import type { PlanPacketLike } from "../../../lib/harness-run-context.js";
2
+ import { stringifyYaml } from "../../../lib/harness-yaml.js";
2
3
 
3
- function wrapLine(text: string, width: number): string[] {
4
- if (width < 20) return [text];
5
- const words = text.split(/\s+/);
6
- const lines: string[] = [];
7
- let line = "";
8
- for (const word of words) {
9
- const next = line ? `${line} ${word}` : word;
10
- if (next.length > width && line) {
11
- lines.push(line);
12
- line = word;
13
- } else {
14
- line = next;
15
- }
16
- }
17
- if (line) lines.push(line);
18
- return lines.length > 0 ? lines : [""];
19
- }
20
-
21
- function riskBadge(risk: string | undefined): string {
22
- const r = (risk ?? "med").toLowerCase();
23
- return `[risk: ${r}]`;
4
+ /** Canonical YAML for plan_packet (same shape as plan-packet.yaml on disk). */
5
+ export function formatPlanPacketYaml(packet: PlanPacketLike): string {
6
+ return stringifyYaml(packet).trimEnd();
24
7
  }
25
8
 
9
+ /** Line array for TUI renderers; preserves YAML structure with optional per-line width cap. */
26
10
  export function formatPlanPacketLines(
27
11
  packet: PlanPacketLike,
28
12
  width: number,
29
13
  ): string[] {
30
- const lines: string[] = [];
31
- const w = Math.max(40, width - 2);
32
- const add = (s: string) => {
33
- for (const part of wrapLine(s, w)) lines.push(part);
34
- };
35
-
36
- lines.push(`plan_id: ${packet.plan_id ?? "?"}`);
37
- lines.push(`task_id: ${packet.task_id ?? "?"}`);
38
- lines.push(
39
- riskBadge(
40
- typeof packet.risk_level === "string" ? packet.risk_level : undefined,
41
- ),
42
- );
43
- lines.push("");
44
- lines.push("scope:");
45
- add(String(packet.scope ?? ""));
46
- lines.push("");
47
-
48
- const assumptions = Array.isArray(packet.assumptions)
49
- ? (packet.assumptions as string[])
50
- : [];
51
- if (assumptions.length > 0) {
52
- lines.push("assumptions:");
53
- for (const a of assumptions) {
54
- add(` • ${a}`);
55
- }
56
- lines.push("");
57
- }
58
-
59
- const checks = Array.isArray(packet.acceptance_checks)
60
- ? (packet.acceptance_checks as string[])
61
- : [];
62
- if (checks.length > 0) {
63
- lines.push("acceptance_checks:");
64
- for (let i = 0; i < checks.length; i++) {
65
- add(` ${i + 1}. ${checks[i]}`);
66
- }
67
- lines.push("");
68
- }
69
-
70
- const rollback = packet.rollback_plan as
71
- | {
72
- rollback_artifacts?: {
73
- revert_command?: string;
74
- revert_branch?: string;
75
- patch_bundle?: string;
76
- };
77
- }
78
- | undefined;
79
- const artifacts = rollback?.rollback_artifacts;
80
- if (artifacts) {
81
- lines.push("rollback:");
82
- if (artifacts.revert_command) {
83
- add(` revert_command: ${artifacts.revert_command}`);
84
- }
85
- if (artifacts.revert_branch) {
86
- add(` revert_branch: ${artifacts.revert_branch}`);
87
- }
88
- if (artifacts.patch_bundle) {
89
- add(` patch_bundle: ${artifacts.patch_bundle}`);
90
- }
91
- }
92
-
93
- return lines;
14
+ const w = Math.max(40, width);
15
+ return formatPlanPacketYaml(packet)
16
+ .split("\n")
17
+ .map((line) => {
18
+ if (line.length <= w) return line;
19
+ return `${line.slice(0, w - 1)}…`;
20
+ });
94
21
  }
@@ -6,7 +6,7 @@ import {
6
6
  type HarnessRunContext,
7
7
  type PlanPacketLike,
8
8
  } from "../../../lib/harness-run-context.js";
9
- import { formatPlanPacketLines } from "./format-plan.js";
9
+ import { formatPlanPacketYaml } from "./format-plan.js";
10
10
  import type { PlanResearchBrief } from "./types.js";
11
11
 
12
12
  export {
@@ -217,7 +217,7 @@ export function formatPlanPacketMarkdown(
217
217
  `- **risk_level:** ${typeof packet.risk_level === "string" ? packet.risk_level : "med"}`,
218
218
  );
219
219
  if (opts?.plan_packet_path) {
220
- lines.push(`- **canonical JSON:** \`${opts.plan_packet_path}\``);
220
+ lines.push(`- **canonical YAML:** \`${opts.plan_packet_path}\``);
221
221
  }
222
222
  lines.push("");
223
223
  if (opts?.human_summary?.trim()) {
@@ -233,15 +233,15 @@ export function formatPlanPacketMarkdown(
233
233
  }
234
234
  lines.push("## Plan packet");
235
235
  lines.push("");
236
- lines.push("```text");
237
- for (const line of formatPlanPacketLines(packet, 100)) {
236
+ lines.push("```yaml");
237
+ for (const line of formatPlanPacketYaml(packet).split("\n")) {
238
238
  lines.push(line);
239
239
  }
240
240
  lines.push("```");
241
241
  lines.push("");
242
242
  if (status === "draft") {
243
243
  lines.push(
244
- "Review this file in your editor, then return to the harness TUI to **Approve**, **Request changes**, or **Cancel**.",
244
+ "Review this plan, then choose **Approve**, **Request changes**, or **Cancel** in the prompt below (same flow as `ask_user`).",
245
245
  );
246
246
  } else if (status === "approved") {
247
247
  lines.push(
@@ -388,6 +388,6 @@ export function formatPlanReviewUserHint(reviewPath: string | null): string {
388
388
  const abs = resolve(reviewPath);
389
389
  return (
390
390
  `Full plan for editor review: ${abs}\n` +
391
- `Open this markdown file in VS Code (or your editor), read the scope and acceptance checks, then return to the harness TUI to Approve / Request changes / Cancel.`
391
+ `Open this markdown file in your editor if you prefer; approval options appear in the harness prompt below.`
392
392
  );
393
393
  }
@@ -61,10 +61,16 @@ export function renderHarnessPlanDraft(
61
61
  details: {
62
62
  plan_packet?: PlanPacketLike;
63
63
  human_summary?: string | null;
64
+ plan_markdown?: string | null;
64
65
  },
65
66
  width: number,
66
67
  theme: Theme,
68
+ content?: string | null,
67
69
  ): string[] {
70
+ const markdown = content?.trim() || details.plan_markdown?.trim();
71
+ if (markdown) {
72
+ return markdown.split("\n").map((line) => truncateToWidth(line, width));
73
+ }
68
74
  const lines: string[] = [];
69
75
  lines.push(theme.fg("accent", "Harness plan (pending approval)"));
70
76
  if (details.human_summary) {
@@ -36,7 +36,7 @@ export function validateApprovePlanParams(
36
36
  human_summary: params.human_summary?.trim() || undefined,
37
37
  research_brief: params.research_brief ?? undefined,
38
38
  options,
39
- displayMode: params.displayMode ?? "overlay",
39
+ displayMode: params.displayMode ?? "inline",
40
40
  };
41
41
  }
42
42
 
@@ -51,6 +51,7 @@ export function buildPlanReviewRoundEnvelope(
51
51
  token_usage: { per_agent: Record<string, number>; round_total: number };
52
52
  consensus_delta: number;
53
53
  severity_scores?: PlanReviewRoundDraft["severity_scores"];
54
+ review_gate_ready?: boolean;
54
55
  };
55
56
  } {
56
57
  const participants = (draft.participants ?? [
@@ -79,6 +80,7 @@ export function buildPlanReviewRoundEnvelope(
79
80
  },
80
81
  consensus_delta: draft.consensus_delta ?? 0,
81
82
  severity_scores: draft.severity_scores,
83
+ review_gate_ready: draft.review_gate_ready,
82
84
  },
83
85
  };
84
86
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * P0 — plan debate artifact + bus gates before approve_plan.
3
+ */
4
+
5
+ import { constants } from "node:fs";
6
+ import { access, readFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { planDebateIdForRun } from "./plan-debate-id.js";
9
+ import {
10
+ getMessengerRoundState,
11
+ loadMessengerState,
12
+ messengerRoundDebateReady,
13
+ } from "./plan-messenger.js";
14
+
15
+ const PLAN_ROUNDS = 4;
16
+ const FOCUS_BY_ROUND = ["spec", "wbs", "schedule", "quality"] as const;
17
+
18
+ function laneFilesForRound(roundIndex: number): string[] {
19
+ const n = roundIndex;
20
+ const lanes = [
21
+ `artifacts/validation-turn-r${n}.yaml`,
22
+ `artifacts/adversary-brief-r${n}.yaml`,
23
+ ];
24
+ if (n === 1) {
25
+ lanes.unshift(`artifacts/hypothesis-validation-r${n}.yaml`);
26
+ }
27
+ if (n === 4) {
28
+ lanes.push(`artifacts/sprint-audit-r${n}.yaml`);
29
+ }
30
+ lanes.push(`artifacts/review-round-r${n}.yaml`);
31
+ return lanes;
32
+ }
33
+
34
+ async function fileExists(path: string): Promise<boolean> {
35
+ try {
36
+ await access(path, constants.R_OK);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ async function countJsonlKinds(
44
+ debateJsonlPath: string,
45
+ ): Promise<{ rounds: number; hasConsensus: boolean }> {
46
+ try {
47
+ const raw = await readFile(debateJsonlPath, "utf-8");
48
+ let rounds = 0;
49
+ let hasConsensus = false;
50
+ for (const line of raw.split("\n")) {
51
+ if (!line.trim()) continue;
52
+ const ev = JSON.parse(line) as { kind?: string };
53
+ if (ev.kind === "round") rounds += 1;
54
+ if (ev.kind === "consensus") hasConsensus = true;
55
+ }
56
+ return { rounds, hasConsensus };
57
+ } catch {
58
+ return { rounds: 0, hasConsensus: false };
59
+ }
60
+ }
61
+
62
+ export interface PlanDebateGateResult {
63
+ ok: boolean;
64
+ errors: string[];
65
+ warnings: string[];
66
+ debateId: string;
67
+ }
68
+
69
+ export async function validatePlanDebateGate(
70
+ projectRoot: string,
71
+ runId: string,
72
+ ): Promise<PlanDebateGateResult> {
73
+ const errors: string[] = [];
74
+ const warnings: string[] = [];
75
+ const debateId = planDebateIdForRun(runId);
76
+ const runDir = join(projectRoot, ".pi", "harness", "runs", runId);
77
+ const debatesDir = join(projectRoot, ".pi", "harness", "debates");
78
+
79
+ for (let r = 1; r <= PLAN_ROUNDS; r++) {
80
+ for (const rel of laneFilesForRound(r)) {
81
+ const abs = join(runDir, rel);
82
+ if (!(await fileExists(abs))) {
83
+ errors.push(`missing ${rel}`);
84
+ }
85
+ }
86
+ const roundState = await getMessengerRoundState(runDir, r);
87
+ const messengerCheck = messengerRoundDebateReady(roundState, r === 4);
88
+ if (!messengerCheck.ok) {
89
+ for (const e of messengerCheck.errors) {
90
+ errors.push(`round ${r} messenger: ${e}`);
91
+ }
92
+ }
93
+ }
94
+
95
+ const messenger = await loadMessengerState(runDir);
96
+ if (!messenger) {
97
+ errors.push(
98
+ "debate-messenger/state.json missing — call harness_debate_open",
99
+ );
100
+ } else if (messenger.debate_id !== debateId) {
101
+ errors.push(`messenger debate_id ${messenger.debate_id} !== ${debateId}`);
102
+ }
103
+
104
+ const jsonlPath = join(debatesDir, `${debateId}.jsonl`);
105
+ const { rounds, hasConsensus } = await countJsonlKinds(jsonlPath);
106
+ if (rounds < PLAN_ROUNDS) {
107
+ errors.push(
108
+ `${debateId}.jsonl has ${rounds}/${PLAN_ROUNDS} round events — use harness_debate_submit_round each round`,
109
+ );
110
+ }
111
+ if (!hasConsensus) {
112
+ errors.push(
113
+ `missing consensus on ${debateId} — call harness_debate_consensus`,
114
+ );
115
+ }
116
+
117
+ const consensusPath = join(debatesDir, `${debateId}.consensus.json`);
118
+ if (!(await fileExists(consensusPath))) {
119
+ errors.push(`missing ${debateId}.consensus.json`);
120
+ } else {
121
+ try {
122
+ const raw = await readFile(consensusPath, "utf-8");
123
+ const packet = JSON.parse(raw) as { policy_decision?: string };
124
+ if (packet.policy_decision === "block") {
125
+ errors.push("consensus policy_decision is block — cannot approve");
126
+ }
127
+ } catch {
128
+ errors.push("invalid consensus json");
129
+ }
130
+ }
131
+
132
+ for (let r = 0; r < FOCUS_BY_ROUND.length; r++) {
133
+ const focus = FOCUS_BY_ROUND[r];
134
+ const reviewPath = join(runDir, `artifacts/review-round-r${r + 1}.yaml`);
135
+ if (await fileExists(reviewPath)) {
136
+ const raw = await readFile(reviewPath, "utf-8");
137
+ if (!raw.includes(focus)) {
138
+ warnings.push(`review-round-r${r + 1} may not match focus ${focus}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ return {
144
+ ok: errors.length === 0,
145
+ errors,
146
+ warnings,
147
+ debateId,
148
+ };
149
+ }
150
+
151
+ export function isReviewRoundArtifactPath(relPath: string): boolean {
152
+ return /^artifacts\/review-round-r\d+\.yaml$/i.test(
153
+ relPath.replace(/\\/g, "/"),
154
+ );
155
+ }