ultimate-pi 0.15.0 → 0.17.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 (90) hide show
  1. package/.agents/skills/harness-governor/SKILL.md +11 -0
  2. package/.agents/skills/harness-orchestration/SKILL.md +3 -1
  3. package/.agents/skills/harness-plan/SKILL.md +5 -5
  4. package/.pi/agents/harness/adversary.md +1 -1
  5. package/.pi/agents/harness/evaluator.md +1 -1
  6. package/.pi/agents/harness/executor.md +1 -1
  7. package/.pi/agents/harness/incident-recorder.md +1 -1
  8. package/.pi/agents/harness/meta-optimizer.md +1 -1
  9. package/.pi/agents/harness/planning/decompose.md +4 -33
  10. package/.pi/agents/harness/planning/execution-plan-author.md +3 -2
  11. package/.pi/agents/harness/planning/hypothesis-validator.md +3 -2
  12. package/.pi/agents/harness/planning/hypothesis.md +4 -27
  13. package/.pi/agents/harness/planning/implementation-researcher.md +3 -2
  14. package/.pi/agents/harness/planning/plan-adversary.md +2 -3
  15. package/.pi/agents/harness/planning/plan-evaluator.md +3 -2
  16. package/.pi/agents/harness/planning/review-integrator.md +2 -3
  17. package/.pi/agents/harness/planning/scout-graphify.md +3 -22
  18. package/.pi/agents/harness/planning/scout-semantic.md +3 -18
  19. package/.pi/agents/harness/planning/scout-structure.md +3 -18
  20. package/.pi/agents/harness/planning/sprint-contract-auditor.md +3 -2
  21. package/.pi/agents/harness/planning/stack-researcher.md +3 -2
  22. package/.pi/agents/harness/tie-breaker.md +1 -1
  23. package/.pi/agents/harness/trace-librarian.md +1 -1
  24. package/.pi/extensions/budget-guard.ts +33 -19
  25. package/.pi/extensions/harness-debate-tools.ts +54 -6
  26. package/.pi/extensions/harness-run-context.ts +108 -2
  27. package/.pi/extensions/harness-subagent-submit.ts +172 -0
  28. package/.pi/extensions/harness-telemetry.ts +29 -4
  29. package/.pi/extensions/lib/debate-bus-core.ts +49 -6
  30. package/.pi/extensions/lib/harness-subagent-auth.ts +104 -19
  31. package/.pi/extensions/lib/harness-subagent-policy.ts +59 -0
  32. package/.pi/extensions/lib/harness-subagent-submit-pipeline.ts +82 -0
  33. package/.pi/extensions/lib/harness-subagent-submit-registry.ts +172 -0
  34. package/.pi/extensions/lib/harness-subagents-bridge.ts +127 -0
  35. package/.pi/extensions/lib/plan-debate-eligibility.ts +61 -8
  36. package/.pi/extensions/lib/plan-debate-focus.ts +21 -9
  37. package/.pi/extensions/lib/plan-debate-gate.ts +92 -18
  38. package/.pi/extensions/lib/plan-debate-lane.ts +15 -0
  39. package/.pi/extensions/lib/plan-debate-lanes.ts +27 -3
  40. package/.pi/extensions/lib/plan-debate-round-status.ts +18 -7
  41. package/.pi/extensions/lib/plan-messenger.ts +4 -0
  42. package/.pi/extensions/lib/plan-review-gate.ts +51 -0
  43. package/.pi/extensions/trace-recorder.ts +1 -0
  44. package/.pi/harness/agents.manifest.json +22 -22
  45. package/.pi/harness/docs/adrs/0037-subagent-submit-tools.md +31 -0
  46. package/.pi/harness/docs/adrs/0038-budget-telemetry-only.md +23 -0
  47. package/.pi/harness/docs/adrs/README.md +2 -0
  48. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/implementation-research.yaml +28 -0
  49. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/artifacts/review-round-consolidated.yaml +25 -0
  50. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-packet.yaml +196 -0
  51. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/plan-review.md +14 -0
  52. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med-fast/research-brief.yaml +62 -0
  53. package/.pi/harness/evals/smoke/smoke-harness-plan.mjs +40 -17
  54. package/.pi/harness/specs/harness-executor-handoff.schema.json +19 -0
  55. package/.pi/harness/specs/harness-human-required.schema.json +16 -0
  56. package/.pi/harness/specs/plan-review-round-draft.schema.json +1 -1
  57. package/.pi/harness/specs/plan-scout-findings.schema.json +19 -0
  58. package/.pi/lib/harness-agent-output.ts +45 -0
  59. package/.pi/lib/harness-budget-enforce.ts +18 -0
  60. package/.pi/lib/harness-schema-validate.ts +89 -0
  61. package/.pi/lib/harness-spawn-parse.ts +86 -0
  62. package/.pi/lib/harness-subagent-submit-path.ts +41 -0
  63. package/.pi/lib/harness-ui-state.ts +15 -2
  64. package/.pi/model-router.example.json +13 -4
  65. package/.pi/prompts/harness-auto.md +2 -2
  66. package/.pi/prompts/harness-plan.md +34 -14
  67. package/.pi/prompts/harness-run.md +2 -2
  68. package/.pi/prompts/harness-setup.md +4 -4
  69. package/.pi/scripts/harness-generate-model-router.mjs +118 -36
  70. package/.pi/scripts/harness-model-router-routing.test.mjs +97 -0
  71. package/.pi/scripts/harness-sync-model-router.mjs +15 -2
  72. package/.pi/scripts/harness-verify.mjs +31 -0
  73. package/.pi/scripts/harness_web/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/.pi/scripts/harness_web/__pycache__/config.cpython-314.pyc +0 -0
  75. package/.pi/scripts/harness_web/__pycache__/output.cpython-314.pyc +0 -0
  76. package/.pi/scripts/harness_web/__pycache__/scrape.cpython-314.pyc +0 -0
  77. package/.pi/scripts/harness_web/__pycache__/search.cpython-314.pyc +0 -0
  78. package/.pi/scripts/harness_web/__pycache__/search_ddg.cpython-314.pyc +0 -0
  79. package/.pi/scripts/harness_web/__pycache__/search_searxng.cpython-314.pyc +0 -0
  80. package/CHANGELOG.md +21 -0
  81. package/package.json +4 -2
  82. package/vendor/pi-model-router/UPSTREAM_PIN.md +3 -1
  83. package/vendor/pi-model-router/extensions/commands.ts +4 -4
  84. package/vendor/pi-model-router/extensions/index.ts +21 -0
  85. package/vendor/pi-model-router/extensions/provider.ts +130 -79
  86. package/vendor/pi-model-router/extensions/routing.ts +148 -0
  87. package/vendor/pi-model-router/extensions/state.ts +3 -0
  88. package/vendor/pi-model-router/extensions/types.ts +9 -0
  89. package/vendor/pi-model-router/extensions/ui.ts +16 -2
  90. package/vendor/pi-subagents/src/subagents.ts +29 -3
