ultimate-pi 0.14.0 → 0.15.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 (52) hide show
  1. package/.agents/skills/harness-debate-plan/SKILL.md +41 -61
  2. package/.agents/skills/harness-orchestration/SKILL.md +2 -2
  3. package/.agents/skills/harness-plan/SKILL.md +10 -8
  4. package/.pi/agents/harness/planning/decompose.md +4 -2
  5. package/.pi/agents/harness/planning/execution-plan-author.md +25 -14
  6. package/.pi/agents/harness/planning/hypothesis-validator.md +21 -5
  7. package/.pi/agents/harness/planning/implementation-researcher.md +42 -0
  8. package/.pi/agents/harness/planning/plan-adversary.md +19 -3
  9. package/.pi/agents/harness/planning/plan-evaluator.md +26 -5
  10. package/.pi/agents/harness/planning/review-integrator.md +23 -9
  11. package/.pi/agents/harness/planning/scout-graphify.md +1 -1
  12. package/.pi/agents/harness/planning/sprint-contract-auditor.md +19 -4
  13. package/.pi/agents/harness/planning/stack-researcher.md +19 -10
  14. package/.pi/extensions/harness-debate-tools.ts +238 -16
  15. package/.pi/extensions/harness-live-widget.ts +39 -159
  16. package/.pi/extensions/harness-plan-approval.ts +47 -5
  17. package/.pi/extensions/lib/debate-bus-core.ts +69 -15
  18. package/.pi/extensions/lib/debate-bus-state.ts +6 -0
  19. package/.pi/extensions/lib/plan-approval/plan-review.ts +56 -0
  20. package/.pi/extensions/lib/plan-approval/types.ts +1 -0
  21. package/.pi/extensions/lib/plan-debate-eligibility.ts +214 -0
  22. package/.pi/extensions/lib/plan-debate-focus.ts +151 -0
  23. package/.pi/extensions/lib/plan-debate-gate.ts +77 -34
  24. package/.pi/extensions/lib/plan-debate-lanes.ts +44 -0
  25. package/.pi/extensions/lib/plan-debate-round-status.ts +63 -20
  26. package/.pi/extensions/lib/plan-messenger.ts +93 -17
  27. package/.pi/extensions/policy-gate.ts +1 -1
  28. package/.pi/harness/README.md +1 -1
  29. package/.pi/harness/agents.manifest.json +15 -11
  30. package/.pi/harness/docs/adrs/0034-darwin-plan-research-pipeline.md +1 -3
  31. package/.pi/harness/docs/adrs/0035-plan-phase-review-gate.md +13 -5
  32. package/.pi/harness/docs/adrs/0036-implementation-research-and-selective-debate.md +51 -0
  33. package/.pi/harness/docs/adrs/README.md +2 -0
  34. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/artifacts/implementation-research.yaml +28 -0
  35. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/artifacts/review-round-r1.yaml +24 -0
  36. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/artifacts/review-round-r2.yaml +25 -0
  37. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/plan-packet.yaml +196 -0
  38. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/plan-review.md +14 -0
  39. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/research-brief.yaml +62 -0
  40. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med/artifacts/implementation-research.yaml +28 -0
  41. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med/artifacts/review-round-r2.yaml +24 -0
  42. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med/artifacts/review-round-r3.yaml +24 -0
  43. package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med/research-brief.yaml +29 -0
  44. package/.pi/harness/evals/smoke/smoke-harness-plan.mjs +97 -16
  45. package/.pi/harness/specs/plan-implementation-research-brief.schema.json +128 -0
  46. package/.pi/harness/specs/plan-review-round-draft.schema.json +1 -1
  47. package/.pi/harness/specs/round-result.schema.json +15 -2
  48. package/.pi/lib/harness-ui-state.ts +92 -0
  49. package/.pi/prompts/harness-plan.md +87 -37
  50. package/.pi/prompts/planning-rubrics.md +31 -0
  51. package/CHANGELOG.md +11 -0
  52. package/package.json +2 -2
