immune-brain 2.8.2
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/README.md +239 -0
- package/README.zh-CN.md +239 -0
- package/package.json +84 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +666 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +1622 -0
- package/plugins/immune-brain/.pi-extension/package.json +11 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +711 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance.ts +154 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +349 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +90 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +214 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +50 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +262 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +28 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +237 -0
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +414 -0
- package/plugins/immune-brain/.pi-extension/tsconfig.json +20 -0
- package/plugins/immune-brain/bin/imm-kernel +4 -0
- package/plugins/immune-brain/bin/imm-plan +4 -0
- package/plugins/immune-brain/bin/imm-pr-diag +230 -0
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +4 -0
- package/plugins/immune-brain/bin/imm-retired +4 -0
- package/plugins/immune-brain/bin/imm-tracker +4 -0
- package/plugins/immune-brain/dist/BASELINE.md +138 -0
- package/plugins/immune-brain/dist/docs/reference/HANDOFF-template.md +122 -0
- package/plugins/immune-brain/dist/docs/reference/design-contract-audit-rubric.md +149 -0
- package/plugins/immune-brain/dist/docs/reference/design-contract-review-checklist.md +55 -0
- package/plugins/immune-brain/dist/docs/reference/i18n-review-checklist.md +110 -0
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +52 -0
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +94 -0
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +44 -0
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +105 -0
- package/plugins/immune-brain/dist/docs/reference/ux-heuristic-checklist.md +131 -0
- package/plugins/immune-brain/dist/imm-brainstorm.md +140 -0
- package/plugins/immune-brain/dist/imm-doc-prune.md +137 -0
- package/plugins/immune-brain/dist/imm-loop.md +158 -0
- package/plugins/immune-brain/dist/imm-planner.md +387 -0
- package/plugins/immune-brain/dist/imm-pr-fix.md +71 -0
- package/plugins/immune-brain/dist/registry.yaml +49 -0
- package/plugins/immune-brain/dist/role-prompts/advisory-reviewer.md +16 -0
- package/plugins/immune-brain/dist/role-prompts/arch-explorer.md +14 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
- package/plugins/immune-brain/dist/role-prompts/compounder.md +20 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +13 -0
- package/plugins/immune-brain/dist/role-prompts/pr-fix.md +81 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +23 -0
- package/plugins/immune-brain/dist/role-prompts/test-fixer.md +3 -0
- package/plugins/immune-brain/dist/role-prompts/ui-review.md +14 -0
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +716 -0
- package/plugins/immune-brain/runtime/canonical_json.ts +19 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +1160 -0
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1009 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +300 -0
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +284 -0
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +208 -0
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +451 -0
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +197 -0
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +507 -0
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +73 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +160 -0
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +194 -0
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +123 -0
- package/plugins/immune-brain/runtime/kernel/index.ts +29 -0
- package/plugins/immune-brain/runtime/kernel/intent.ts +563 -0
- package/plugins/immune-brain/runtime/kernel/intent_token_registry.ts +80 -0
- package/plugins/immune-brain/runtime/kernel/legacy.ts +299 -0
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +153 -0
- package/plugins/immune-brain/runtime/kernel/observation.ts +395 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +169 -0
- package/plugins/immune-brain/runtime/kernel/readiness.ts +282 -0
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +132 -0
- package/plugins/immune-brain/runtime/kernel/reducer.ts +624 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1780 -0
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +791 -0
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +492 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +295 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +963 -0
- package/plugins/immune-brain/runtime/loop_contract.ts +362 -0
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +462 -0
- package/plugins/immune-brain/runtime/plan_core.ts +1053 -0
- package/plugins/immune-brain/runtime/prompts/advisory-reviewer.md +16 -0
- package/plugins/immune-brain/runtime/prompts/arch-explorer.md +14 -0
- package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
- package/plugins/immune-brain/runtime/prompts/compounder.md +20 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +13 -0
- package/plugins/immune-brain/runtime/prompts/pr-fix.md +81 -0
- package/plugins/immune-brain/runtime/prompts/qa.md +23 -0
- package/plugins/immune-brain/runtime/prompts/test-fixer.md +3 -0
- package/plugins/immune-brain/runtime/prompts/ui-review.md +14 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +160 -0
- package/plugins/immune-brain/runtime/v4_runtime.ts +295 -0
- package/plugins/immune-brain/runtime/verification_descriptor.ts +162 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +623 -0
- package/plugins/immune-brain/skills/.ignore +1 -0
- package/plugins/immune-brain/skills/BASELINE.md +138 -0
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +66 -0
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +11 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +52 -0
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +221 -0
- package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +10 -0
- package/plugins/immune-brain/skills/registry.yaml +49 -0
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan parsing, normalization, signatures, and validation.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { resolve, relative, sep } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { stableStringify } from "./canonical_json";
|
|
9
|
+
|
|
10
|
+
// ── errors ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export class PlanValidationError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "PlanValidationError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── regexes (ported from plan_runtime.py) ────────────────────────────
|
|
20
|
+
|
|
21
|
+
const STEP_HEADER_RE = /^### Step (\d+)\s*$/;
|
|
22
|
+
const ROADMAP_PHASE_RE = /^###\s+(.+?)\s*$/;
|
|
23
|
+
const TASK_FIELD_RE = /^- ([A-Za-z][A-Za-z0-9 _-]*):\s*(.+?)\s*$/;
|
|
24
|
+
const FIELD_RE =
|
|
25
|
+
/^- (Result|Scope|Verification|Depends on|Step ID|Test scenarios|Discovery cache|Parallel probes|Agent|Agent Hint|Dispatch):\s*(.*)\s*$/;
|
|
26
|
+
const ROADMAP_CRITERIA_RE =
|
|
27
|
+
/^- (acceptance_criteria|promotion_criteria):\s*(.*?)\s*$/i;
|
|
28
|
+
const DISCOVERY_CACHE_ENTRY_RE = /^(.+?)\s*\((.+)\)\s*$/;
|
|
29
|
+
const MULTI_RESULT_MARKERS = [" and ", ",", ";", ";"];
|
|
30
|
+
const ACTION_RESULT_RE = /^(read|inspect|review|edit|modify|run|execute)\b/i;
|
|
31
|
+
const STEP_ID_RE = /^U[0-9]+$/;
|
|
32
|
+
const PLACEHOLDER_RE = /<[^>\n]+>/;
|
|
33
|
+
const NON_BEHAVIORAL_ACCEPTANCE_RE =
|
|
34
|
+
/\b(implementation complete|code complete|definition of done|checklist|test plan)\b/i;
|
|
35
|
+
const BR_ITEM_RE = /^BR-(REQ|DEC|OUT|DEFER|Q)-[0-9]+$/;
|
|
36
|
+
const ROADMAP_SLICE_CONTRACT = "roadmap-slice/v1";
|
|
37
|
+
const ROADMAP_PHASE_ID_RE = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
|
38
|
+
const ROADMAP_SLICE_REQUIRED_FIELDS: Array<[key: string, label: string]> = [
|
|
39
|
+
["roadmap_source", "Roadmap source"],
|
|
40
|
+
["current_phase", "Current phase"],
|
|
41
|
+
["plan_boundary", "Plan boundary"],
|
|
42
|
+
["boundary_rationale", "Boundary rationale"],
|
|
43
|
+
["scope_pressure", "Scope pressure"],
|
|
44
|
+
["successor_candidate", "Successor candidate"],
|
|
45
|
+
["successor_preconditions", "Successor preconditions"],
|
|
46
|
+
["current-slice_warning", "Current-slice warning"],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ── plan parsing helpers ─────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export function hasPlaceholder(value: string): boolean {
|
|
52
|
+
const text = value || "";
|
|
53
|
+
return Boolean(
|
|
54
|
+
PLACEHOLDER_RE.test(text) || text.includes("<") || text.includes(">"),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isActionStepResult(value: string): boolean {
|
|
59
|
+
return ACTION_RESULT_RE.test((value || "").trim());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseDependsOn(rawValue: string): number[] {
|
|
63
|
+
const value = rawValue.trim();
|
|
64
|
+
if (value.toLowerCase() === "none") return [];
|
|
65
|
+
const deps: number[] = [];
|
|
66
|
+
for (const part of value.split(",")) {
|
|
67
|
+
const item = part.trim();
|
|
68
|
+
if (!item) continue;
|
|
69
|
+
if (/^\d+$/.test(item)) {
|
|
70
|
+
deps.push(Number(item));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (STEP_ID_RE.test(item)) {
|
|
74
|
+
deps.push(Number(item.slice(1)));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
throw new PlanValidationError(
|
|
78
|
+
`Depends on contains an invalid step reference: ${item}. Use a step number like 1 or a Step ID like U1.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return deps;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseStepScope(rawValue: string): string[] {
|
|
85
|
+
const codeSpans = [...rawValue.matchAll(/`([^`\n]+)`/g)].map((match) =>
|
|
86
|
+
match[1].trim(),
|
|
87
|
+
);
|
|
88
|
+
const candidates =
|
|
89
|
+
codeSpans.length > 0
|
|
90
|
+
? codeSpans
|
|
91
|
+
: rawValue
|
|
92
|
+
.split(/[;,]/)
|
|
93
|
+
.map((value) => value.trim())
|
|
94
|
+
.filter((value) => value.length > 0 && !/\s/.test(value));
|
|
95
|
+
const paths: string[] = [];
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
const normalized = candidate.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
98
|
+
if (
|
|
99
|
+
!normalized ||
|
|
100
|
+
normalized.startsWith("/") ||
|
|
101
|
+
normalized.split("/").includes("..") ||
|
|
102
|
+
normalized.includes("\0")
|
|
103
|
+
) {
|
|
104
|
+
throw new PlanValidationError(
|
|
105
|
+
`Scope contains an invalid project-relative path: ${candidate}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (!paths.includes(normalized)) paths.push(normalized);
|
|
109
|
+
}
|
|
110
|
+
return paths;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseBrainstormManifestItems(rawValue: string): string[] {
|
|
114
|
+
return rawValue
|
|
115
|
+
? rawValue
|
|
116
|
+
.split(/[;,]/)
|
|
117
|
+
.map((s) => s.trim())
|
|
118
|
+
.filter((s) => s.length > 0)
|
|
119
|
+
: [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parseBrainstormTraceRow(
|
|
123
|
+
stripped: string,
|
|
124
|
+
): Record<string, string> | null {
|
|
125
|
+
if (!stripped.startsWith("|") || !stripped.endsWith("|")) return null;
|
|
126
|
+
const cells = stripped
|
|
127
|
+
.slice(1, -1)
|
|
128
|
+
.split("|")
|
|
129
|
+
.map((c) => c.trim());
|
|
130
|
+
if (cells.length < 4) return null;
|
|
131
|
+
const [item, status, target, reason] = cells.slice(0, 4);
|
|
132
|
+
if (
|
|
133
|
+
item.toLowerCase() === "item" ||
|
|
134
|
+
(new Set(item).size <= 1 && [...item].every((c) => c === "-"))
|
|
135
|
+
)
|
|
136
|
+
return null;
|
|
137
|
+
return { item, status, target, reason };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function parseDiscoveryCache(
|
|
141
|
+
rawValue: string,
|
|
142
|
+
): Array<{ path: string; reason: string }> {
|
|
143
|
+
const value = (rawValue || "").trim();
|
|
144
|
+
if (!value) return [];
|
|
145
|
+
const entries: Array<{ path: string; reason: string }> = [];
|
|
146
|
+
for (const part of value.split(";")) {
|
|
147
|
+
const item = part.trim();
|
|
148
|
+
if (!item) continue;
|
|
149
|
+
const match = DISCOVERY_CACHE_ENTRY_RE.exec(item);
|
|
150
|
+
if (!match) {
|
|
151
|
+
throw new PlanValidationError(
|
|
152
|
+
"Discovery cache entries must use 'path (reason)' format.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
entries.push({ path: match[1].trim(), reason: match[2].trim() });
|
|
156
|
+
}
|
|
157
|
+
return entries;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function validateParallelProbeShape(
|
|
161
|
+
probe: Record<string, unknown>,
|
|
162
|
+
): Record<string, unknown> {
|
|
163
|
+
if (typeof probe !== "object" || probe === null) {
|
|
164
|
+
throw new PlanValidationError("Parallel probes must be objects.");
|
|
165
|
+
}
|
|
166
|
+
for (const field of ["scope", "output"]) {
|
|
167
|
+
const value = (probe as Record<string, unknown>)[field];
|
|
168
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
169
|
+
throw new PlanValidationError(
|
|
170
|
+
`Parallel probes require a non-empty ${field}.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (probe.readonly !== true) {
|
|
175
|
+
throw new PlanValidationError(
|
|
176
|
+
"Parallel probes must declare readonly: true.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return probe;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function parseParallelProbeEntry(
|
|
183
|
+
rawEntry: string,
|
|
184
|
+
): Record<string, unknown> {
|
|
185
|
+
const probe: Record<string, unknown> = {};
|
|
186
|
+
for (const part of rawEntry.split(",")) {
|
|
187
|
+
const item = part.trim();
|
|
188
|
+
if (!item) continue;
|
|
189
|
+
const eq = item.indexOf("=");
|
|
190
|
+
if (eq === -1) {
|
|
191
|
+
throw new PlanValidationError(
|
|
192
|
+
"Parallel probes entries must use key=value pairs or JSON.",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const key = item.slice(0, eq).trim();
|
|
196
|
+
const value = item.slice(eq + 1).trim();
|
|
197
|
+
probe[key] = value;
|
|
198
|
+
}
|
|
199
|
+
if (Object.keys(probe).length === 0) {
|
|
200
|
+
throw new PlanValidationError("Parallel probes entries must not be empty.");
|
|
201
|
+
}
|
|
202
|
+
if ("readonly" in probe) {
|
|
203
|
+
probe.readonly = String(probe.readonly).toLowerCase() === "true";
|
|
204
|
+
}
|
|
205
|
+
return validateParallelProbeShape(probe);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function parseParallelProbes(
|
|
209
|
+
rawValue: string,
|
|
210
|
+
): Array<Record<string, unknown>> {
|
|
211
|
+
const value = (rawValue || "").trim();
|
|
212
|
+
if (!value) return [];
|
|
213
|
+
if (value.startsWith("[")) {
|
|
214
|
+
let parsed: unknown;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(value);
|
|
217
|
+
} catch (exc) {
|
|
218
|
+
throw new PlanValidationError(`Parallel probes JSON is invalid: ${exc}`);
|
|
219
|
+
}
|
|
220
|
+
if (!Array.isArray(parsed)) {
|
|
221
|
+
throw new PlanValidationError("Parallel probes JSON must be a list.");
|
|
222
|
+
}
|
|
223
|
+
return parsed.map((p) =>
|
|
224
|
+
validateParallelProbeShape(p as Record<string, unknown>),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return value
|
|
228
|
+
.split(";")
|
|
229
|
+
.map((s) => s.trim())
|
|
230
|
+
.filter((s) => s.length > 0)
|
|
231
|
+
.map((s) => parseParallelProbeEntry(s));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── spec reference normalization ─────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
function parseSpecReference(rawValue: string): string {
|
|
237
|
+
return (rawValue || "").trim().replace(/^`|`$/g, "");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface ParsedSpecDesign {
|
|
241
|
+
path: string;
|
|
242
|
+
exists: boolean;
|
|
243
|
+
design_risk: string | null;
|
|
244
|
+
diagram_decision: string | null;
|
|
245
|
+
diagram_reason: string | null;
|
|
246
|
+
has_technical_design: boolean;
|
|
247
|
+
has_mermaid: boolean;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function contentOutsideFences(content: string): string {
|
|
251
|
+
let inFence = false;
|
|
252
|
+
return content
|
|
253
|
+
.split("\n")
|
|
254
|
+
.filter((line) => {
|
|
255
|
+
if (/^\s*```/.test(line)) {
|
|
256
|
+
inFence = !inFence;
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
return !inFence && !/^\s*>/.test(line);
|
|
260
|
+
})
|
|
261
|
+
.join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function technicalDesignSection(content: string): string | null {
|
|
265
|
+
const lines = content.split("\n");
|
|
266
|
+
let inFence = false;
|
|
267
|
+
let start = -1;
|
|
268
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
269
|
+
const line = lines[index];
|
|
270
|
+
if (/^\s*```/.test(line)) {
|
|
271
|
+
inFence = !inFence;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (inFence) continue;
|
|
275
|
+
if (
|
|
276
|
+
start === -1 &&
|
|
277
|
+
/^##\s+(?:\d+(?:\.\d+)*\.?\s+)?Technical Design\b/i.test(line)
|
|
278
|
+
) {
|
|
279
|
+
start = index;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (start !== -1 && /^##\s+/.test(line))
|
|
283
|
+
return lines.slice(start, index).join("\n");
|
|
284
|
+
}
|
|
285
|
+
return start === -1 ? null : lines.slice(start).join("\n");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function parseSpecField(content: string, field: string): string | null {
|
|
289
|
+
const match = new RegExp(
|
|
290
|
+
`^(?:-\\s*)?(?:\\*\\*)?${field}(?:\\*\\*)?\\s*:\\s*(.+?)\\s*$`,
|
|
291
|
+
"im",
|
|
292
|
+
).exec(contentOutsideFences(content));
|
|
293
|
+
return match?.[1]?.trim() || null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function resolveReferencedSpec(
|
|
297
|
+
planPath: string,
|
|
298
|
+
specReference: string,
|
|
299
|
+
): string {
|
|
300
|
+
const absolutePlan = resolve(planPath);
|
|
301
|
+
const marker = `${sep}docs${sep}plans${sep}`;
|
|
302
|
+
const markerIndex = absolutePlan.lastIndexOf(marker);
|
|
303
|
+
const projectRoot =
|
|
304
|
+
markerIndex === -1 ? process.cwd() : absolutePlan.slice(0, markerIndex);
|
|
305
|
+
return resolve(projectRoot, specReference);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function parseReferencedSpec(
|
|
309
|
+
planPath: string,
|
|
310
|
+
specReference: string | undefined,
|
|
311
|
+
): ParsedSpecDesign | null {
|
|
312
|
+
if (!specReference) return null;
|
|
313
|
+
const specPath = resolveReferencedSpec(planPath, specReference);
|
|
314
|
+
if (!existsSync(specPath)) {
|
|
315
|
+
return {
|
|
316
|
+
path: specPath,
|
|
317
|
+
exists: false,
|
|
318
|
+
design_risk: null,
|
|
319
|
+
diagram_decision: null,
|
|
320
|
+
diagram_reason: null,
|
|
321
|
+
has_technical_design: false,
|
|
322
|
+
has_mermaid: false,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const content = readFileSync(specPath, "utf-8");
|
|
327
|
+
const technicalDesign = technicalDesignSection(content);
|
|
328
|
+
return {
|
|
329
|
+
path: specPath,
|
|
330
|
+
exists: true,
|
|
331
|
+
design_risk: parseSpecField(content, "Design risk"),
|
|
332
|
+
diagram_decision: parseSpecField(content, "Diagram decision"),
|
|
333
|
+
diagram_reason: parseSpecField(content, "Diagram reason"),
|
|
334
|
+
has_technical_design: technicalDesign !== null,
|
|
335
|
+
has_mermaid: technicalDesign
|
|
336
|
+
? /```mermaid\b/i.test(technicalDesign)
|
|
337
|
+
: false,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── plan parsing ─────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
export interface ParsedStep {
|
|
344
|
+
number: number;
|
|
345
|
+
result: string | null;
|
|
346
|
+
verification: string | null;
|
|
347
|
+
step_id: string | null;
|
|
348
|
+
test_scenarios: string[];
|
|
349
|
+
scope: string[];
|
|
350
|
+
discovery_cache: Array<{ path: string; reason: string }>;
|
|
351
|
+
parallel_probes: Array<Record<string, unknown>>;
|
|
352
|
+
depends_on: number[];
|
|
353
|
+
agent_hint: string | null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface ParsedRoadmapPhase {
|
|
357
|
+
title: string;
|
|
358
|
+
acceptance_criteria_present: boolean;
|
|
359
|
+
acceptance_criteria: string[];
|
|
360
|
+
promotion_criteria_present: boolean;
|
|
361
|
+
promotion_criteria: string[];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface PlanValidationWarning {
|
|
365
|
+
code: string;
|
|
366
|
+
message: string;
|
|
367
|
+
phase?: string;
|
|
368
|
+
field?: string;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface ParsedPlan {
|
|
372
|
+
path: string;
|
|
373
|
+
summary: string | null;
|
|
374
|
+
task: Record<string, string>;
|
|
375
|
+
spec_design: ParsedSpecDesign | null;
|
|
376
|
+
brainstorm_trace: Array<Record<string, string>>;
|
|
377
|
+
roadmap_phases: ParsedRoadmapPhase[];
|
|
378
|
+
steps: ParsedStep[];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function parsePlan(path: string): ParsedPlan {
|
|
382
|
+
const lines = readFileSync(path, "utf-8").split("\n");
|
|
383
|
+
let summary: string | null = null;
|
|
384
|
+
const steps: ParsedStep[] = [];
|
|
385
|
+
let currentStep: ParsedStep | null = null;
|
|
386
|
+
const taskMeta: Record<string, string> = {};
|
|
387
|
+
let inTaskSection = false;
|
|
388
|
+
let inBrainstormTrace = false;
|
|
389
|
+
const brainstormTrace: Array<Record<string, string>> = [];
|
|
390
|
+
const roadmapPhases: ParsedRoadmapPhase[] = [];
|
|
391
|
+
let inRoadmap = false;
|
|
392
|
+
let currentRoadmapPhase: ParsedRoadmapPhase | null = null;
|
|
393
|
+
let currentRoadmapField: "acceptance_criteria" | "promotion_criteria" | null =
|
|
394
|
+
null;
|
|
395
|
+
const pushRoadmapPhase = () => {
|
|
396
|
+
if (currentRoadmapPhase) roadmapPhases.push(currentRoadmapPhase);
|
|
397
|
+
currentRoadmapPhase = null;
|
|
398
|
+
currentRoadmapField = null;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
for (const line of lines) {
|
|
402
|
+
const stripped = line.trim();
|
|
403
|
+
if (stripped.startsWith("- Summary:")) {
|
|
404
|
+
summary = stripped.slice("- Summary:".length).trim();
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (stripped === "## Task") {
|
|
408
|
+
inTaskSection = true;
|
|
409
|
+
inBrainstormTrace = false;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (stripped === "## Brainstorm Trace") {
|
|
413
|
+
inTaskSection = false;
|
|
414
|
+
inBrainstormTrace = true;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (stripped.startsWith("## ") && stripped !== "## Task") {
|
|
418
|
+
inTaskSection = false;
|
|
419
|
+
if (stripped !== "## Brainstorm Trace") inBrainstormTrace = false;
|
|
420
|
+
if (inRoadmap && !/roadmap/i.test(stripped)) pushRoadmapPhase();
|
|
421
|
+
inRoadmap = /roadmap/i.test(stripped);
|
|
422
|
+
currentRoadmapField = null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (inRoadmap) {
|
|
426
|
+
const phaseMatch = ROADMAP_PHASE_RE.exec(stripped);
|
|
427
|
+
if (phaseMatch) {
|
|
428
|
+
pushRoadmapPhase();
|
|
429
|
+
currentRoadmapPhase = {
|
|
430
|
+
title: phaseMatch[1].trim(),
|
|
431
|
+
acceptance_criteria_present: false,
|
|
432
|
+
acceptance_criteria: [],
|
|
433
|
+
promotion_criteria_present: false,
|
|
434
|
+
promotion_criteria: [],
|
|
435
|
+
};
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const criteriaMatch = ROADMAP_CRITERIA_RE.exec(stripped);
|
|
440
|
+
if (criteriaMatch && currentRoadmapPhase) {
|
|
441
|
+
const key = criteriaMatch[1].toLowerCase() as
|
|
442
|
+
| "acceptance_criteria"
|
|
443
|
+
| "promotion_criteria";
|
|
444
|
+
const value = criteriaMatch[2].trim();
|
|
445
|
+
currentRoadmapField = key;
|
|
446
|
+
currentRoadmapPhase[`${key}_present`] = true;
|
|
447
|
+
if (value) currentRoadmapPhase[key].push(value);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const listMatch = /^-\s+(.+?)\s*$/.exec(stripped);
|
|
452
|
+
if (listMatch && currentRoadmapPhase && currentRoadmapField) {
|
|
453
|
+
currentRoadmapPhase[currentRoadmapField].push(listMatch[1].trim());
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (stripped && !stripped.startsWith("-")) currentRoadmapField = null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const stepMatch = STEP_HEADER_RE.exec(stripped);
|
|
461
|
+
if (stepMatch) {
|
|
462
|
+
if (currentStep) steps.push(currentStep);
|
|
463
|
+
currentStep = {
|
|
464
|
+
number: Number(stepMatch[1]),
|
|
465
|
+
result: null,
|
|
466
|
+
verification: null,
|
|
467
|
+
step_id: null,
|
|
468
|
+
test_scenarios: [],
|
|
469
|
+
scope: [],
|
|
470
|
+
discovery_cache: [],
|
|
471
|
+
parallel_probes: [],
|
|
472
|
+
depends_on: [],
|
|
473
|
+
agent_hint: null,
|
|
474
|
+
};
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (inTaskSection) {
|
|
479
|
+
const taskMatch = TASK_FIELD_RE.exec(stripped);
|
|
480
|
+
if (taskMatch) {
|
|
481
|
+
const [, key, value] = taskMatch;
|
|
482
|
+
const normalizedKey = key.toLowerCase().replace(/ /g, "_");
|
|
483
|
+
if (normalizedKey === "spec") {
|
|
484
|
+
taskMeta[normalizedKey] = parseSpecReference(value);
|
|
485
|
+
} else {
|
|
486
|
+
taskMeta[normalizedKey] = value.trim();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (inBrainstormTrace) {
|
|
493
|
+
const traceRow = parseBrainstormTraceRow(stripped);
|
|
494
|
+
if (traceRow) brainstormTrace.push(traceRow);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const fieldMatch = FIELD_RE.exec(stripped);
|
|
499
|
+
if (fieldMatch && currentStep) {
|
|
500
|
+
const [, key, value] = fieldMatch;
|
|
501
|
+
if (key === "Result") currentStep.result = value.trim();
|
|
502
|
+
else if (key === "Scope") currentStep.scope = parseStepScope(value);
|
|
503
|
+
else if (key === "Verification") currentStep.verification = value.trim();
|
|
504
|
+
else if (key === "Step ID") currentStep.step_id = value.trim();
|
|
505
|
+
else if (key === "Test scenarios") {
|
|
506
|
+
if (value)
|
|
507
|
+
currentStep.test_scenarios = value
|
|
508
|
+
.split(";")
|
|
509
|
+
.map((s) => s.trim())
|
|
510
|
+
.filter((s) => s.length > 0);
|
|
511
|
+
} else if (key === "Discovery cache")
|
|
512
|
+
currentStep.discovery_cache = parseDiscoveryCache(value);
|
|
513
|
+
else if (key === "Parallel probes")
|
|
514
|
+
currentStep.parallel_probes = parseParallelProbes(value);
|
|
515
|
+
else if (key === "Depends on")
|
|
516
|
+
currentStep.depends_on = parseDependsOn(value);
|
|
517
|
+
else if (["Agent", "Agent Hint", "Dispatch"].includes(key))
|
|
518
|
+
currentStep.agent_hint = value.trim();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (currentStep) steps.push(currentStep);
|
|
523
|
+
if (inRoadmap) pushRoadmapPhase();
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
path,
|
|
527
|
+
summary,
|
|
528
|
+
task: taskMeta,
|
|
529
|
+
spec_design: parseReferencedSpec(path, taskMeta.spec),
|
|
530
|
+
brainstorm_trace: brainstormTrace,
|
|
531
|
+
roadmap_phases: roadmapPhases,
|
|
532
|
+
steps,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── plan signature ───────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
export interface NormalizedStep {
|
|
539
|
+
number: number;
|
|
540
|
+
step_id: string;
|
|
541
|
+
result: string;
|
|
542
|
+
verification: string;
|
|
543
|
+
scope: string[];
|
|
544
|
+
depends_on: number[];
|
|
545
|
+
discovery_cache: Array<{ path: string; reason: string }>;
|
|
546
|
+
parallel_probes: Array<Record<string, unknown>>;
|
|
547
|
+
agent_hint?: string | null;
|
|
548
|
+
test_scenarios?: string[] | null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export interface NormalizedPlan {
|
|
552
|
+
plan_path: string;
|
|
553
|
+
summary: string;
|
|
554
|
+
task: Record<string, string>;
|
|
555
|
+
steps: NormalizedStep[];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export type WorkflowProfile = "standard" | "strict";
|
|
559
|
+
export type CompounderPolicy = "optional" | "required";
|
|
560
|
+
|
|
561
|
+
export function workflowProfileForTask(
|
|
562
|
+
task: Record<string, string> | null | undefined,
|
|
563
|
+
): WorkflowProfile {
|
|
564
|
+
return task?.workflow_profile?.trim().toLowerCase() === "standard"
|
|
565
|
+
? "standard"
|
|
566
|
+
: "strict";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function compounderPolicyForTask(
|
|
570
|
+
task: Record<string, string> | null | undefined,
|
|
571
|
+
): CompounderPolicy {
|
|
572
|
+
if (task?.compounder?.trim().toLowerCase() === "optional") return "optional";
|
|
573
|
+
return "required";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function buildSignaturePayload(normalizedPlan: NormalizedPlan): string {
|
|
577
|
+
// Keep cross-runtime parity: the legacy Plan signature predates Scope.
|
|
578
|
+
const payload = {
|
|
579
|
+
summary: normalizedPlan.summary,
|
|
580
|
+
task: normalizedPlan.task,
|
|
581
|
+
steps: normalizedPlan.steps.map((step) => ({
|
|
582
|
+
number: step.number,
|
|
583
|
+
step_id: step.step_id,
|
|
584
|
+
result: step.result,
|
|
585
|
+
verification: step.verification,
|
|
586
|
+
test_scenarios: step.test_scenarios,
|
|
587
|
+
discovery_cache: step.discovery_cache,
|
|
588
|
+
parallel_probes: step.parallel_probes,
|
|
589
|
+
depends_on: step.depends_on,
|
|
590
|
+
agent_hint: step.agent_hint,
|
|
591
|
+
})),
|
|
592
|
+
};
|
|
593
|
+
return stableStringify(payload);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function buildPlanSignature(normalizedPlan: NormalizedPlan): string {
|
|
597
|
+
return createHash("sha256")
|
|
598
|
+
.update(buildSignaturePayload(normalizedPlan))
|
|
599
|
+
.digest("hex");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function normalizePlan(
|
|
603
|
+
plan: ParsedPlan,
|
|
604
|
+
projectRoot?: string,
|
|
605
|
+
): NormalizedPlan {
|
|
606
|
+
const normalizedPlanPath = normalizePlanPath(plan.path, projectRoot);
|
|
607
|
+
return {
|
|
608
|
+
plan_path: normalizedPlanPath,
|
|
609
|
+
summary: plan.summary || "",
|
|
610
|
+
task: plan.task,
|
|
611
|
+
steps: plan.steps.map((step) => ({
|
|
612
|
+
number: step.number,
|
|
613
|
+
step_id: step.step_id || "",
|
|
614
|
+
result: step.result || "",
|
|
615
|
+
verification: step.verification || "",
|
|
616
|
+
scope: step.scope || [],
|
|
617
|
+
test_scenarios: step.test_scenarios,
|
|
618
|
+
discovery_cache: step.discovery_cache || [],
|
|
619
|
+
parallel_probes: step.parallel_probes || [],
|
|
620
|
+
depends_on: step.depends_on,
|
|
621
|
+
agent_hint: step.agent_hint,
|
|
622
|
+
})),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function normalizePlanPath(
|
|
627
|
+
planPath: string,
|
|
628
|
+
projectRoot?: string,
|
|
629
|
+
): string {
|
|
630
|
+
const root = projectRoot ? projectRoot : process.cwd();
|
|
631
|
+
const resolved = resolve(root, planPath);
|
|
632
|
+
const rel = relative(root, resolved);
|
|
633
|
+
return rel.startsWith("..") ? resolved : rel;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── plan validation ─────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
function validateRoadmapCriteria(
|
|
639
|
+
phases: ParsedRoadmapPhase[],
|
|
640
|
+
): PlanValidationWarning[] {
|
|
641
|
+
if (phases.length < 3) return [];
|
|
642
|
+
const warnings: PlanValidationWarning[] = [];
|
|
643
|
+
for (const phase of phases) {
|
|
644
|
+
if (!phase.acceptance_criteria_present) {
|
|
645
|
+
warnings.push({
|
|
646
|
+
code: "roadmap_acceptance_criteria_missing",
|
|
647
|
+
message: `${phase.title} is missing acceptance_criteria.`,
|
|
648
|
+
phase: phase.title,
|
|
649
|
+
field: "acceptance_criteria",
|
|
650
|
+
});
|
|
651
|
+
} else if (phase.acceptance_criteria.length === 0) {
|
|
652
|
+
warnings.push({
|
|
653
|
+
code: "roadmap_acceptance_criteria_empty",
|
|
654
|
+
message: `${phase.title} has empty acceptance_criteria.`,
|
|
655
|
+
phase: phase.title,
|
|
656
|
+
field: "acceptance_criteria",
|
|
657
|
+
});
|
|
658
|
+
} else if (
|
|
659
|
+
phase.acceptance_criteria.some((item) =>
|
|
660
|
+
NON_BEHAVIORAL_ACCEPTANCE_RE.test(item),
|
|
661
|
+
)
|
|
662
|
+
) {
|
|
663
|
+
warnings.push({
|
|
664
|
+
code: "roadmap_acceptance_criteria_non_behavioral",
|
|
665
|
+
message: `${phase.title} has non-behavioral acceptance_criteria.`,
|
|
666
|
+
phase: phase.title,
|
|
667
|
+
field: "acceptance_criteria",
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (
|
|
672
|
+
phase.promotion_criteria_present &&
|
|
673
|
+
phase.promotion_criteria.length === 0
|
|
674
|
+
) {
|
|
675
|
+
warnings.push({
|
|
676
|
+
code: "roadmap_promotion_criteria_empty",
|
|
677
|
+
message: `${phase.title} has empty promotion_criteria.`,
|
|
678
|
+
phase: phase.title,
|
|
679
|
+
field: "promotion_criteria",
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return warnings;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function validateSpecDesign(spec: ParsedSpecDesign | null): {
|
|
687
|
+
errors: string[];
|
|
688
|
+
warnings: PlanValidationWarning[];
|
|
689
|
+
} {
|
|
690
|
+
if (!spec) return { errors: [], warnings: [] };
|
|
691
|
+
if (!spec.exists)
|
|
692
|
+
return {
|
|
693
|
+
errors: [`Referenced Spec does not exist: ${spec.path}`],
|
|
694
|
+
warnings: [],
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const hasMetadata = Boolean(
|
|
698
|
+
spec.design_risk || spec.diagram_decision || spec.diagram_reason,
|
|
699
|
+
);
|
|
700
|
+
if (!hasMetadata) {
|
|
701
|
+
return {
|
|
702
|
+
errors: [],
|
|
703
|
+
warnings: [
|
|
704
|
+
{
|
|
705
|
+
code: "spec_design_metadata_missing",
|
|
706
|
+
message: `${spec.path} uses the legacy design contract; add Design risk, Diagram decision, and Diagram reason when revising it.`,
|
|
707
|
+
field: "design_metadata",
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const errors: string[] = [];
|
|
714
|
+
const risk = spec.design_risk?.split(/\s|—|-/)[0].toLowerCase();
|
|
715
|
+
const decision = spec.diagram_decision?.toLowerCase().replace(/[ -]+/g, "_");
|
|
716
|
+
|
|
717
|
+
if (!risk || !["low", "medium", "high"].includes(risk)) {
|
|
718
|
+
errors.push("Referenced Spec Design risk must be Low, Medium, or High.");
|
|
719
|
+
}
|
|
720
|
+
if (!decision || !["required", "not_required"].includes(decision)) {
|
|
721
|
+
errors.push(
|
|
722
|
+
"Referenced Spec Diagram decision must be required or not_required.",
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
if (!spec.diagram_reason) {
|
|
726
|
+
errors.push("Referenced Spec is missing Diagram reason.");
|
|
727
|
+
}
|
|
728
|
+
if (["medium", "high"].includes(risk || "") && !spec.has_technical_design) {
|
|
729
|
+
errors.push(
|
|
730
|
+
`Referenced ${risk}-risk Spec is missing a Technical Design section.`,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
if (decision === "required" && !spec.has_mermaid) {
|
|
734
|
+
errors.push(
|
|
735
|
+
"Referenced Spec requires a Mermaid diagram but does not contain one.",
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return { errors, warnings: [] };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function validateWorkflowProfile(plan: ParsedPlan): string[] {
|
|
743
|
+
const errors: string[] = [];
|
|
744
|
+
const rawProfile = (plan.task.workflow_profile || "").trim().toLowerCase();
|
|
745
|
+
const rawCompounder = (plan.task.compounder || "").trim().toLowerCase();
|
|
746
|
+
if (rawProfile && !["standard", "strict"].includes(rawProfile)) {
|
|
747
|
+
errors.push(
|
|
748
|
+
"Workflow profile must be standard or strict; Direct Path does not use a Plan.",
|
|
749
|
+
);
|
|
750
|
+
return errors;
|
|
751
|
+
}
|
|
752
|
+
if (rawCompounder && !["optional", "required"].includes(rawCompounder)) {
|
|
753
|
+
errors.push("Compounder must be optional or required.");
|
|
754
|
+
}
|
|
755
|
+
const profile = workflowProfileForTask(plan.task);
|
|
756
|
+
if (profile === "strict" && rawCompounder === "optional") {
|
|
757
|
+
errors.push("Strict workflow profile requires Compounder: required.");
|
|
758
|
+
}
|
|
759
|
+
if (profile !== "standard") return errors;
|
|
760
|
+
|
|
761
|
+
const risk = plan.spec_design?.design_risk
|
|
762
|
+
?.split(/\s|—|-/)[0]
|
|
763
|
+
.toLowerCase();
|
|
764
|
+
if (risk === "high") {
|
|
765
|
+
errors.push("High-risk Specs require Workflow profile: strict.");
|
|
766
|
+
}
|
|
767
|
+
for (const step of plan.steps) {
|
|
768
|
+
if (!/`[^`]+`/.test(step.verification || "")) {
|
|
769
|
+
errors.push(
|
|
770
|
+
`Standard workflow profile requires automated Verification for Step ${step.number}.`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return errors;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function validatePlanContract(task: Record<string, string>): string[] {
|
|
778
|
+
const contract = (task.plan_contract || "").trim();
|
|
779
|
+
if (!contract) return [];
|
|
780
|
+
if (contract !== ROADMAP_SLICE_CONTRACT)
|
|
781
|
+
return [`Unsupported Plan contract: ${contract}`];
|
|
782
|
+
|
|
783
|
+
const errors: string[] = [];
|
|
784
|
+
for (const [key, label] of ROADMAP_SLICE_REQUIRED_FIELDS) {
|
|
785
|
+
if (!(task[key] || "").trim()) {
|
|
786
|
+
errors.push(
|
|
787
|
+
`${ROADMAP_SLICE_CONTRACT} is missing required Task field: ${label}.`,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const currentPhase = (task.current_phase || "").trim();
|
|
793
|
+
if (
|
|
794
|
+
currentPhase &&
|
|
795
|
+
(currentPhase.toLowerCase() === "none" ||
|
|
796
|
+
!ROADMAP_PHASE_ID_RE.test(currentPhase))
|
|
797
|
+
) {
|
|
798
|
+
errors.push(
|
|
799
|
+
`${ROADMAP_SLICE_CONTRACT} has invalid Current phase: ${currentPhase}`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const successor = (task.successor_candidate || "").trim();
|
|
804
|
+
const successorIsTerminal = successor.toLowerCase() === "none";
|
|
805
|
+
if (
|
|
806
|
+
successor &&
|
|
807
|
+
!successorIsTerminal &&
|
|
808
|
+
!ROADMAP_PHASE_ID_RE.test(successor)
|
|
809
|
+
) {
|
|
810
|
+
errors.push(
|
|
811
|
+
`${ROADMAP_SLICE_CONTRACT} has invalid Successor candidate: ${successor}`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
if (
|
|
815
|
+
currentPhase &&
|
|
816
|
+
successor &&
|
|
817
|
+
!successorIsTerminal &&
|
|
818
|
+
currentPhase === successor
|
|
819
|
+
) {
|
|
820
|
+
errors.push(
|
|
821
|
+
`${ROADMAP_SLICE_CONTRACT} Successor candidate must differ from Current phase.`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const preconditions = (task.successor_preconditions || "").trim();
|
|
826
|
+
if (
|
|
827
|
+
successor &&
|
|
828
|
+
!successorIsTerminal &&
|
|
829
|
+
preconditions.toLowerCase() === "none"
|
|
830
|
+
) {
|
|
831
|
+
errors.push(
|
|
832
|
+
`${ROADMAP_SLICE_CONTRACT} requires Successor preconditions for non-terminal candidate ${successor}.`,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
if (
|
|
836
|
+
successorIsTerminal &&
|
|
837
|
+
preconditions &&
|
|
838
|
+
preconditions.toLowerCase() !== "none"
|
|
839
|
+
) {
|
|
840
|
+
errors.push(
|
|
841
|
+
`${ROADMAP_SLICE_CONTRACT} terminal Successor candidate requires Successor preconditions: none.`,
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return errors;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export function validatePlan(plan: ParsedPlan): {
|
|
849
|
+
errors: string[];
|
|
850
|
+
warnings: PlanValidationWarning[];
|
|
851
|
+
} {
|
|
852
|
+
const designValidation = validateSpecDesign(plan.spec_design);
|
|
853
|
+
const legacySpecErrors = plan.task.spec?.startsWith("docs/architecture/")
|
|
854
|
+
? [
|
|
855
|
+
"Legacy Plan Spec references are not accepted; migrate the active project or update the Plan to docs/specs/.",
|
|
856
|
+
]
|
|
857
|
+
: [];
|
|
858
|
+
const errors = [
|
|
859
|
+
...legacySpecErrors,
|
|
860
|
+
...designValidation.errors,
|
|
861
|
+
...validateWorkflowProfile(plan),
|
|
862
|
+
...validatePlanContract(plan.task),
|
|
863
|
+
];
|
|
864
|
+
const warnings = [
|
|
865
|
+
...validateRoadmapCriteria(plan.roadmap_phases || []),
|
|
866
|
+
...designValidation.warnings,
|
|
867
|
+
];
|
|
868
|
+
const steps = plan.steps;
|
|
869
|
+
|
|
870
|
+
if (steps.length === 0) {
|
|
871
|
+
errors.push("Plan must contain at least one independently closable step.");
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const seenNumbers = new Set<number>();
|
|
875
|
+
let previousNumber = 0;
|
|
876
|
+
|
|
877
|
+
for (const step of steps) {
|
|
878
|
+
const number = step.number;
|
|
879
|
+
const result = (step.result || "").trim();
|
|
880
|
+
const verification = (step.verification || "").trim();
|
|
881
|
+
const dependsOn = step.depends_on;
|
|
882
|
+
|
|
883
|
+
if (seenNumbers.has(number)) errors.push(`Step ${number} is duplicated.`);
|
|
884
|
+
seenNumbers.add(number);
|
|
885
|
+
|
|
886
|
+
if (number <= previousNumber)
|
|
887
|
+
errors.push("Steps must be in strictly increasing order.");
|
|
888
|
+
previousNumber = number;
|
|
889
|
+
|
|
890
|
+
if (!result) errors.push(`Step ${number} is missing Result.`);
|
|
891
|
+
else if (hasPlaceholder(result))
|
|
892
|
+
errors.push(
|
|
893
|
+
`Step ${number} Result still contains a template placeholder.`,
|
|
894
|
+
);
|
|
895
|
+
else if (isActionStepResult(result)) {
|
|
896
|
+
errors.push(`Step ${number} Result is an action, not an outcome.`);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (result) {
|
|
900
|
+
for (const marker of MULTI_RESULT_MARKERS) {
|
|
901
|
+
if (result.includes(marker)) {
|
|
902
|
+
errors.push(
|
|
903
|
+
`Step ${number} Result contains multi-result marker '${marker}'.`,
|
|
904
|
+
);
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (!verification) errors.push(`Step ${number} is missing Verification.`);
|
|
911
|
+
else if (hasPlaceholder(verification)) {
|
|
912
|
+
errors.push(
|
|
913
|
+
`Step ${number} Verification still contains a template placeholder.`,
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (step.step_id && !STEP_ID_RE.test(step.step_id)) {
|
|
918
|
+
errors.push(`Step ${number} has invalid Step ID: ${step.step_id}`);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
for (const dep of dependsOn) {
|
|
922
|
+
if (dep >= number) {
|
|
923
|
+
errors.push(`Step ${number} depends on a later or equal step: ${dep}.`);
|
|
924
|
+
}
|
|
925
|
+
if (dep < 1) {
|
|
926
|
+
errors.push(`Step ${number} depends on an invalid step: ${dep}.`);
|
|
927
|
+
}
|
|
928
|
+
if (!steps.some((s) => s.number === dep)) {
|
|
929
|
+
errors.push(`Step ${number} depends on a missing step: ${dep}.`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// brainstorm trace validation
|
|
935
|
+
const manifestItems = parseBrainstormManifestItems(
|
|
936
|
+
plan.task.brainstorm_manifest || "",
|
|
937
|
+
);
|
|
938
|
+
if (manifestItems.length > 0) {
|
|
939
|
+
const traceItems = new Set(plan.brainstorm_trace.map((r) => r.item));
|
|
940
|
+
for (const item of manifestItems) {
|
|
941
|
+
if (!traceItems.has(item)) {
|
|
942
|
+
errors.push(
|
|
943
|
+
`Brainstorm manifest item ${item} is not mapped in Brainstorm Trace.`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
for (const row of plan.brainstorm_trace) {
|
|
948
|
+
if (!BR_ITEM_RE.test(row.item)) {
|
|
949
|
+
errors.push(`Brainstorm Trace item has invalid ID: ${row.item}`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return { errors, warnings };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export interface OriginCoverageProjection {
|
|
958
|
+
applicable: boolean;
|
|
959
|
+
declared_items: number;
|
|
960
|
+
mapped_items: number;
|
|
961
|
+
unmapped_items: number;
|
|
962
|
+
reason_required_without_reason: number;
|
|
963
|
+
deferred_or_out_of_scope_without_reason: number;
|
|
964
|
+
complete: boolean;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export interface PlanValidationProjection extends NormalizedPlan {
|
|
968
|
+
warnings: PlanValidationWarning[];
|
|
969
|
+
origin_coverage: OriginCoverageProjection;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const REASON_REQUIRED_STATUSES = new Set([
|
|
973
|
+
"partially_covered",
|
|
974
|
+
"out_of_scope",
|
|
975
|
+
"deferred",
|
|
976
|
+
]);
|
|
977
|
+
|
|
978
|
+
export function deriveOriginCoverage(
|
|
979
|
+
plan: ParsedPlan,
|
|
980
|
+
): OriginCoverageProjection {
|
|
981
|
+
const declared = [
|
|
982
|
+
...new Set(
|
|
983
|
+
parseBrainstormManifestItems(plan.task.brainstorm_manifest || ""),
|
|
984
|
+
),
|
|
985
|
+
];
|
|
986
|
+
if (declared.length === 0) {
|
|
987
|
+
return {
|
|
988
|
+
applicable: false,
|
|
989
|
+
declared_items: 0,
|
|
990
|
+
mapped_items: 0,
|
|
991
|
+
unmapped_items: 0,
|
|
992
|
+
reason_required_without_reason: 0,
|
|
993
|
+
deferred_or_out_of_scope_without_reason: 0,
|
|
994
|
+
complete: true,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const declaredSet = new Set(declared);
|
|
999
|
+
const mapped = new Set(
|
|
1000
|
+
plan.brainstorm_trace
|
|
1001
|
+
.map((row) => row.item)
|
|
1002
|
+
.filter((item) => declaredSet.has(item)),
|
|
1003
|
+
);
|
|
1004
|
+
const reasonRequiredWithoutReason = plan.brainstorm_trace.filter(
|
|
1005
|
+
(row) =>
|
|
1006
|
+
declaredSet.has(row.item) &&
|
|
1007
|
+
REASON_REQUIRED_STATUSES.has((row.status || "").trim().toLowerCase()) &&
|
|
1008
|
+
!(row.reason || "").trim(),
|
|
1009
|
+
).length;
|
|
1010
|
+
const unmappedItems = declared.filter((item) => !mapped.has(item)).length;
|
|
1011
|
+
|
|
1012
|
+
return {
|
|
1013
|
+
applicable: true,
|
|
1014
|
+
declared_items: declared.length,
|
|
1015
|
+
mapped_items: mapped.size,
|
|
1016
|
+
unmapped_items: unmappedItems,
|
|
1017
|
+
reason_required_without_reason: reasonRequiredWithoutReason,
|
|
1018
|
+
deferred_or_out_of_scope_without_reason: reasonRequiredWithoutReason,
|
|
1019
|
+
complete: unmappedItems === 0 && reasonRequiredWithoutReason === 0,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function boundedPlanError(error: unknown): string {
|
|
1024
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1025
|
+
return message.replace(/[\r\n]+/g, " ").slice(0, 1024);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export function projectPlanValidation(
|
|
1029
|
+
planPath: string,
|
|
1030
|
+
projectRoot = process.cwd(),
|
|
1031
|
+
): PlanValidationProjection {
|
|
1032
|
+
let parsed: ParsedPlan;
|
|
1033
|
+
try {
|
|
1034
|
+
parsed = parsePlan(resolve(projectRoot, planPath));
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
throw new PlanValidationError(
|
|
1037
|
+
`Plan could not be read: ${boundedPlanError(error)}`,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
const validation = validatePlan(parsed);
|
|
1041
|
+
if (validation.errors.length > 0) {
|
|
1042
|
+
throw new PlanValidationError(
|
|
1043
|
+
`Plan validation failed: ${validation.errors.join("; ")}`.slice(0, 4096),
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
return {
|
|
1047
|
+
...normalizePlan(parsed, projectRoot),
|
|
1048
|
+
warnings: validation.warnings,
|
|
1049
|
+
origin_coverage: deriveOriginCoverage(parsed),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ── state machine ────────────────────────────────────────────────────
|