ultimate-pi 0.13.1 → 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.
- package/.agents/skills/harness-debate-plan/SKILL.md +42 -22
- package/.agents/skills/harness-orchestration/SKILL.md +3 -3
- package/.agents/skills/harness-plan/SKILL.md +10 -8
- package/.pi/agents/harness/planning/decompose.md +4 -2
- package/.pi/agents/harness/planning/execution-plan-author.md +25 -14
- package/.pi/agents/harness/planning/hypothesis-validator.md +21 -5
- package/.pi/agents/harness/planning/implementation-researcher.md +42 -0
- package/.pi/agents/harness/planning/plan-adversary.md +20 -4
- package/.pi/agents/harness/planning/plan-evaluator.md +28 -5
- package/.pi/agents/harness/planning/review-integrator.md +25 -9
- package/.pi/agents/harness/planning/scout-graphify.md +1 -1
- package/.pi/agents/harness/planning/sprint-contract-auditor.md +19 -4
- package/.pi/agents/harness/planning/stack-researcher.md +19 -10
- package/.pi/extensions/debate-orchestrator.ts +39 -435
- package/.pi/extensions/harness-debate-tools.ts +741 -0
- package/.pi/extensions/harness-live-widget.ts +39 -159
- package/.pi/extensions/harness-plan-approval.ts +88 -22
- package/.pi/extensions/harness-run-context.ts +18 -0
- package/.pi/extensions/lib/debate-bus-core.ts +488 -0
- package/.pi/extensions/lib/debate-bus-state.ts +64 -0
- package/.pi/extensions/lib/harness-spawn-budget.ts +5 -25
- package/.pi/extensions/lib/plan-approval/dialog.ts +33 -272
- package/.pi/extensions/lib/plan-approval/format-plan.ts +12 -85
- package/.pi/extensions/lib/plan-approval/plan-review.ts +62 -6
- package/.pi/extensions/lib/plan-approval/render.ts +6 -0
- package/.pi/extensions/lib/plan-approval/types.ts +1 -0
- package/.pi/extensions/lib/plan-approval/validate.ts +1 -1
- package/.pi/extensions/lib/plan-debate-eligibility.ts +214 -0
- package/.pi/extensions/lib/plan-debate-envelope.ts +2 -0
- package/.pi/extensions/lib/plan-debate-focus.ts +151 -0
- package/.pi/extensions/lib/plan-debate-gate.ts +198 -0
- package/.pi/extensions/lib/plan-debate-id.ts +39 -0
- package/.pi/extensions/lib/plan-debate-lane.ts +220 -0
- package/.pi/extensions/lib/plan-debate-lanes.ts +44 -0
- package/.pi/extensions/lib/plan-debate-round-status.ts +137 -0
- package/.pi/extensions/lib/plan-debate-write-guard.ts +20 -0
- package/.pi/extensions/lib/plan-messenger.ts +352 -0
- package/.pi/extensions/lib/plan-review-integrator-rules.ts +119 -0
- package/.pi/extensions/lib/plan-scope-guard.ts +89 -0
- package/.pi/extensions/policy-gate.ts +1 -1
- package/.pi/harness/README.md +1 -1
- package/.pi/harness/agents.manifest.json +16 -12
- package/.pi/harness/docs/adrs/0034-darwin-plan-research-pipeline.md +1 -3
- package/.pi/harness/docs/adrs/0035-plan-phase-review-gate.md +13 -5
- package/.pi/harness/docs/adrs/0036-implementation-research-and-selective-debate.md +51 -0
- package/.pi/harness/docs/adrs/README.md +2 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/artifacts/implementation-research.yaml +28 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/artifacts/review-round-r1.yaml +24 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/artifacts/review-round-r2.yaml +25 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/plan-packet.yaml +196 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/plan-review.md +14 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-low-light/research-brief.yaml +62 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med/artifacts/implementation-research.yaml +28 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med/artifacts/review-round-r2.yaml +24 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med/artifacts/review-round-r3.yaml +24 -0
- package/.pi/harness/evals/smoke/fixtures/plan-phase/minimal-med/research-brief.yaml +29 -0
- package/.pi/harness/evals/smoke/smoke-harness-plan.mjs +97 -16
- package/.pi/harness/specs/plan-implementation-research-brief.schema.json +128 -0
- package/.pi/harness/specs/plan-review-round-draft.schema.json +1 -1
- package/.pi/harness/specs/round-result.schema.json +15 -2
- package/.pi/lib/harness-ui-state.ts +92 -0
- package/.pi/prompts/harness-plan.md +90 -30
- package/.pi/prompts/planning-rubrics.md +31 -0
- package/CHANGELOG.md +23 -0
- package/package.json +3 -3
- package/.pi/extensions/lib/plan-approval/fallback.ts +0 -50
|
@@ -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
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
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 { capsForDebate } from "./debate-bus-core.js";
|
|
9
|
+
import {
|
|
10
|
+
getPlanFocusCoverage,
|
|
11
|
+
type PlanDebateFocus,
|
|
12
|
+
planDebateOutcomeComplete,
|
|
13
|
+
} from "./plan-debate-focus.js";
|
|
14
|
+
import { planDebateIdForRun } from "./plan-debate-id.js";
|
|
15
|
+
import { laneArtifactPathsForRound } from "./plan-debate-lanes.js";
|
|
16
|
+
import {
|
|
17
|
+
getMessengerRoundState,
|
|
18
|
+
loadMessengerState,
|
|
19
|
+
messengerRoundDebateReady,
|
|
20
|
+
} from "./plan-messenger.js";
|
|
21
|
+
|
|
22
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
23
|
+
try {
|
|
24
|
+
await access(path, constants.R_OK);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function countJsonlKinds(
|
|
32
|
+
debateJsonlPath: string,
|
|
33
|
+
): Promise<{ rounds: number; hasConsensus: boolean }> {
|
|
34
|
+
try {
|
|
35
|
+
const raw = await readFile(debateJsonlPath, "utf-8");
|
|
36
|
+
let rounds = 0;
|
|
37
|
+
let hasConsensus = false;
|
|
38
|
+
for (const line of raw.split("\n")) {
|
|
39
|
+
if (!line.trim()) continue;
|
|
40
|
+
const ev = JSON.parse(line) as { kind?: string };
|
|
41
|
+
if (ev.kind === "round") rounds += 1;
|
|
42
|
+
if (ev.kind === "consensus") hasConsensus = true;
|
|
43
|
+
}
|
|
44
|
+
return { rounds, hasConsensus };
|
|
45
|
+
} catch {
|
|
46
|
+
return { rounds: 0, hasConsensus: false };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PlanDebateGateResult {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
errors: string[];
|
|
53
|
+
warnings: string[];
|
|
54
|
+
debateId: string;
|
|
55
|
+
focus_coverage?: {
|
|
56
|
+
covered: string[];
|
|
57
|
+
missing: string[];
|
|
58
|
+
last_review_gate_ready: boolean;
|
|
59
|
+
};
|
|
60
|
+
debate_profile?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function validatePlanDebateGate(
|
|
64
|
+
projectRoot: string,
|
|
65
|
+
runId: string,
|
|
66
|
+
): Promise<PlanDebateGateResult> {
|
|
67
|
+
const errors: string[] = [];
|
|
68
|
+
const warnings: string[] = [];
|
|
69
|
+
const debateId = planDebateIdForRun(runId);
|
|
70
|
+
const runDir = join(projectRoot, ".pi", "harness", "runs", runId);
|
|
71
|
+
const debatesDir = join(projectRoot, ".pi", "harness", "debates");
|
|
72
|
+
const messenger = await loadMessengerState(runDir);
|
|
73
|
+
const debateProfile = messenger?.debate_profile ?? "standard";
|
|
74
|
+
const requiredFocuses: readonly PlanDebateFocus[] =
|
|
75
|
+
messenger?.required_focuses && messenger.required_focuses.length > 0
|
|
76
|
+
? messenger.required_focuses
|
|
77
|
+
: (["spec", "wbs", "schedule", "quality"] as const);
|
|
78
|
+
const caps = capsForDebate(debateId, debateProfile);
|
|
79
|
+
const coverage = await getPlanFocusCoverage(runDir, { requiredFocuses });
|
|
80
|
+
const dialogueOpts = {
|
|
81
|
+
max_exchanges_per_round: caps.max_exchanges_per_round,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (const focus of coverage.missing) {
|
|
85
|
+
errors.push(`focus not covered in submitted rounds: ${focus}`);
|
|
86
|
+
}
|
|
87
|
+
if (!coverage.last_review_gate_ready) {
|
|
88
|
+
errors.push("last submitted review round has review_gate_ready !== true");
|
|
89
|
+
}
|
|
90
|
+
|
|
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)) {
|
|
101
|
+
const abs = join(runDir, rel);
|
|
102
|
+
if (!(await fileExists(abs))) {
|
|
103
|
+
errors.push(`missing ${rel}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const roundState = await getMessengerRoundState(runDir, r);
|
|
107
|
+
const requireSprint = focus === "quality" || r >= 4;
|
|
108
|
+
const messengerCheck = messengerRoundDebateReady(
|
|
109
|
+
roundState,
|
|
110
|
+
requireSprint,
|
|
111
|
+
dialogueOpts,
|
|
112
|
+
);
|
|
113
|
+
if (!messengerCheck.ok) {
|
|
114
|
+
for (const e of messengerCheck.errors) {
|
|
115
|
+
errors.push(`round ${r} messenger: ${e}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (coverage.last_round_index > caps.max_rounds) {
|
|
121
|
+
errors.push(
|
|
122
|
+
`round_count ${coverage.last_round_index} exceeds max_rounds ${caps.max_rounds}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!messenger) {
|
|
127
|
+
errors.push(
|
|
128
|
+
"debate-messenger/state.json missing — call harness_debate_open",
|
|
129
|
+
);
|
|
130
|
+
} else if (messenger.debate_id !== debateId) {
|
|
131
|
+
errors.push(`messenger debate_id ${messenger.debate_id} !== ${debateId}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const jsonlPath = join(debatesDir, `${debateId}.jsonl`);
|
|
135
|
+
const { rounds, hasConsensus } = await countJsonlKinds(jsonlPath);
|
|
136
|
+
const minRounds = caps.min_focus_rounds;
|
|
137
|
+
if (rounds < minRounds) {
|
|
138
|
+
errors.push(
|
|
139
|
+
`${debateId}.jsonl has ${rounds}/${minRounds} minimum round events — use harness_debate_submit_round per focus`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!hasConsensus) {
|
|
143
|
+
errors.push(
|
|
144
|
+
`missing consensus on ${debateId} — call harness_debate_consensus`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
!planDebateOutcomeComplete(coverage, {
|
|
150
|
+
requiredFocuses,
|
|
151
|
+
minRoundIndex: caps.min_focus_rounds,
|
|
152
|
+
})
|
|
153
|
+
) {
|
|
154
|
+
errors.push(
|
|
155
|
+
`debate outcome incomplete: required focuses [${requiredFocuses.join(", ")}] with last review_gate_ready true (profile=${debateProfile})`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const consensusPath = join(debatesDir, `${debateId}.consensus.json`);
|
|
160
|
+
if (!(await fileExists(consensusPath))) {
|
|
161
|
+
errors.push(`missing ${debateId}.consensus.json`);
|
|
162
|
+
} else {
|
|
163
|
+
try {
|
|
164
|
+
const raw = await readFile(consensusPath, "utf-8");
|
|
165
|
+
const packet = JSON.parse(raw) as { policy_decision?: string };
|
|
166
|
+
if (packet.policy_decision === "block") {
|
|
167
|
+
errors.push("consensus policy_decision is block — cannot approve");
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
errors.push("invalid consensus json");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (rounds > caps.max_rounds) {
|
|
175
|
+
warnings.push(
|
|
176
|
+
`bus round count ${rounds} exceeds soft max_rounds ${caps.max_rounds}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
ok: errors.length === 0,
|
|
182
|
+
errors,
|
|
183
|
+
warnings,
|
|
184
|
+
debateId,
|
|
185
|
+
focus_coverage: {
|
|
186
|
+
covered: coverage.covered,
|
|
187
|
+
missing: coverage.missing,
|
|
188
|
+
last_review_gate_ready: coverage.last_review_gate_ready,
|
|
189
|
+
},
|
|
190
|
+
debate_profile: debateProfile,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function isReviewRoundArtifactPath(relPath: string): boolean {
|
|
195
|
+
return /^artifacts\/review-round-r\d+\.yaml$/i.test(
|
|
196
|
+
relPath.replace(/\\/g, "/"),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical plan-phase debate identifiers (ADR-0035).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function planDebateIdForRun(runId: string): string {
|
|
6
|
+
const trimmed = runId.trim();
|
|
7
|
+
if (!trimmed) throw new Error("run_id is required for plan debate");
|
|
8
|
+
return `plan-${trimmed}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Accept plan-<run_id> only; rewrite plan-<plan_id> when run_id is known. */
|
|
12
|
+
export function normalizePlanDebateId(
|
|
13
|
+
rawDebateId: string,
|
|
14
|
+
runId: string,
|
|
15
|
+
): { debateId: string; corrected: boolean; warning?: string } {
|
|
16
|
+
const trimmed = rawDebateId.trim();
|
|
17
|
+
const canonical = planDebateIdForRun(runId);
|
|
18
|
+
if (!trimmed) {
|
|
19
|
+
return { debateId: canonical, corrected: true, warning: "empty debate id" };
|
|
20
|
+
}
|
|
21
|
+
if (trimmed === canonical) {
|
|
22
|
+
return { debateId: canonical, corrected: false };
|
|
23
|
+
}
|
|
24
|
+
if (trimmed.startsWith("plan-") && trimmed !== canonical) {
|
|
25
|
+
return {
|
|
26
|
+
debateId: canonical,
|
|
27
|
+
corrected: true,
|
|
28
|
+
warning: `debate id must be plan-<run_id>; got ${trimmed}, using ${canonical}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!trimmed.startsWith("plan-")) {
|
|
32
|
+
return {
|
|
33
|
+
debateId: trimmed,
|
|
34
|
+
corrected: false,
|
|
35
|
+
warning: "non-plan debate id (post-execute profile)",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { debateId: trimmed, corrected: false };
|
|
39
|
+
}
|