@@ -2,6 +2,7 @@
2
2
  * ultimate-pi harness wrapper around vendored pi-subagents.
3
3
  */
4
4
 
5
+ import { join } from "node:path";
5
6
  import type {
6
7
  ExtensionAPI,
7
8
  ExtensionContext,
@@ -12,6 +13,13 @@ import {
12
13
  type HarnessSubagentsOptions,
13
14
  type SpawnAuthForward,
14
15
  } from "../../../vendor/pi-subagents/src/subagents.js";
16
+ import {
17
+ getLatestRunContext,
18
+ getRunIdFromSession,
19
+ type HarnessPhase,
20
+ } from "../../lib/harness-run-context.js";
21
+ import { parseSpawnContextFromTask } from "../../lib/harness-spawn-parse.js";
22
+ import { harnessSubagentSubmitExtensionPath } from "../harness-subagent-submit.js";
15
23
  import { refreshHarnessCocoindexIndex } from "./harness-cocoindex-refresh.js";
16
24
  import { captureHarnessEvent } from "./harness-posthog.js";
17
25
  import {
@@ -32,6 +40,51 @@ import {
32
40
 
33
41
  const spawnBudget = createSpawnBudgetState();
34
42
  let lastSessionId = "harness";
43
+ let spawnGroupCounter = 0;
44
+ type PendingSpawnTelemetry = {
45
+ harness_run_id: string;
46
+ run_id: string;
47
+ harness_plan_id: string;
48
+ harness_phase: HarnessPhase;
49
+ agent_ids: string[];
50
+ spawn_group_id: string;
51
+ };
52
+ let pendingSpawnTelemetry: PendingSpawnTelemetry | null = null;
53
+
54
+ function collectHarnessAgentIds(params: Record<string, unknown>): string[] {
55
+ const out = new Set<string>();
56
+ const maybe = params as {
57
+ agent?: string;
58
+ chain?: Array<{ agent?: string }>;
59
+ tasks?: Array<{ agent?: string }>;
60
+ aggregator?: { agent?: string };
61
+ };
62
+ if (typeof maybe.agent === "string" && maybe.agent.startsWith("harness/")) {
63
+ out.add(maybe.agent);
64
+ }
65
+ for (const item of maybe.chain ?? []) {
66
+ if (typeof item?.agent === "string" && item.agent.startsWith("harness/")) {
67
+ out.add(item.agent);
68
+ }
69
+ }
70
+ for (const item of maybe.tasks ?? []) {
71
+ if (typeof item?.agent === "string" && item.agent.startsWith("harness/")) {
72
+ out.add(item.agent);
73
+ }
74
+ }
75
+ if (
76
+ typeof maybe.aggregator?.agent === "string" &&
77
+ maybe.aggregator.agent.startsWith("harness/")
78
+ ) {
79
+ out.add(maybe.aggregator.agent);
80
+ }
81
+ return Array.from(out.values()).sort();
82
+ }
83
+
84
+ function nextSpawnGroupId(sessionId: string): string {
85
+ spawnGroupCounter += 1;
86
+ return `${sessionId}-${Date.now()}-${spawnGroupCounter}`;
87
+ }
35
88
 
36
89
  async function resolveHarnessSpawnAuth(
37
90
  ctx: ExtensionContext,
@@ -58,8 +111,47 @@ async function resolveHarnessSpawnAuth(
58
111
  export function createHarnessSubagentsExtension(
59
112
  packageRoot: string,
60
113
  ): (pi: ExtensionAPI) => void {
114
+ const submitExtPath = harnessSubagentSubmitExtensionPath(packageRoot);
61
115
  const options: HarnessSubagentsOptions = {
62
116
  packageRoot,
117
+ harnessSubprocessExtensionPath: submitExtPath,
118
+ resolveSubprocessEnv: (task, agent) => {
119
+ if (!agent.name.startsWith("harness/")) return undefined;
120
+ const ctx = parseSpawnContextFromTask(task);
121
+ // #region agent log
122
+ fetch(
123
+ "http://127.0.0.1:7928/ingest/a5d40896-34cb-4f12-97db-df7ada0b22f0",
124
+ {
125
+ method: "POST",
126
+ headers: {
127
+ "Content-Type": "application/json",
128
+ "X-Debug-Session-Id": "2ca12b",
129
+ },
130
+ body: JSON.stringify({
131
+ sessionId: "2ca12b",
132
+ hypothesisId: "H1",
133
+ location: "harness-subagents-bridge.ts:resolveSubprocessEnv",
134
+ message: "parsed spawn context for subprocess env",
135
+ data: {
136
+ agent: agent.name,
137
+ hasCtx: Boolean(ctx?.run_id),
138
+ run_id: ctx?.run_id ?? null,
139
+ run_dir: ctx?.run_dir ?? null,
140
+ taskPrefix: task.slice(0, 160),
141
+ },
142
+ timestamp: Date.now(),
143
+ }),
144
+ },
145
+ ).catch(() => {});
146
+ // #endregion
147
+ if (!ctx?.run_id) return undefined;
148
+ return {
149
+ HARNESS_RUN_ID: ctx.run_id,
150
+ HARNESS_RUN_DIR:
151
+ ctx.run_dir ??
152
+ join(packageRoot, ".pi", "harness", "runs", ctx.run_id),
153
+ };
154
+ },
63
155
  defaultAgentScope: "both",
64
156
  defaultConfirmProjectAgents: false,
65
157
  truncateDetails: true,
@@ -69,11 +161,13 @@ export function createHarnessSubagentsExtension(
69
161
  const { harnessCount } = countHarnessAgentsInRequest(
70
162
  params as Parameters<typeof countHarnessAgentsInRequest>[0],
71
163
  );
164
+ pendingSpawnTelemetry = null;
72
165
  if (harnessCount > 0) {
73
166
  const budget = checkHarnessSpawnBudget(spawnBudget, harnessCount);
74
167
  if (!budget.ok) {
75
168
  return { ok: false, message: budget.message };
76
169
  }
170
+ const entries = ctx.sessionManager.getEntries();
77
171
  const phase = inferPhaseForPrecheck(ctx.sessionManager.getEntries());
78
172
  const pre = precheckHarnessSubagentSpawn(
79
173
  params as Parameters<typeof precheckHarnessSubagentSpawn>[0],
@@ -91,6 +185,19 @@ export function createHarnessSubagentsExtension(
91
185
  return { ok: false, message: refreshMsg };
92
186
  }
93
187
  }
188
+ const runCtx = getLatestRunContext(entries);
189
+ const runId =
190
+ runCtx?.run_id ??
191
+ getRunIdFromSession(entries, lastSessionId) ??
192
+ lastSessionId;
193
+ pendingSpawnTelemetry = {
194
+ harness_run_id: runId,
195
+ run_id: runId,
196
+ harness_plan_id: runCtx?.plan_id ?? "plan-unknown",
197
+ harness_phase: phase,
198
+ agent_ids: collectHarnessAgentIds(params as Record<string, unknown>),
199
+ spawn_group_id: nextSpawnGroupId(lastSessionId),
200
+ };
94
201
  }
95
202
  return { ok: true };
96
203
  },
@@ -100,6 +207,16 @@ export function createHarnessSubagentsExtension(
100
207
  captureHarnessEvent(lastSessionId, "harness_subagent_spawned", {
101
208
  active_after: spawnBudget.active,
102
209
  spawn_count: harnessCount,
210
+ harness_run_id: pendingSpawnTelemetry?.harness_run_id ?? lastSessionId,
211
+ run_id: pendingSpawnTelemetry?.run_id ?? lastSessionId,
212
+ harness_plan_id:
213
+ pendingSpawnTelemetry?.harness_plan_id ?? "plan-unknown",
214
+ harness_phase: pendingSpawnTelemetry?.harness_phase ?? "plan",
215
+ agent_ids: pendingSpawnTelemetry?.agent_ids ?? [],
216
+ agent_count: pendingSpawnTelemetry?.agent_ids.length ?? harnessCount,
217
+ spawn_group_id:
218
+ pendingSpawnTelemetry?.spawn_group_id ??
219
+ nextSpawnGroupId(lastSessionId),
103
220
  });
104
221
  },
105
222
  onSpawnEnd: (harnessCount) => {
@@ -112,7 +229,17 @@ export function createHarnessSubagentsExtension(
112
229
  mode,
113
230
  duration_ms: durationMs,
114
231
  agent_count: agents.length,
232
+ agent_ids: agents,
233
+ harness_run_id: pendingSpawnTelemetry?.harness_run_id ?? lastSessionId,
234
+ run_id: pendingSpawnTelemetry?.run_id ?? lastSessionId,
235
+ harness_plan_id:
236
+ pendingSpawnTelemetry?.harness_plan_id ?? "plan-unknown",
237
+ harness_phase: pendingSpawnTelemetry?.harness_phase ?? "plan",
238
+ spawn_group_id:
239
+ pendingSpawnTelemetry?.spawn_group_id ??
240
+ nextSpawnGroupId(lastSessionId),
115
241
  });
242
+ pendingSpawnTelemetry = null;
116
243
  },
117
244
  };
118
245
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { PLAN_FOCUS_AREAS, type PlanDebateFocus } from "./plan-debate-focus.js";
6
6
 
7
- export type DebateProfile = "full" | "standard" | "light";
7
+ export type DebateProfile = "full" | "standard" | "light" | "fast";
8
8
 
9
9
  export interface DebateEligibilityInput {
10
10
  risk_level?: string;
@@ -26,6 +26,7 @@ export interface DebateEligibilityResult {
26
26
  debate_global_cap: number;
27
27
  human_required: boolean;
28
28
  rationale: string[];
29
+ review_gate_strategy: PlanReviewGateStrategy;
29
30
  }
30
31
 
31
32
  const LIGHT_FOCUS: PlanDebateFocus[] = ["spec", "quality"];
@@ -75,7 +76,7 @@ function confidenceAllowsLight(brief: Record<string, unknown> | null): boolean {
75
76
  if (!rationale || refs.length < 2) return false;
76
77
  if (implementationOpenQuestions(brief).length > 0) return false;
77
78
  const patterns = Array.isArray(brief?.solution_patterns)
78
- ? (brief!.solution_patterns as unknown[])
79
+ ? (brief?.solution_patterns as unknown[])
79
80
  : [];
80
81
  for (const p of patterns) {
81
82
  const pat = asRecord(p);
@@ -85,7 +86,7 @@ function confidenceAllowsLight(brief: Record<string, unknown> | null): boolean {
85
86
  }
86
87
  }
87
88
  const similar = Array.isArray(brief?.similar_implementations)
88
- ? (brief!.similar_implementations as unknown[])
89
+ ? (brief?.similar_implementations as unknown[])
89
90
  : [];
90
91
  if (similar.length === 0) return false;
91
92
  return true;
@@ -116,17 +117,46 @@ export const PLAN_BUDGET_LIGHT = {
116
117
  debate_global_cap: 40000,
117
118
  } as const;
118
119
 
120
+ export const PLAN_BUDGET_FAST = {
121
+ min_focus_rounds: 1,
122
+ max_rounds: 2,
123
+ max_exchanges_per_round: 1,
124
+ round_token_cap: 3500,
125
+ debate_global_cap: 20000,
126
+ } as const;
127
+
128
+ export interface PlanReviewGateStrategy {
129
+ mode: "consolidated" | "threaded";
130
+ profile: DebateProfile;
131
+ required_focuses: PlanDebateFocus[];
132
+ min_focus_rounds: number;
133
+ max_rounds: number;
134
+ max_exchanges_per_round: number;
135
+ round_token_cap: number;
136
+ debate_global_cap: number;
137
+ rationale: string[];
138
+ }
139
+
119
140
  function capsForProfile(
120
141
  profile: DebateProfile,
121
142
  ): Omit<
122
143
  DebateEligibilityResult,
123
- "profile" | "required_focuses" | "human_required" | "rationale"
144
+ | "profile"
145
+ | "required_focuses"
146
+ | "human_required"
147
+ | "rationale"
148
+ | "review_gate_strategy"
124
149
  > {
125
150
  if (profile === "light") {
126
151
  return {
127
152
  ...PLAN_BUDGET_LIGHT,
128
153
  };
129
154
  }
155
+ if (profile === "fast") {
156
+ return {
157
+ ...PLAN_BUDGET_FAST,
158
+ };
159
+ }
130
160
  return {
131
161
  ...PLAN_BUDGET_STANDARD,
132
162
  };
@@ -161,7 +191,7 @@ export function harnessPlanDebateEligibility(
161
191
 
162
192
  const conflictingPatterns =
163
193
  Array.isArray(impl?.solution_patterns) &&
164
- (impl!.solution_patterns as unknown[]).length >= 2 &&
194
+ (impl?.solution_patterns as unknown[]).length >= 2 &&
165
195
  openQs.length > 0;
166
196
  if (conflictingPatterns) {
167
197
  human_required = true;
@@ -182,6 +212,18 @@ export function harnessPlanDebateEligibility(
182
212
  rationale.push(
183
213
  "full: high risk, material fork, open questions, DAG patch, or tensions",
184
214
  );
215
+ } else if (
216
+ risk === "med" &&
217
+ !materialFork &&
218
+ !dagPatched &&
219
+ input.dag_pass !== false &&
220
+ openQs.length === 0 &&
221
+ stackHasClearPrimary(stack)
222
+ ) {
223
+ profile = "fast";
224
+ rationale.push(
225
+ "fast: medium risk with clear stack and no open questions; use consolidated review with escalation on blockers",
226
+ );
185
227
  } else if (
186
228
  risk === "low" &&
187
229
  !materialFork &&
@@ -190,9 +232,9 @@ export function harnessPlanDebateEligibility(
190
232
  confidenceAllowsLight(impl) &&
191
233
  stackHasClearPrimary(stack)
192
234
  ) {
193
- profile = "light";
235
+ profile = "fast";
194
236
  rationale.push(
195
- "light: low risk, clear stack, high-confidence implementation approach",
237
+ "fast: low risk, clear stack, high-confidence implementation approach",
196
238
  );
197
239
  } else if (risk === "med") {
198
240
  profile = "standard";
@@ -200,7 +242,7 @@ export function harnessPlanDebateEligibility(
200
242
  }
201
243
 
202
244
  const required_focuses: PlanDebateFocus[] =
203
- profile === "light" ? [...LIGHT_FOCUS] : [...PLAN_FOCUS_AREAS];
245
+ profile === "fast" ? [...LIGHT_FOCUS] : [...PLAN_FOCUS_AREAS];
204
246
 
205
247
  const caps = capsForProfile(profile);
206
248
 
@@ -210,5 +252,16 @@ export function harnessPlanDebateEligibility(
210
252
  ...caps,
211
253
  human_required,
212
254
  rationale,
255
+ review_gate_strategy: {
256
+ mode: profile === "fast" ? "consolidated" : "threaded",
257
+ profile,
258
+ required_focuses: [...required_focuses],
259
+ min_focus_rounds: caps.min_focus_rounds,
260
+ max_rounds: caps.max_rounds,
261
+ max_exchanges_per_round: caps.max_exchanges_per_round,
262
+ round_token_cap: caps.round_token_cap,
263
+ debate_global_cap: caps.debate_global_cap,
264
+ rationale: [...rationale],
265
+ },
213
266
  };
214
267
  }
@@ -9,12 +9,13 @@ import { parse as parseYaml } from "yaml";
9
9
 
10
10
  export const PLAN_FOCUS_AREAS = ["spec", "wbs", "schedule", "quality"] as const;
11
11
  export type PlanDebateFocus = (typeof PLAN_FOCUS_AREAS)[number];
12
+ export type PlanDebateRoundFocus = PlanDebateFocus | "all";
12
13
 
13
14
  export interface PlanFocusCoverage {
14
15
  covered: PlanDebateFocus[];
15
16
  missing: PlanDebateFocus[];
16
17
  rounds_by_focus: Partial<Record<PlanDebateFocus, number>>;
17
- focus_by_round: Partial<Record<number, PlanDebateFocus>>;
18
+ focus_by_round: Partial<Record<number, PlanDebateRoundFocus>>;
18
19
  last_review_gate_ready: boolean;
19
20
  last_round_index: number;
20
21
  }
@@ -34,8 +35,9 @@ async function fileExists(path: string): Promise<boolean> {
34
35
 
35
36
  function focusFromDraft(
36
37
  draft: Record<string, unknown>,
37
- ): PlanDebateFocus | null {
38
+ ): PlanDebateRoundFocus | null {
38
39
  const focus = String(draft.debate_round_focus ?? "").trim();
40
+ if (focus === "all") return "all";
39
41
  if ((PLAN_FOCUS_AREAS as readonly string[]).includes(focus)) {
40
42
  return focus as PlanDebateFocus;
41
43
  }
@@ -56,14 +58,14 @@ export async function getPlanFocusCoverage(
56
58
  const artifactsDir = join(runDir, "artifacts");
57
59
  const covered = new Set<PlanDebateFocus>();
58
60
  const rounds_by_focus: Partial<Record<PlanDebateFocus, number>> = {};
59
- const focus_by_round: Partial<Record<number, PlanDebateFocus>> = {};
61
+ const focus_by_round: Partial<Record<number, PlanDebateRoundFocus>> = {};
60
62
  let last_review_gate_ready = false;
61
63
  let last_round_index = 0;
62
64
 
63
65
  let files: string[] = [];
64
66
  try {
65
67
  files = (await readdir(artifactsDir)).filter((f) =>
66
- /^review-round-r\d+\.yaml$/i.test(f),
68
+ /^review-round(?:-r\d+|-consolidated)\.yaml$/i.test(f),
67
69
  );
68
70
  } catch {
69
71
  return {
@@ -77,9 +79,12 @@ export async function getPlanFocusCoverage(
77
79
  }
78
80
 
79
81
  for (const name of files.sort()) {
80
- const m = /^review-round-r(\d+)\.yaml$/i.exec(name);
82
+ const consolidated = /^review-round-consolidated\.yaml$/i.test(name);
83
+ const m = consolidated
84
+ ? ["review-round-consolidated.yaml", "1"]
85
+ : /^review-round-r(\d+)\.yaml$/i.exec(name);
81
86
  if (!m) continue;
82
- const roundIndex = Number(m[1]);
87
+ const roundIndex = consolidated ? 1 : Number(m[1]);
83
88
  if (roundIndex > last_round_index) last_round_index = roundIndex;
84
89
  const raw = await readFile(join(artifactsDir, name), "utf-8");
85
90
  let draft: Record<string, unknown>;
@@ -90,8 +95,15 @@ export async function getPlanFocusCoverage(
90
95
  }
91
96
  const focus = focusFromDraft(draft);
92
97
  if (focus) {
93
- covered.add(focus);
94
- rounds_by_focus[focus] = roundIndex;
98
+ if (focus === "all") {
99
+ for (const requiredFocus of required) {
100
+ covered.add(requiredFocus);
101
+ rounds_by_focus[requiredFocus] = roundIndex;
102
+ }
103
+ } else {
104
+ covered.add(focus);
105
+ rounds_by_focus[focus] = roundIndex;
106
+ }
95
107
  focus_by_round[roundIndex] = focus;
96
108
  }
97
109
  if (roundIndex === last_round_index) {
@@ -138,7 +150,7 @@ export function planDebateOutcomeComplete(
138
150
  export async function readDebateRoundFocus(
139
151
  runDir: string,
140
152
  roundIndex: number,
141
- ): Promise<PlanDebateFocus | null> {
153
+ ): Promise<PlanDebateRoundFocus | null> {
142
154
  const path = join(runDir, "artifacts", `review-round-r${roundIndex}.yaml`);
143
155
  if (!(await fileExists(path))) return null;
144
156
  try {
@@ -5,19 +5,29 @@
5
5
  import { constants } from "node:fs";
6
6
  import { access, readFile } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
+ import { isHarnessBudgetEnforceOn } from "../../lib/harness-budget-enforce.js";
8
9
  import { capsForDebate } from "./debate-bus-core.js";
10
+ import type { DebateEligibilityResult } from "./plan-debate-eligibility.js";
9
11
  import {
10
12
  getPlanFocusCoverage,
11
13
  type PlanDebateFocus,
12
14
  planDebateOutcomeComplete,
13
15
  } from "./plan-debate-focus.js";
14
16
  import { planDebateIdForRun } from "./plan-debate-id.js";
15
- import { laneArtifactPathsForRound } from "./plan-debate-lanes.js";
17
+ import {
18
+ laneArtifactPathsForConsolidatedRound,
19
+ laneArtifactPathsForRound,
20
+ } from "./plan-debate-lanes.js";
16
21
  import {
17
22
  getMessengerRoundState,
18
23
  loadMessengerState,
19
24
  messengerRoundDebateReady,
20
25
  } from "./plan-messenger.js";
26
+ import {
27
+ CONSOLIDATED_REVIEW_ARTIFACT,
28
+ isConsolidatedReviewStrategy,
29
+ planReviewGateStrategyFromEligibility,
30
+ } from "./plan-review-gate.js";
21
31
 
22
32
  async function fileExists(path: string): Promise<boolean> {
23
33
  try {
@@ -63,6 +73,7 @@ export interface PlanDebateGateResult {
63
73
  export async function validatePlanDebateGate(
64
74
  projectRoot: string,
65
75
  runId: string,
76
+ eligibility?: DebateEligibilityResult,
66
77
  ): Promise<PlanDebateGateResult> {
67
78
  const errors: string[] = [];
68
79
  const warnings: string[] = [];
@@ -76,6 +87,33 @@ export async function validatePlanDebateGate(
76
87
  ? messenger.required_focuses
77
88
  : (["spec", "wbs", "schedule", "quality"] as const);
78
89
  const caps = capsForDebate(debateId, debateProfile);
90
+ const reviewStrategy =
91
+ eligibility != null
92
+ ? planReviewGateStrategyFromEligibility(eligibility)
93
+ : messenger?.review_gate_mode === "consolidated"
94
+ ? {
95
+ mode: "consolidated" as const,
96
+ profile: debateProfile as DebateEligibilityResult["profile"],
97
+ required_focuses: [...requiredFocuses],
98
+ min_focus_rounds: caps.min_focus_rounds,
99
+ max_rounds: caps.max_rounds,
100
+ max_exchanges_per_round: caps.max_exchanges_per_round,
101
+ round_token_cap: caps.round_token_cap,
102
+ debate_global_cap: caps.debate_global_cap,
103
+ rationale: ["messenger review_gate_mode=consolidated"],
104
+ }
105
+ : {
106
+ mode: "threaded" as const,
107
+ profile: debateProfile as DebateEligibilityResult["profile"],
108
+ required_focuses: [...requiredFocuses],
109
+ min_focus_rounds: caps.min_focus_rounds,
110
+ max_rounds: caps.max_rounds,
111
+ max_exchanges_per_round: caps.max_exchanges_per_round,
112
+ round_token_cap: caps.round_token_cap,
113
+ debate_global_cap: caps.debate_global_cap,
114
+ rationale: [],
115
+ };
116
+ const consolidated = isConsolidatedReviewStrategy(reviewStrategy);
79
117
  const coverage = await getPlanFocusCoverage(runDir, { requiredFocuses });
80
118
  const dialogueOpts = {
81
119
  max_exchanges_per_round: caps.max_exchanges_per_round,
@@ -88,39 +126,73 @@ export async function validatePlanDebateGate(
88
126
  errors.push("last submitted review round has review_gate_ready !== true");
89
127
  }
90
128
 
91
- const roundIndices = [
92
- ...new Set(
93
- Object.values(coverage.rounds_by_focus).filter(
94
- (v): v is number => typeof v === "number",
95
- ),
96
- ),
97
- ];
98
- for (const r of roundIndices) {
99
- const focus = coverage.focus_by_round[r] ?? null;
100
- for (const rel of laneArtifactPathsForRound(r, focus)) {
129
+ if (consolidated) {
130
+ const absConsolidated = join(runDir, CONSOLIDATED_REVIEW_ARTIFACT);
131
+ if (!(await fileExists(absConsolidated))) {
132
+ errors.push(`missing ${CONSOLIDATED_REVIEW_ARTIFACT}`);
133
+ }
134
+ for (const rel of laneArtifactPathsForConsolidatedRound()) {
101
135
  const abs = join(runDir, rel);
102
136
  if (!(await fileExists(abs))) {
103
137
  errors.push(`missing ${rel}`);
104
138
  }
105
139
  }
106
- const roundState = await getMessengerRoundState(runDir, r);
107
- const requireSprint = focus === "quality" || r >= 4;
140
+ const roundState = await getMessengerRoundState(runDir, 1);
108
141
  const messengerCheck = messengerRoundDebateReady(
109
142
  roundState,
110
- requireSprint,
143
+ true,
111
144
  dialogueOpts,
112
145
  );
113
146
  if (!messengerCheck.ok) {
114
147
  for (const e of messengerCheck.errors) {
115
- errors.push(`round ${r} messenger: ${e}`);
148
+ errors.push(`consolidated round messenger: ${e}`);
149
+ }
150
+ }
151
+ } else {
152
+ const roundIndices = [
153
+ ...new Set(
154
+ Object.values(coverage.rounds_by_focus).filter(
155
+ (v): v is number => typeof v === "number",
156
+ ),
157
+ ),
158
+ ];
159
+ for (const r of roundIndices) {
160
+ const focus = coverage.focus_by_round[r] ?? null;
161
+ for (const rel of laneArtifactPathsForRound(r, focus)) {
162
+ const abs = join(runDir, rel);
163
+ if (!(await fileExists(abs))) {
164
+ errors.push(`missing ${rel}`);
165
+ }
166
+ }
167
+ const roundState = await getMessengerRoundState(runDir, r);
168
+ const requireSprint = focus === "quality" || r >= 4;
169
+ const messengerCheck = messengerRoundDebateReady(
170
+ roundState,
171
+ requireSprint,
172
+ dialogueOpts,
173
+ );
174
+ if (!messengerCheck.ok) {
175
+ for (const e of messengerCheck.errors) {
176
+ errors.push(`round ${r} messenger: ${e}`);
177
+ }
116
178
  }
117
179
  }
118
180
  }
119
181
 
120
- if (coverage.last_round_index > caps.max_rounds) {
182
+ if (
183
+ isHarnessBudgetEnforceOn() &&
184
+ coverage.last_round_index > caps.max_rounds
185
+ ) {
121
186
  errors.push(
122
187
  `round_count ${coverage.last_round_index} exceeds max_rounds ${caps.max_rounds}`,
123
188
  );
189
+ } else if (
190
+ !isHarnessBudgetEnforceOn() &&
191
+ coverage.last_round_index > caps.max_rounds
192
+ ) {
193
+ warnings.push(
194
+ `round_count ${coverage.last_round_index} exceeds advisory max_rounds ${caps.max_rounds} (budget enforce off)`,
195
+ );
124
196
  }
125
197
 
126
198
  if (!messenger) {
@@ -192,7 +264,9 @@ export async function validatePlanDebateGate(
192
264
  }
193
265
 
194
266
  export function isReviewRoundArtifactPath(relPath: string): boolean {
195
- return /^artifacts\/review-round-r\d+\.yaml$/i.test(
196
- relPath.replace(/\\/g, "/"),
267
+ const norm = relPath.replace(/\\/g, "/");
268
+ return (
269
+ /^artifacts\/review-round-r\d+\.yaml$/i.test(norm) ||
270
+ norm === CONSOLIDATED_REVIEW_ARTIFACT
197
271
  );
198
272
  }
@@ -45,6 +45,21 @@ export function laneArtifactPath(
45
45
  }
46
46
  }
47
47
 
48
+ /** Apply messenger side effects when artifact YAML was already written via submit tool. */
49
+ export async function applyDebateLaneFromDoc(opts: {
50
+ runDir: string;
51
+ lane: DebateLaneKind;
52
+ doc: Record<string, unknown>;
53
+ roundIndex?: number;
54
+ }): Promise<ApplyDebateLaneResult> {
55
+ return applyDebateLane({
56
+ runDir: opts.runDir,
57
+ lane: opts.lane,
58
+ content: JSON.stringify(opts.doc),
59
+ roundIndex: opts.roundIndex,
60
+ });
61
+ }
62
+
48
63
  export function extractClaimIds(doc: Record<string, unknown>): string[] {
49
64
  const explicit = doc.messenger_claim_ids;
50
65
  if (Array.isArray(explicit)) {
@@ -2,13 +2,13 @@
2
2
  * Shared Review Gate lane list for a round (gate + round-status).
3
3
  */
4
4
 
5
- import type { PlanDebateFocus } from "./plan-debate-focus.js";
5
+ import type { PlanDebateRoundFocus } from "./plan-debate-focus.js";
6
6
  import type { DebateLaneKind } from "./plan-debate-lane.js";
7
7
 
8
8
  /** Lanes required before review-integrator for this round. */
9
9
  export function lanesForRound(
10
10
  roundIndex: number,
11
- debateRoundFocus?: PlanDebateFocus | null,
11
+ debateRoundFocus?: PlanDebateRoundFocus | null,
12
12
  ): DebateLaneKind[] {
13
13
  const lanes: DebateLaneKind[] = ["validation-turn", "adversary-brief"];
14
14
  if (roundIndex === 1) {
@@ -23,7 +23,7 @@ export function lanesForRound(
23
23
  /** Relative artifact paths for lane YAML + review-round. */
24
24
  export function laneArtifactPathsForRound(
25
25
  roundIndex: number,
26
- debateRoundFocus?: PlanDebateFocus | null,
26
+ debateRoundFocus?: PlanDebateRoundFocus | null,
27
27
  ): string[] {
28
28
  const paths = lanesForRound(roundIndex, debateRoundFocus).map((lane) => {
29
29
  switch (lane) {
@@ -42,3 +42,27 @@ export function laneArtifactPathsForRound(
42
42
  paths.push(`artifacts/review-round-r${roundIndex}.yaml`);
43
43
  return paths;
44
44
  }
45
+
46
+ /** Lanes for consolidated Review Gate (single round, parallel-friendly). */
47
+ export function lanesForConsolidatedRound(): DebateLaneKind[] {
48
+ return ["validation-turn", "adversary-brief", "sprint-audit"];
49
+ }
50
+
51
+ export function laneArtifactPathsForConsolidatedRound(): string[] {
52
+ const roundIndex = 1;
53
+ return [
54
+ ...lanesForConsolidatedRound().map((lane) => {
55
+ switch (lane) {
56
+ case "validation-turn":
57
+ return `artifacts/validation-turn-r${roundIndex}.yaml`;
58
+ case "adversary-brief":
59
+ return `artifacts/adversary-brief-r${roundIndex}.yaml`;
60
+ case "sprint-audit":
61
+ return `artifacts/sprint-audit-r${roundIndex}.yaml`;
62
+ default:
63
+ return `artifacts/${lane}-r${roundIndex}.yaml`;
64
+ }
65
+ }),
66
+ "artifacts/review-round-consolidated.yaml",
67
+ ];
68
+ }