@@ -2,6 +2,9 @@
2
2
  * harness-plan-approval — PlanPacket approval UI and transcript renderer for parent sessions.
3
3
  */
4
4
 
5
+ import { constants } from "node:fs";
6
+ import { access } from "node:fs/promises";
7
+ import { join } from "node:path";
5
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
9
  import { Text } from "@earendil-works/pi-tui";
7
10
  import { Type } from "@sinclair/typebox";
@@ -146,6 +149,43 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
146
149
  `Plan ${planId} — pending your approval`;
147
150
  const runCtx = getLatestRunContext(entries);
148
151
  const projectRoot = process.cwd();
152
+ const implWarnings: string[] = [];
153
+ if (runCtx?.run_id) {
154
+ const implPath = join(
155
+ projectRoot,
156
+ ".pi",
157
+ "harness",
158
+ "runs",
159
+ runCtx.run_id,
160
+ "artifacts",
161
+ "implementation-research.yaml",
162
+ );
163
+ let implExists = false;
164
+ try {
165
+ await access(implPath, constants.R_OK);
166
+ implExists = true;
167
+ } catch {
168
+ implExists = false;
169
+ }
170
+ const risk = String(
171
+ validated.plan_packet.risk_level ?? "med",
172
+ ).toLowerCase();
173
+ if (!implExists) {
174
+ const msg =
175
+ "approve_plan: missing artifacts/implementation-research.yaml (Phase 3.5 required)";
176
+ if (risk === "high") {
177
+ return {
178
+ content: [{ type: "text", text: msg }],
179
+ details: {
180
+ plan_packet: validated.plan_packet,
181
+ cancelled: true,
182
+ },
183
+ isError: true,
184
+ };
185
+ }
186
+ implWarnings.push(msg);
187
+ }
188
+ }
149
189
  if (runCtx?.run_id) {
150
190
  const gate = await validatePlanDebateGate(projectRoot, runCtx.run_id);
151
191
  if (!gate.ok) {
@@ -237,13 +277,15 @@ export default function harnessPlanApproval(pi: ExtensionAPI) {
237
277
  );
238
278
  }
239
279
 
240
- const text = formatApprovePlanResultText(
241
- outcome.response,
242
- outcome.cancelled,
243
- );
280
+ const text = [
281
+ formatApprovePlanResultText(outcome.response, outcome.cancelled),
282
+ ...implWarnings,
283
+ ]
284
+ .filter(Boolean)
285
+ .join("\n\n");
244
286
  return {
245
287
  content: [{ type: "text", text }],
246
- details,
288
+ details: { ...details, implementation_warnings: implWarnings },
247
289
  };
248
290
  },
249
291
 
@@ -19,6 +19,17 @@ import {
19
19
  setDebateState,
20
20
  setLastSeverity,
21
21
  } from "./debate-bus-state.js";
22
+ import {
23
+ type DebateProfile,
24
+ PLAN_BUDGET_LIGHT,
25
+ PLAN_BUDGET_STANDARD,
26
+ } from "./plan-debate-eligibility.js";
27
+ import {
28
+ getPlanFocusCoverage,
29
+ PLAN_FOCUS_AREAS,
30
+ type PlanDebateFocus,
31
+ planDebateOutcomeComplete,
32
+ } from "./plan-debate-focus.js";
22
33
 
23
34
  export type PolicyDecision =
24
35
  | "pass"
@@ -66,11 +77,7 @@ const THRESHOLDS = {
66
77
  };
67
78
  const HARD_STOP_DEBATE_CAPS = process.env.HARNESS_DEBATE_HARD_STOP === "true";
68
79
 
69
- const PLAN_BUDGET = {
70
- max_rounds: 4,
71
- round_token_cap: 2000,
72
- debate_global_cap: 12000,
73
- } as const;
80
+ const PLAN_BUDGET = PLAN_BUDGET_STANDARD;
74
81
 
75
82
  const AGGRESSIVE_BUDGET = {
76
83
  max_rounds: 6,
@@ -88,16 +95,28 @@ function toSafeFloat(value: unknown): number {
88
95
  return Math.max(0, Math.min(1, n));
89
96
  }
90
97
 
91
- export function capsForDebate(debateId: string): {
98
+ export function capsForDebate(
99
+ debateId: string,
100
+ profile?: DebateProfile,
101
+ ): {
92
102
  name: "plan" | "aggressive";
103
+ min_focus_rounds: number;
93
104
  max_rounds: number;
105
+ max_exchanges_per_round: number;
94
106
  round_token_cap: number;
95
107
  debate_global_cap: number;
96
108
  } {
97
109
  if (isPlanDebateId(debateId)) {
98
- return { name: "plan", ...PLAN_BUDGET };
110
+ const active = profile ?? getDebateState()?.debate_profile ?? "standard";
111
+ const budget = active === "light" ? PLAN_BUDGET_LIGHT : PLAN_BUDGET;
112
+ return { name: "plan", ...budget };
99
113
  }
100
- return { name: "aggressive", ...AGGRESSIVE_BUDGET };
114
+ return {
115
+ name: "aggressive",
116
+ min_focus_rounds: 1,
117
+ max_exchanges_per_round: 1,
118
+ ...AGGRESSIVE_BUDGET,
119
+ };
101
120
  }
102
121
 
103
122
  function participantAllowed(
@@ -161,23 +180,40 @@ export interface DebateBusHooks {
161
180
  appendEntry: (customType: string, data: unknown) => void;
162
181
  }
163
182
 
183
+ export interface OpenDebateBusOptions {
184
+ debate_profile?: DebateProfile;
185
+ required_focuses?: DebateState["required_focuses"];
186
+ }
187
+
164
188
  export async function openDebateBus(
165
189
  runId: string,
166
190
  debateId: string,
167
191
  hooks: DebateBusHooks,
192
+ opts?: OpenDebateBusOptions,
168
193
  ): Promise<DebateState> {
169
- const caps = capsForDebate(debateId);
194
+ const profile = opts?.debate_profile ?? "standard";
195
+ const caps = capsForDebate(debateId, profile);
170
196
  const debate_phase = debatePhaseFromId(debateId);
197
+ const defaultFocuses: PlanDebateFocus[] =
198
+ profile === "light" ? ["spec", "quality"] : [...PLAN_FOCUS_AREAS];
199
+ const required_focuses =
200
+ opts?.required_focuses && opts.required_focuses.length > 0
201
+ ? opts.required_focuses
202
+ : defaultFocuses;
171
203
  const next: DebateState = {
172
204
  run_id: runId,
173
205
  debate_id: debateId,
174
206
  debate_phase,
175
207
  round_count: 0,
176
208
  budget_used: 0,
209
+ min_focus_rounds: caps.min_focus_rounds,
177
210
  max_rounds: caps.max_rounds,
211
+ max_exchanges_per_round: caps.max_exchanges_per_round,
178
212
  round_token_cap: caps.round_token_cap,
179
213
  debate_global_cap: caps.debate_global_cap,
180
214
  last_review_gate_ready: false,
215
+ debate_profile: profile,
216
+ required_focuses,
181
217
  };
182
218
  setDebateState(next);
183
219
  setLastSeverity({
@@ -199,6 +235,8 @@ export async function openDebateBus(
199
235
  opened_at: nowIso(),
200
236
  debate_phase,
201
237
  budget_profile: caps.name,
238
+ debate_profile: profile,
239
+ required_focuses,
202
240
  },
203
241
  };
204
242
  hooks.appendEntry("harness-debate-envelope", envelope);
@@ -230,7 +268,9 @@ async function emitBudgetExhausted(
230
268
  budget_used: state.budget_used,
231
269
  exhaustion_reason: reason,
232
270
  caps: {
271
+ min_focus_rounds: state.min_focus_rounds,
233
272
  max_rounds: state.max_rounds,
273
+ max_exchanges_per_round: state.max_exchanges_per_round,
234
274
  round_token_cap: state.round_token_cap,
235
275
  debate_global_cap: state.debate_global_cap,
236
276
  },
@@ -327,7 +367,9 @@ export async function acceptDebateRound(
327
367
  token_usage: envelope.payload.token_usage,
328
368
  budget_profile: {
329
369
  name: profileName,
370
+ min_focus_rounds: state.min_focus_rounds,
330
371
  max_rounds: state.max_rounds,
372
+ max_exchanges_per_round: state.max_exchanges_per_round,
331
373
  round_token_cap: state.round_token_cap,
332
374
  debate_global_cap: state.debate_global_cap,
333
375
  },
@@ -363,12 +405,24 @@ export async function finalizeDebateConsensus(
363
405
  );
364
406
  const decision = decidePolicy(lastSeverity, evidenceScore);
365
407
  const planPhase = state.debate_phase === "plan";
366
- const evaluatorPassed = planPhase
367
- ? Boolean(state.last_review_gate_ready)
368
- : true;
369
- const debateComplete = planPhase
370
- ? state.round_count >= state.max_rounds
371
- : state.round_count > 0;
408
+ let evaluatorPassed = true;
409
+ let debateComplete = state.round_count > 0;
410
+ if (planPhase) {
411
+ const runDir = join(process.cwd(), ".pi", "harness", "runs", state.run_id);
412
+ const requiredFocuses =
413
+ state.required_focuses && state.required_focuses.length > 0
414
+ ? state.required_focuses
415
+ : undefined;
416
+ const coverage = await getPlanFocusCoverage(runDir, {
417
+ requiredFocuses,
418
+ });
419
+ evaluatorPassed =
420
+ coverage.last_review_gate_ready || Boolean(state.last_review_gate_ready);
421
+ debateComplete = planDebateOutcomeComplete(coverage, {
422
+ requiredFocuses,
423
+ minRoundIndex: state.min_focus_rounds,
424
+ });
425
+ }
372
426
 
373
427
  const consensus = {
374
428
  schema_version: "1.0.0",
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import type { DebateParticipant } from "../../lib/debate-orchestrator-types.js";
6
+ import type { DebateProfile } from "./plan-debate-eligibility.js";
7
+ import type { PlanDebateFocus } from "./plan-debate-focus.js";
6
8
 
7
9
  export type DebatePhase = "plan" | "post_execute";
8
10
 
@@ -12,10 +14,14 @@ export interface DebateState {
12
14
  debate_phase: DebatePhase;
13
15
  round_count: number;
14
16
  budget_used: number;
17
+ min_focus_rounds: number;
15
18
  max_rounds: number;
19
+ max_exchanges_per_round: number;
16
20
  round_token_cap: number;
17
21
  debate_global_cap: number;
18
22
  last_review_gate_ready?: boolean;
23
+ debate_profile?: DebateProfile;
24
+ required_focuses?: PlanDebateFocus[];
19
25
  }
20
26
 
21
27
  export interface SeverityScores {
@@ -160,6 +160,62 @@ export function formatResearchBriefMarkdown(
160
160
  }
161
161
  }
162
162
 
163
+ const impl = asRecord(research.implementation);
164
+ if (impl) {
165
+ lines.push("## Phase 3.5 — Implementation research");
166
+ lines.push("");
167
+ const framing = str(impl.problem_framing);
168
+ if (framing) {
169
+ lines.push("**Problem framing:**");
170
+ lines.push("");
171
+ lines.push(framing);
172
+ lines.push("");
173
+ }
174
+ const rec = asRecord(impl.recommended_approach);
175
+ if (rec) {
176
+ const summary = str(rec.summary);
177
+ const conf = str(rec.recommended_approach_confidence);
178
+ if (summary) {
179
+ lines.push(
180
+ `**Recommended approach**${conf ? ` (${conf} confidence)` : ""}:`,
181
+ );
182
+ lines.push("");
183
+ lines.push(summary);
184
+ lines.push("");
185
+ }
186
+ const rationale = str(rec.confidence_rationale);
187
+ if (rationale) {
188
+ lines.push(`*Rationale:* ${rationale}`);
189
+ lines.push("");
190
+ }
191
+ }
192
+ const patterns = Array.isArray(impl.solution_patterns)
193
+ ? impl.solution_patterns
194
+ : [];
195
+ if (patterns.length) {
196
+ lines.push("**Solution patterns:**");
197
+ for (const p of patterns) {
198
+ const pat = asRecord(p);
199
+ const name = pat ? str(pat.name) : null;
200
+ const fit = pat ? str(pat.fit) : null;
201
+ if (name) lines.push(`- **${name}**${fit ? `: ${fit}` : ""}`);
202
+ }
203
+ lines.push("");
204
+ }
205
+ const openQs = strList(impl.open_questions);
206
+ if (openQs.length) {
207
+ lines.push("**Open questions:**");
208
+ for (const q of openQs) lines.push(`- ${q}`);
209
+ lines.push("");
210
+ }
211
+ const anti = strList(impl.anti_patterns);
212
+ if (anti.length) {
213
+ lines.push("**Anti-patterns:**");
214
+ for (const a of anti) lines.push(`- ${a}`);
215
+ lines.push("");
216
+ }
217
+ }
218
+
163
219
  if (evalBrief) {
164
220
  lines.push("## Self-evaluation");
165
221
  lines.push("");
@@ -13,6 +13,7 @@ export interface PlanResearchBrief {
13
13
  hypothesis?: Record<string, unknown> | null;
14
14
  eval?: Record<string, unknown> | null;
15
15
  stack?: Record<string, unknown> | null;
16
+ implementation?: Record<string, unknown> | null;
16
17
  debate?: {
17
18
  rounds?: Record<string, unknown>[];
18
19
  hypothesis_validations?: Record<string, unknown>[];
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Pre-debate profile selection (full | standard | light).
3
+ */
4
+
5
+ import { PLAN_FOCUS_AREAS, type PlanDebateFocus } from "./plan-debate-focus.js";
6
+
7
+ export type DebateProfile = "full" | "standard" | "light";
8
+
9
+ export interface DebateEligibilityInput {
10
+ risk_level?: string;
11
+ material_fork?: boolean;
12
+ dag_pass?: boolean;
13
+ dag_manually_patched?: boolean;
14
+ implementation_brief?: Record<string, unknown> | null;
15
+ stack_brief?: Record<string, unknown> | null;
16
+ decomposition?: Record<string, unknown> | null;
17
+ }
18
+
19
+ export interface DebateEligibilityResult {
20
+ profile: DebateProfile;
21
+ required_focuses: PlanDebateFocus[];
22
+ min_focus_rounds: number;
23
+ max_rounds: number;
24
+ max_exchanges_per_round: number;
25
+ round_token_cap: number;
26
+ debate_global_cap: number;
27
+ human_required: boolean;
28
+ rationale: string[];
29
+ }
30
+
31
+ const LIGHT_FOCUS: PlanDebateFocus[] = ["spec", "quality"];
32
+
33
+ function asRecord(value: unknown): Record<string, unknown> | null {
34
+ return value && typeof value === "object" && !Array.isArray(value)
35
+ ? (value as Record<string, unknown>)
36
+ : null;
37
+ }
38
+
39
+ function strList(value: unknown): string[] {
40
+ if (!Array.isArray(value)) return [];
41
+ return value
42
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
43
+ .filter(Boolean);
44
+ }
45
+
46
+ function implementationOpenQuestions(
47
+ brief: Record<string, unknown> | null,
48
+ ): string[] {
49
+ if (!brief) return [];
50
+ return strList(brief.open_questions);
51
+ }
52
+
53
+ function recommendedApproach(
54
+ brief: Record<string, unknown> | null,
55
+ ): Record<string, unknown> | null {
56
+ return asRecord(brief?.recommended_approach);
57
+ }
58
+
59
+ function stackHasClearPrimary(stack: Record<string, unknown> | null): boolean {
60
+ if (!stack) return false;
61
+ const primary = stack.recommended_primary;
62
+ return typeof primary === "string" && primary.trim().length > 0;
63
+ }
64
+
65
+ function confidenceAllowsLight(brief: Record<string, unknown> | null): boolean {
66
+ const rec = recommendedApproach(brief);
67
+ if (!rec) return false;
68
+ const conf = String(rec.recommended_approach_confidence ?? "").toLowerCase();
69
+ if (conf !== "high") return false;
70
+ const rationale =
71
+ typeof rec.confidence_rationale === "string"
72
+ ? rec.confidence_rationale.trim()
73
+ : "";
74
+ const refs = strList(rec.evidence_refs);
75
+ if (!rationale || refs.length < 2) return false;
76
+ if (implementationOpenQuestions(brief).length > 0) return false;
77
+ const patterns = Array.isArray(brief?.solution_patterns)
78
+ ? (brief!.solution_patterns as unknown[])
79
+ : [];
80
+ for (const p of patterns) {
81
+ const pat = asRecord(p);
82
+ const risks = pat ? strList(pat.risks) : [];
83
+ if (risks.some((r) => /unmitigated|critical|blocker/i.test(r))) {
84
+ return false;
85
+ }
86
+ }
87
+ const similar = Array.isArray(brief?.similar_implementations)
88
+ ? (brief!.similar_implementations as unknown[])
89
+ : [];
90
+ if (similar.length === 0) return false;
91
+ return true;
92
+ }
93
+
94
+ function decompositionTensionCount(
95
+ decomposition: Record<string, unknown> | null,
96
+ ): number {
97
+ if (!decomposition) return 0;
98
+ return Array.isArray(decomposition.tensions)
99
+ ? decomposition.tensions.length
100
+ : 0;
101
+ }
102
+
103
+ export const PLAN_BUDGET_STANDARD = {
104
+ min_focus_rounds: 4,
105
+ max_rounds: 12,
106
+ max_exchanges_per_round: 3,
107
+ round_token_cap: 8000,
108
+ debate_global_cap: 80000,
109
+ } as const;
110
+
111
+ export const PLAN_BUDGET_LIGHT = {
112
+ min_focus_rounds: 2,
113
+ max_rounds: 8,
114
+ max_exchanges_per_round: 3,
115
+ round_token_cap: 6000,
116
+ debate_global_cap: 40000,
117
+ } as const;
118
+
119
+ function capsForProfile(
120
+ profile: DebateProfile,
121
+ ): Omit<
122
+ DebateEligibilityResult,
123
+ "profile" | "required_focuses" | "human_required" | "rationale"
124
+ > {
125
+ if (profile === "light") {
126
+ return {
127
+ ...PLAN_BUDGET_LIGHT,
128
+ };
129
+ }
130
+ return {
131
+ ...PLAN_BUDGET_STANDARD,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Select debate profile from pre-debate signals only (no R1 hypothesis output).
137
+ */
138
+ export function harnessPlanDebateEligibility(
139
+ input: DebateEligibilityInput,
140
+ ): DebateEligibilityResult {
141
+ const rationale: string[] = [];
142
+ const risk = String(input.risk_level ?? "med").toLowerCase();
143
+ const impl = input.implementation_brief ?? null;
144
+ const stack = input.stack_brief ?? null;
145
+ const openQs = implementationOpenQuestions(impl);
146
+ const materialFork = input.material_fork === true;
147
+ const dagPatched = input.dag_manually_patched === true;
148
+ const dagFail = input.dag_pass === false;
149
+
150
+ let human_required = false;
151
+
152
+ if (dagFail) {
153
+ rationale.push("DAG validation failed — use standard profile until fixed");
154
+ }
155
+
156
+ if (openQs.length > 0) {
157
+ rationale.push(
158
+ `implementation open_questions (${openQs.length}) — not eligible for light`,
159
+ );
160
+ }
161
+
162
+ const conflictingPatterns =
163
+ Array.isArray(impl?.solution_patterns) &&
164
+ (impl!.solution_patterns as unknown[]).length >= 2 &&
165
+ openQs.length > 0;
166
+ if (conflictingPatterns) {
167
+ human_required = true;
168
+ rationale.push("conflicting external patterns with open questions");
169
+ }
170
+
171
+ let profile: DebateProfile = "standard";
172
+ rationale.push("default profile: standard (fail-safe)");
173
+
174
+ if (
175
+ risk === "high" ||
176
+ materialFork ||
177
+ openQs.length > 0 ||
178
+ dagPatched ||
179
+ decompositionTensionCount(input.decomposition ?? null) >= 3
180
+ ) {
181
+ profile = "full";
182
+ rationale.push(
183
+ "full: high risk, material fork, open questions, DAG patch, or tensions",
184
+ );
185
+ } else if (
186
+ risk === "low" &&
187
+ !materialFork &&
188
+ !dagPatched &&
189
+ input.dag_pass !== false &&
190
+ confidenceAllowsLight(impl) &&
191
+ stackHasClearPrimary(stack)
192
+ ) {
193
+ profile = "light";
194
+ rationale.push(
195
+ "light: low risk, clear stack, high-confidence implementation approach",
196
+ );
197
+ } else if (risk === "med") {
198
+ profile = "standard";
199
+ rationale.push("standard: med risk default");
200
+ }
201
+
202
+ const required_focuses: PlanDebateFocus[] =
203
+ profile === "light" ? [...LIGHT_FOCUS] : [...PLAN_FOCUS_AREAS];
204
+
205
+ const caps = capsForProfile(profile);
206
+
207
+ return {
208
+ profile,
209
+ required_focuses,
210
+ ...caps,
211
+ human_required,
212
+ rationale,
213
+ };
214
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Plan-phase Review Gate focus coverage (spec | wbs | schedule | quality).
3
+ */
4
+
5
+ import { constants } from "node:fs";
6
+ import { access, readdir, readFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { parse as parseYaml } from "yaml";
9
+
10
+ export const PLAN_FOCUS_AREAS = ["spec", "wbs", "schedule", "quality"] as const;
11
+ export type PlanDebateFocus = (typeof PLAN_FOCUS_AREAS)[number];
12
+
13
+ export interface PlanFocusCoverage {
14
+ covered: PlanDebateFocus[];
15
+ missing: PlanDebateFocus[];
16
+ rounds_by_focus: Partial<Record<PlanDebateFocus, number>>;
17
+ focus_by_round: Partial<Record<number, PlanDebateFocus>>;
18
+ last_review_gate_ready: boolean;
19
+ last_round_index: number;
20
+ }
21
+
22
+ export interface PlanFocusCoverageOptions {
23
+ requiredFocuses?: readonly PlanDebateFocus[];
24
+ }
25
+
26
+ async function fileExists(path: string): Promise<boolean> {
27
+ try {
28
+ await access(path, constants.R_OK);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function focusFromDraft(
36
+ draft: Record<string, unknown>,
37
+ ): PlanDebateFocus | null {
38
+ const focus = String(draft.debate_round_focus ?? "").trim();
39
+ if ((PLAN_FOCUS_AREAS as readonly string[]).includes(focus)) {
40
+ return focus as PlanDebateFocus;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Scan submitted review-round artifacts for focus coverage and last gate flag.
47
+ */
48
+ export async function getPlanFocusCoverage(
49
+ runDir: string,
50
+ opts?: PlanFocusCoverageOptions,
51
+ ): Promise<PlanFocusCoverage> {
52
+ const required =
53
+ opts?.requiredFocuses && opts.requiredFocuses.length > 0
54
+ ? opts.requiredFocuses
55
+ : PLAN_FOCUS_AREAS;
56
+ const artifactsDir = join(runDir, "artifacts");
57
+ const covered = new Set<PlanDebateFocus>();
58
+ const rounds_by_focus: Partial<Record<PlanDebateFocus, number>> = {};
59
+ const focus_by_round: Partial<Record<number, PlanDebateFocus>> = {};
60
+ let last_review_gate_ready = false;
61
+ let last_round_index = 0;
62
+
63
+ let files: string[] = [];
64
+ try {
65
+ files = (await readdir(artifactsDir)).filter((f) =>
66
+ /^review-round-r\d+\.yaml$/i.test(f),
67
+ );
68
+ } catch {
69
+ return {
70
+ covered: [],
71
+ missing: [...required],
72
+ rounds_by_focus: {},
73
+ focus_by_round: {},
74
+ last_review_gate_ready: false,
75
+ last_round_index: 0,
76
+ };
77
+ }
78
+
79
+ for (const name of files.sort()) {
80
+ const m = /^review-round-r(\d+)\.yaml$/i.exec(name);
81
+ if (!m) continue;
82
+ const roundIndex = Number(m[1]);
83
+ if (roundIndex > last_round_index) last_round_index = roundIndex;
84
+ const raw = await readFile(join(artifactsDir, name), "utf-8");
85
+ let draft: Record<string, unknown>;
86
+ try {
87
+ draft = parseYaml(raw) as Record<string, unknown>;
88
+ } catch {
89
+ continue;
90
+ }
91
+ const focus = focusFromDraft(draft);
92
+ if (focus) {
93
+ covered.add(focus);
94
+ rounds_by_focus[focus] = roundIndex;
95
+ focus_by_round[roundIndex] = focus;
96
+ }
97
+ if (roundIndex === last_round_index) {
98
+ last_review_gate_ready = draft.review_gate_ready === true;
99
+ }
100
+ }
101
+
102
+ const coveredList = required.filter((f) => covered.has(f));
103
+ const missing = required.filter((f) => !covered.has(f));
104
+
105
+ return {
106
+ covered: coveredList,
107
+ missing,
108
+ rounds_by_focus,
109
+ focus_by_round,
110
+ last_review_gate_ready,
111
+ last_round_index,
112
+ };
113
+ }
114
+
115
+ export interface PlanDebateOutcomeOptions {
116
+ requiredFocuses?: readonly PlanDebateFocus[];
117
+ minRoundIndex?: number;
118
+ }
119
+
120
+ export function planDebateOutcomeComplete(
121
+ coverage: PlanFocusCoverage,
122
+ opts?: PlanDebateOutcomeOptions,
123
+ ): boolean {
124
+ const required =
125
+ opts?.requiredFocuses && opts.requiredFocuses.length > 0
126
+ ? opts.requiredFocuses
127
+ : PLAN_FOCUS_AREAS;
128
+ const minRounds = opts?.minRoundIndex ?? required.length;
129
+ const missing = required.filter((f) => !coverage.covered.includes(f));
130
+ return (
131
+ missing.length === 0 &&
132
+ coverage.last_review_gate_ready === true &&
133
+ coverage.last_round_index >= minRounds
134
+ );
135
+ }
136
+
137
+ /** Read debate_round_focus from an existing review-round artifact. */
138
+ export async function readDebateRoundFocus(
139
+ runDir: string,
140
+ roundIndex: number,
141
+ ): Promise<PlanDebateFocus | null> {
142
+ const path = join(runDir, "artifacts", `review-round-r${roundIndex}.yaml`);
143
+ if (!(await fileExists(path))) return null;
144
+ try {
145
+ const raw = await readFile(path, "utf-8");
146
+ const draft = parseYaml(raw) as Record<string, unknown>;
147
+ return focusFromDraft(draft);
148
+ } catch {
149
+ return null;
150
+ }
151
+ }