@peterxiaoyang/superspec 0.1.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 (68) hide show
  1. package/README.md +47 -0
  2. package/adapters/codex/agents/architect.toml +157 -0
  3. package/adapters/codex/agents/code-reviewer.toml +175 -0
  4. package/adapters/codex/agents/critic.toml +114 -0
  5. package/adapters/codex/agents/test-engineer.toml +163 -0
  6. package/adapters/codex/agents/verifier.toml +119 -0
  7. package/adapters/codex/install-map.json +81 -0
  8. package/bin/launch.js +37 -0
  9. package/bin/superspec-guard.js +4 -0
  10. package/bin/superspec-init.js +4 -0
  11. package/bin/superspec.js +4 -0
  12. package/dist/src/archive.d.ts +23 -0
  13. package/dist/src/archive.js +428 -0
  14. package/dist/src/cli.d.ts +1 -0
  15. package/dist/src/cli.js +20 -0
  16. package/dist/src/cli_args.d.ts +12 -0
  17. package/dist/src/cli_args.js +146 -0
  18. package/dist/src/core.d.ts +19 -0
  19. package/dist/src/core.js +357 -0
  20. package/dist/src/disclosure.d.ts +35 -0
  21. package/dist/src/disclosure.js +671 -0
  22. package/dist/src/evidence.d.ts +28 -0
  23. package/dist/src/evidence.js +849 -0
  24. package/dist/src/gates.d.ts +16 -0
  25. package/dist/src/gates.js +1470 -0
  26. package/dist/src/git.d.ts +8 -0
  27. package/dist/src/git.js +112 -0
  28. package/dist/src/init_cli.d.ts +2 -0
  29. package/dist/src/init_cli.js +145 -0
  30. package/dist/src/install_engine.d.ts +54 -0
  31. package/dist/src/install_engine.js +351 -0
  32. package/dist/src/invariants.d.ts +16 -0
  33. package/dist/src/invariants.js +363 -0
  34. package/dist/src/openspec.d.ts +18 -0
  35. package/dist/src/openspec.js +157 -0
  36. package/dist/src/paths.d.ts +22 -0
  37. package/dist/src/paths.js +203 -0
  38. package/dist/src/project_init.d.ts +4 -0
  39. package/dist/src/project_init.js +161 -0
  40. package/dist/src/state.d.ts +37 -0
  41. package/dist/src/state.js +464 -0
  42. package/dist/src/tasks.d.ts +23 -0
  43. package/dist/src/tasks.js +225 -0
  44. package/dist/src/util.d.ts +120 -0
  45. package/dist/src/util.js +442 -0
  46. package/dist/superspec.d.ts +4 -0
  47. package/dist/superspec.js +57 -0
  48. package/dist/superspec_guard.d.ts +4 -0
  49. package/dist/superspec_guard.js +19 -0
  50. package/dist/superspec_init.d.ts +2 -0
  51. package/dist/superspec_init.js +17 -0
  52. package/package.json +63 -0
  53. package/schemas/install-manifest.schema.json +80 -0
  54. package/templates/sidecar/archive-preservation.json +11 -0
  55. package/templates/sidecar/business-invariants.md +38 -0
  56. package/templates/sidecar/config.yaml +13 -0
  57. package/templates/sidecar/discovery.md +24 -0
  58. package/templates/sidecar/test-contract.md +26 -0
  59. package/templates/workflow/prompts/architect.md +113 -0
  60. package/templates/workflow/prompts/code-reviewer.md +141 -0
  61. package/templates/workflow/prompts/critic.md +80 -0
  62. package/templates/workflow/prompts/test-engineer.md +130 -0
  63. package/templates/workflow/prompts/verifier.md +85 -0
  64. package/templates/workflow/skills/superspec-apply/SKILL.md +72 -0
  65. package/templates/workflow/skills/superspec-archive/SKILL.md +41 -0
  66. package/templates/workflow/skills/superspec-explore/SKILL.md +70 -0
  67. package/templates/workflow/skills/superspec-propose/SKILL.md +79 -0
  68. package/templates/workflow/skills/superspec-review/SKILL.md +237 -0
@@ -0,0 +1,1470 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { ARTIFACT_ENTER_GATE, MAIN_ADJUDICATION_DECISIONS, REQUEST_CHANGES_ROUTES, NO_TDD_REASONS, OPENSPEC_ARTIFACTS, FINAL_VERIFICATION_ROLES, REQUIRED_SUPERSPEC_AGENT_ROLES, REQUIRED_SUPERSPEC_WORKFLOW_SKILLS, REQUIRED_OPENSPEC_CLI_SURFACES, REQUIRED_OPENSPEC_CODEX_SKILLS, REVIEW_GUIDANCE_ROLES, REVIEW_EVIDENCE_REQUIRED_FIELDS, TDD_MODES, VERIFY_EVIDENCE_REQUIRED_FIELDS, allow, block, commandExists, isObject, reason, renderList, repr, pinned_ref_key, safe_within, runCommand, sha256_text, runtime, toPosix, } from "./util.js";
4
+ import { all_done, artifact_status_map, get_repo_root, is_done, normalize_gate } from "./openspec.js";
5
+ import { read_agent_toml_name, read_skill_frontmatter_name, sidecar_business_invariants_path, sidecar_discovery_path, sidecar_test_contract_path } from "./paths.js";
6
+ import { business_invariant_ids, business_invariant_validation_reasons, automated_hard_business_invariant_ids, evidence_invariant_refs, evidence_invariant_ref_reasons, evidence_test_contract_invariant_reasons, human_confirmation_business_invariant_ids, invariant_matrix_coverage_reasons, post_implementation_business_invariant_ids, red_green_invariant_ids, test_contract_invariant_ids, } from "./invariants.js";
7
+ import { evidence_test_id_reasons, declared_test_evidence_reasons, parse_spec_scenarios, parse_tasks, parse_test_contract_ids, parse_test_contract_records, red_green_test_ids, splitList, tasks_structure_hash, task_alternative_verification, task_test_evidence, task_test_refs, test_contract_covers_scenario, test_contract_invariant_refs_by_test, write_scope_conflict_reasons, } from "./tasks.js";
8
+ import { duplicate_evidence_id_reasons, dangling_evidence_ref_reasons, final_verification_evidences, live_task_reopens, live_task_reopen_resolutions, live_pass, pass_task_reopens, supersede_reasons, unresolved_live_task_reopens, validate_evidence_schema, verify_reference_reasons, } from "./evidence.js";
9
+ import { review_disclosure_reasons } from "./disclosure.js";
10
+ import { archive_manifest_path } from "./archive.js";
11
+ function action_list(...items) {
12
+ return [...new Set(items.filter((item) => typeof item === "string" && item.length > 0))];
13
+ }
14
+ const SOURCE_GUIDANCE_REPAIR_REASONS = new Set([
15
+ "review_evidence_incomplete",
16
+ "missing_rollback_target",
17
+ "review_diff_not_covered",
18
+ "review_diff_unavailable",
19
+ ]);
20
+ const MAIN_ADJUDICATION_WRITE_REASONS = new Set([
21
+ "missing_main_adjudication",
22
+ "ambiguous_main_adjudication",
23
+ ]);
24
+ const MAIN_ADJUDICATION_REPAIR_REASONS = new Set([
25
+ "source_guidance_unreferenced",
26
+ "verification_evidence_unreferenced",
27
+ "required_claim_unadjudicated",
28
+ "claim_adjudication_blocked",
29
+ "blocking_findings_open",
30
+ "required_load_unloaded",
31
+ ]);
32
+ const VERIFICATION_REPAIR_REASONS = new Set([
33
+ "verification_evidence_incomplete",
34
+ "verification_ref_invalid",
35
+ "verification_ref_missing",
36
+ "test_evidence_ref_missing",
37
+ ]);
38
+ function reason_codes(reasons) {
39
+ return new Set(reasons.map((item) => item.code));
40
+ }
41
+ function has_reason(codes, wanted) {
42
+ return [...wanted].some((code) => codes.has(code));
43
+ }
44
+ function review_complete_actions(change, reasons, pre, opts = {}) {
45
+ const codes = reason_codes(reasons);
46
+ const inheritedReadyActions = !pre.allowed ? pre.next_allowed_actions : [];
47
+ const missingReviewRoles = (opts.missing_review_roles ?? []).sort();
48
+ const missingVerificationRoles = (opts.missing_verification_roles ?? []).sort();
49
+ return action_list(...inheritedReadyActions, !pre.allowed && inheritedReadyActions.length === 0 ? `pass check-review-ready for ${change} first` : null, missingReviewRoles.length > 0 ? `collect review_complete source_guidance from missing roles: ${renderList(missingReviewRoles)}` : null, has_reason(codes, SOURCE_GUIDANCE_REPAIR_REASONS)
50
+ ? "repair source_guidance evidence fields so every review lane has base/head refs, reviewed_files, and rollback_targets"
51
+ : null, has_reason(codes, MAIN_ADJUDICATION_WRITE_REASONS)
52
+ ? "write exactly one live main_adjudication referencing every live source_guidance and final verification evidence"
53
+ : null, has_reason(codes, MAIN_ADJUDICATION_REPAIR_REASONS)
54
+ ? "repair main_adjudication so it references all source_guidance/final verification evidence and explicitly covers required loads, claims, and blocking findings"
55
+ : null, missingVerificationRoles.length > 0 ? `collect verification_review evidence from missing roles: ${renderList(missingVerificationRoles)}` : null, has_reason(codes, VERIFICATION_REPAIR_REASONS)
56
+ ? "repair verification_review references so openspec_validate_ref, matrices, scope drift report, and test evidence refs are readable"
57
+ : null, codes.has("scope_drift") ? "resolve scope drift before review close: narrow the change or record accepted/none with evidence" : null, codes.has("missing_final_tests") ? "record final_test pass evidence and reference it from verification_review" : null, codes.has("verify_failure_unconfirmed")
58
+ ? "AskUserQuestion for the failed-verification disposition (fix or accept deviation) and record gate=\"verify_failure_handling\" human_confirmation referencing the failed evidence ids"
59
+ : null, codes.has("validate_failed") ? `fix openspec validate failures for ${change}` : null);
60
+ }
61
+ function request_changes_handoff_actions(adjudication) {
62
+ const route = String(adjudication.request_changes_route ?? "");
63
+ const reopenTaskIds = Array.isArray(adjudication.reopen_task_ids) ? adjudication.reopen_task_ids.map((item) => String(item)).filter(Boolean) : [];
64
+ if (route === "reopen_tasks") {
65
+ return action_list(reopenTaskIds.length > 0
66
+ ? `stop review completion and hand off to apply task_reopen for ${renderList(reopenTaskIds)}`
67
+ : "stop review completion and hand off to apply task_reopen", "do not add allow-path verification_review/final_test evidence to this request_changes round");
68
+ }
69
+ if (route === "change_update") {
70
+ return action_list("stop review completion and hand off to propose/change update", "do not add allow-path verification_review/final_test evidence to this request_changes round");
71
+ }
72
+ return action_list("repair request_changes_route and hand off the review round before retrying review_complete");
73
+ }
74
+ function blocking_findings(ev) {
75
+ return (Array.isArray(ev.blocking_findings) ? ev.blocking_findings : []).filter((item) => isObject(item));
76
+ }
77
+ function string_set(raw, opts = {}) {
78
+ if (!Array.isArray(raw))
79
+ return null;
80
+ const values = raw.map((item) => String(item)).filter(Boolean);
81
+ if (!opts.allowEmpty && values.length === 0)
82
+ return null;
83
+ if (values.length !== raw.length)
84
+ return null;
85
+ return values;
86
+ }
87
+ function live_request_changes_adjudications(evidences) {
88
+ return live_pass(evidences, { gate: "review_complete", kind: "main_adjudication" })
89
+ .filter((ev) => ev.review_decision === "request_changes");
90
+ }
91
+ function live_request_changes_reopen_adjudications_for_task(evidences, taskId) {
92
+ return live_request_changes_adjudications(evidences).filter((ev) => (ev.request_changes_route === "reopen_tasks"
93
+ && Array.isArray(ev.reopen_task_ids)
94
+ && ev.reopen_task_ids.map((item) => String(item)).includes(taskId)));
95
+ }
96
+ function output_ref_identity(changeRoot, ev) {
97
+ if (typeof ev.output_ref !== "string" || !ev.output_ref)
98
+ return null;
99
+ const outputPath = safe_within(changeRoot, ev.output_ref);
100
+ if (outputPath === null || !existsSync(outputPath) || !statSync(outputPath).isFile())
101
+ return ev.output_ref;
102
+ const st = statSync(outputPath);
103
+ return `${st.dev}:${st.ino}`;
104
+ }
105
+ function duplicate_output_ref_reasons(changeRoot, evidences) {
106
+ const byRef = new Map();
107
+ for (const ev of evidences) {
108
+ const identity = output_ref_identity(changeRoot, ev);
109
+ if (identity === null)
110
+ continue;
111
+ const bucket = byRef.get(identity) ?? { refs: new Set(), ids: [] };
112
+ bucket.refs.add(String(ev.output_ref));
113
+ bucket.ids.push(String(ev.evidence_id ?? ev._path ?? ev.kind ?? "unknown"));
114
+ byRef.set(identity, bucket);
115
+ }
116
+ const problems = [];
117
+ for (const bucket of byRef.values()) {
118
+ if (bucket.ids.length > 1) {
119
+ const refs = [...bucket.refs].sort();
120
+ const ids = bucket.ids.sort();
121
+ problems.push(reason("evidence_output_ref_duplicate", `review evidence output_ref must be unique, ${renderList(refs)} is shared by: ${renderList(ids)}`, ids));
122
+ }
123
+ }
124
+ return problems;
125
+ }
126
+ // FIX-11 (audit H-1): a propose-phase review stamp is a per-gate contract, not a rubber stamp.
127
+ // Within one gate, live/pass role evidence must not reuse the same output_ref (same dedup as
128
+ // review_complete). Reusing one output_ref across gates (the omnibus "refresh" pattern) is only
129
+ // legal when the evidence declares review_scope[] explicitly covering this gate's target artifact.
130
+ const PROPOSE_REVIEW_TARGET_ARTIFACTS = {
131
+ explore_complete: ".superspec/artifacts/discovery.md",
132
+ proposal_reviewed: "proposal.md",
133
+ design_complete: "design.md",
134
+ invariants_reviewed: ".superspec/artifacts/business-invariants.md",
135
+ test_contract_drafted: ".superspec/artifacts/test-contract.md",
136
+ };
137
+ function propose_review_output_ref_reasons(changeRoot, evidences, gate) {
138
+ const target = PROPOSE_REVIEW_TARGET_ARTIFACTS[gate];
139
+ if (!target)
140
+ return [];
141
+ const gateReviews = live_pass(evidences, { gate });
142
+ const problems = duplicate_output_ref_reasons(changeRoot, gateReviews);
143
+ const foreign = live_pass(evidences).filter((ev) => normalize_gate(String(ev.gate ?? "")) !== gate);
144
+ for (const ev of gateReviews) {
145
+ const identity = output_ref_identity(changeRoot, ev);
146
+ if (identity === null)
147
+ continue;
148
+ const reusedAcrossGates = foreign.some((other) => output_ref_identity(changeRoot, other) === identity);
149
+ if (!reusedAcrossGates)
150
+ continue;
151
+ const scope = Array.isArray(ev.review_scope)
152
+ ? ev.review_scope.filter((item) => typeof item === "string").map((item) => toPosix(String(item)))
153
+ : [];
154
+ if (!scope.includes(target)) {
155
+ const id = String(ev.evidence_id ?? ev._path ?? ev.kind ?? "unknown");
156
+ problems.push(reason("review_scope_unverified", `${id}: output_ref ${repr(String(ev.output_ref))} is reused across gates; ${gate} requires review_scope[] covering ${target}`, [id]));
157
+ }
158
+ }
159
+ return problems;
160
+ }
161
+ function main_adjudication_source_guidance_reasons(adjudication, sourceGuidance) {
162
+ const reasons = [];
163
+ const requiredClaims = new Set();
164
+ const requiredLoads = new Map();
165
+ const requiredFindings = new Set();
166
+ for (const ev of sourceGuidance) {
167
+ for (const claimId of Array.isArray(ev.required_claim_ids) ? ev.required_claim_ids : [])
168
+ requiredClaims.add(String(claimId));
169
+ for (const refItem of Array.isArray(ev.required_load_refs) ? ev.required_load_refs : []) {
170
+ if (isObject(refItem) && typeof refItem.path === "string" && typeof refItem.blob_sha === "string") {
171
+ requiredLoads.set(pinned_ref_key(refItem), refItem);
172
+ }
173
+ }
174
+ for (const finding of Array.isArray(ev.blocking_findings) ? ev.blocking_findings : []) {
175
+ if (isObject(finding) && typeof finding.finding_id === "string" && finding.finding_id)
176
+ requiredFindings.add(finding.finding_id);
177
+ }
178
+ }
179
+ const claimCounts = new Map();
180
+ const claimNeedsFix = [];
181
+ for (const item of Array.isArray(adjudication.claim_adjudications) ? adjudication.claim_adjudications : []) {
182
+ if (!isObject(item) || typeof item.claim_id !== "string" || !item.claim_id)
183
+ continue;
184
+ claimCounts.set(item.claim_id, (claimCounts.get(item.claim_id) ?? 0) + 1);
185
+ if (item.decision === "needs_fix")
186
+ claimNeedsFix.push(item.claim_id);
187
+ }
188
+ const missingClaims = [...requiredClaims].filter((id) => !claimCounts.has(id)).sort();
189
+ if (missingClaims.length > 0) {
190
+ reasons.push(reason("required_claim_unadjudicated", `${adjudication._path}: main_adjudication must cover required_claim_ids from source_guidance: ${renderList(missingClaims)}`, missingClaims));
191
+ }
192
+ const duplicatedClaims = [...claimCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
193
+ if (duplicatedClaims.length > 0) {
194
+ reasons.push(reason("required_claim_unadjudicated", `${adjudication._path}: claim_adjudications must cover required_claim_ids exactly once: ${renderList(duplicatedClaims)}`, duplicatedClaims));
195
+ }
196
+ if (claimNeedsFix.length > 0) {
197
+ reasons.push(reason("claim_adjudication_blocked", `${adjudication._path}: claim_adjudications still contain needs_fix: ${renderList(claimNeedsFix.sort())}`, claimNeedsFix.sort()));
198
+ }
199
+ const findingCounts = new Map();
200
+ const openFindings = [];
201
+ for (const item of Array.isArray(adjudication.finding_adjudications) ? adjudication.finding_adjudications : []) {
202
+ if (!isObject(item) || typeof item.finding_id !== "string" || !item.finding_id)
203
+ continue;
204
+ findingCounts.set(item.finding_id, (findingCounts.get(item.finding_id) ?? 0) + 1);
205
+ if (item.decision === "needs_fix")
206
+ openFindings.push(item.finding_id);
207
+ }
208
+ const missingFindings = [...requiredFindings].filter((id) => !findingCounts.has(id)).sort();
209
+ if (missingFindings.length > 0) {
210
+ reasons.push(reason("blocking_findings_open", `${adjudication._path}: finding_adjudications must cover every blocking finding exactly once: ${renderList(missingFindings)}`, missingFindings));
211
+ }
212
+ const duplicatedFindings = [...findingCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
213
+ if (duplicatedFindings.length > 0) {
214
+ reasons.push(reason("blocking_findings_open", `${adjudication._path}: finding_adjudications duplicate blocking finding ids: ${renderList(duplicatedFindings)}`, duplicatedFindings));
215
+ }
216
+ if (openFindings.length > 0) {
217
+ reasons.push(reason("blocking_findings_open", `${adjudication._path}: blocking findings still require fixes: ${renderList(openFindings.sort())}`, openFindings.sort()));
218
+ }
219
+ const loadedPaths = new Set((Array.isArray(adjudication.loaded_refs) ? adjudication.loaded_refs : [])
220
+ .filter((item) => isObject(item) && typeof item.path === "string")
221
+ .map((item) => pinned_ref_key(item)));
222
+ const missingLoads = [...requiredLoads.entries()].filter(([key]) => !loadedPaths.has(key)).map(([, item]) => String(item.path)).sort();
223
+ if (missingLoads.length > 0) {
224
+ reasons.push(reason("required_load_unloaded", `${adjudication._path}: main_adjudication must load required source refs before deciding: ${renderList(missingLoads)}`, missingLoads));
225
+ }
226
+ return reasons;
227
+ }
228
+ function request_changes_round_reasons(evidences) {
229
+ const reasons = [];
230
+ const liveSourceGuidance = live_pass(evidences, { gate: "review_complete", kind: "source_guidance" });
231
+ const liveSourceGuidanceById = new Map(liveSourceGuidance.map((ev) => [String(ev.evidence_id), ev]));
232
+ for (const adjudication of live_request_changes_adjudications(evidences)) {
233
+ const route = String(adjudication.request_changes_route ?? "");
234
+ reasons.push(...main_adjudication_source_guidance_reasons(adjudication, liveSourceGuidance));
235
+ if (route !== "reopen_tasks")
236
+ continue;
237
+ const sourceEvidenceIds = (Array.isArray(adjudication.source_evidence_refs) ? adjudication.source_evidence_refs : []).map((item) => String(item)).filter(Boolean);
238
+ const sourceEvidenceIdSet = new Set(sourceEvidenceIds);
239
+ const blockingSourceIds = (Array.isArray(adjudication.blocking_source_evidence_refs) ? adjudication.blocking_source_evidence_refs : []).map((item) => String(item)).filter(Boolean);
240
+ const blockingSourceIdSet = new Set(blockingSourceIds);
241
+ const reopenTaskIds = (Array.isArray(adjudication.reopen_task_ids) ? adjudication.reopen_task_ids : []).map((item) => String(item)).filter(Boolean);
242
+ const missingLiveSourceGuidance = liveSourceGuidance
243
+ .map((source) => String(source.evidence_id ?? ""))
244
+ .filter(Boolean)
245
+ .filter((evidenceId) => !sourceEvidenceIdSet.has(evidenceId))
246
+ .sort();
247
+ if (missingLiveSourceGuidance.length > 0) {
248
+ reasons.push(reason("main_adjudication_invalid", `${adjudication._path}: request_changes main_adjudication must reference every live/pass source_guidance in source_evidence_refs: ${renderList(missingLiveSourceGuidance)}`, missingLiveSourceGuidance));
249
+ }
250
+ const unknownSourceRefs = sourceEvidenceIds.filter((evidenceId) => !liveSourceGuidanceById.has(evidenceId)).sort();
251
+ if (unknownSourceRefs.length > 0) {
252
+ reasons.push(reason("main_adjudication_invalid", `${adjudication._path}: request_changes source_evidence_refs must reference live/pass review_complete source_guidance evidence only: ${renderList(unknownSourceRefs)}`, unknownSourceRefs));
253
+ }
254
+ const blockerSourcesMissingFromBlockingRefs = [];
255
+ for (const source of liveSourceGuidance) {
256
+ const evidenceId = String(source.evidence_id ?? "");
257
+ if (!evidenceId)
258
+ continue;
259
+ if (blocking_findings(source).length === 0)
260
+ continue;
261
+ if (!blockingSourceIdSet.has(evidenceId))
262
+ blockerSourcesMissingFromBlockingRefs.push(evidenceId);
263
+ }
264
+ if (blockerSourcesMissingFromBlockingRefs.length > 0) {
265
+ reasons.push(reason("main_adjudication_invalid", `${adjudication._path}: request_changes_route='reopen_tasks' must include every source_guidance with blocking_findings in blocking_source_evidence_refs: ${renderList(blockerSourcesMissingFromBlockingRefs.sort())}`, blockerSourcesMissingFromBlockingRefs.sort()));
266
+ }
267
+ const blockingGuidance = [];
268
+ for (const evidenceId of blockingSourceIds) {
269
+ const source = liveSourceGuidanceById.get(evidenceId);
270
+ if (!source) {
271
+ reasons.push(reason("main_adjudication_invalid", `${adjudication._path}: blocking_source_evidence_refs must reference live/pass review_complete source_guidance evidence: ${evidenceId}`, [evidenceId]));
272
+ continue;
273
+ }
274
+ blockingGuidance.push(source);
275
+ }
276
+ const findingCoverage = new Map();
277
+ for (const source of blockingGuidance) {
278
+ const findings = blocking_findings(source);
279
+ if (findings.length === 0) {
280
+ reasons.push(reason("mixed_request_changes_route", `${adjudication._path}: request_changes_route='reopen_tasks' requires blocking findings on ${source._path}`));
281
+ continue;
282
+ }
283
+ if (source.agent_role !== "code-reviewer") {
284
+ reasons.push(reason("mixed_request_changes_route", `${adjudication._path}: request_changes_route='reopen_tasks' can only authorize task reopen from code-reviewer source_guidance, got ${repr(source.agent_role)} at ${source._path}`));
285
+ continue;
286
+ }
287
+ for (const finding of findings) {
288
+ const affected = string_set(finding.affected_task_ids);
289
+ if (affected === null) {
290
+ const findingId = typeof finding.finding_id === "string" && finding.finding_id ? finding.finding_id : "<unknown>";
291
+ reasons.push(reason("mixed_request_changes_route", `${adjudication._path}: request_changes_route='reopen_tasks' requires blocking finding ${repr(findingId)} from ${source._path} to provide non-empty affected_task_ids`));
292
+ continue;
293
+ }
294
+ for (const taskId of affected)
295
+ findingCoverage.set(taskId, (findingCoverage.get(taskId) ?? 0) + 1);
296
+ }
297
+ }
298
+ for (const taskId of reopenTaskIds) {
299
+ if ((findingCoverage.get(taskId) ?? 0) === 0) {
300
+ reasons.push(reason("main_adjudication_invalid", `${adjudication._path}: request_changes_route='reopen_tasks' requires reopen-compatible blocking findings covering task ${taskId}`, [taskId]));
301
+ }
302
+ }
303
+ }
304
+ return reasons;
305
+ }
306
+ function archive_ready_actions(change, reasons, review) {
307
+ const codes = reason_codes(reasons);
308
+ const inheritedReviewActions = !review.allowed ? review.next_allowed_actions : [];
309
+ return action_list(...inheritedReviewActions, !review.allowed && inheritedReviewActions.length === 0 ? `pass check-review-complete for ${change} first` : null, codes.has("missing_final_confirmation") ? "record archive_ready human_confirmation evidence before calling openspec archive -y" : null, codes.has("validate_failed") ? `fix openspec validate failures for ${change}` : null);
310
+ }
311
+ function default_gate_next_actions(gate) {
312
+ switch (gate) {
313
+ case "explore_complete":
314
+ return ["write .superspec/artifacts/discovery.md and record native_subagent critic evidence"];
315
+ case "proposal_reviewed":
316
+ return ["run the proposal critic review (round-tagged, findings[]) and record a main_review_digest disclosing every finding"];
317
+ case "design_complete":
318
+ return ["finish design.md, collect architect/critic/test-engineer evidence, and record design_complete human confirmation"];
319
+ case "invariants_reviewed":
320
+ return ["write .superspec/artifacts/business-invariants.md, collect critic/test-engineer evidence, and record required invariant confirmations"];
321
+ case "test_contract_drafted":
322
+ return ["write .superspec/artifacts/test-contract.md covering every Scenario and hard INV, then collect critic/test-engineer evidence"];
323
+ case "test_contract_honored":
324
+ return ["map every TEST-* and INV-* from test-contract into tasks.md and matching RED/GREEN evidence"];
325
+ case "tasks_complete":
326
+ return ["finish structured tasks.md metadata and resolve parallel_group/write_scope conflicts"];
327
+ case "propose_complete":
328
+ return ["pass explore_complete, proposal_reviewed, design_complete, invariants_reviewed, test_contract_drafted, and tasks_complete"];
329
+ default:
330
+ return [];
331
+ }
332
+ }
333
+ export function openspec_init_reasons(repoRoot) {
334
+ const reasons = [];
335
+ reasons.push(...runtime.openspec_cli_capability_reasons());
336
+ const skillsRoot = join(repoRoot, ".codex", "skills");
337
+ const missing = REQUIRED_OPENSPEC_CODEX_SKILLS.filter((name) => !existsSync(join(skillsRoot, name, "SKILL.md")));
338
+ if (missing.length > 0) {
339
+ reasons.push(reason("openspec_init_missing", "OpenSpec native Codex skills are missing; run `openspec init --tools codex .` or `openspec update --force .` from the repository root", missing));
340
+ }
341
+ for (const name of REQUIRED_OPENSPEC_CODEX_SKILLS) {
342
+ const skillPath = join(skillsRoot, name, "SKILL.md");
343
+ if (!existsSync(skillPath))
344
+ continue;
345
+ const declared = read_skill_frontmatter_name(skillPath);
346
+ if (declared !== name) {
347
+ reasons.push(reason("openspec_native_surface_invalid", `OpenSpec native skill ${skillPath} has invalid front matter name ${repr(declared)}`, [skillPath]));
348
+ }
349
+ }
350
+ return reasons;
351
+ }
352
+ // D4 (audit G-2): check-init must notice when SuperSpec's own workflow skills are missing or
353
+ // corrupted, otherwise a deleted .codex/skills/superspec-* surface stays invisible end to end.
354
+ export function superspec_workflow_skill_reasons(repoRoot) {
355
+ const reasons = [];
356
+ const skillsRoot = join(repoRoot, ".codex", "skills");
357
+ const missing = REQUIRED_SUPERSPEC_WORKFLOW_SKILLS.filter((name) => !existsSync(join(skillsRoot, name, "SKILL.md")));
358
+ if (missing.length > 0) {
359
+ reasons.push(reason("superspec_init_missing", "SuperSpec workflow skills are missing; run superspec init --scope project to (re)install them", missing));
360
+ }
361
+ for (const name of REQUIRED_SUPERSPEC_WORKFLOW_SKILLS) {
362
+ const skillPath = join(skillsRoot, name, "SKILL.md");
363
+ if (!existsSync(skillPath))
364
+ continue;
365
+ const declared = read_skill_frontmatter_name(skillPath);
366
+ if (declared !== name) {
367
+ reasons.push(reason("superspec_skill_invalid", `SuperSpec workflow skill ${skillPath} has invalid front matter name ${repr(declared)}`, [skillPath]));
368
+ }
369
+ }
370
+ return reasons;
371
+ }
372
+ export function superspec_agent_reasons(repoRoot) {
373
+ const reasons = [];
374
+ const agentsRoot = join(repoRoot, ".codex", "agents");
375
+ const promptsRoot = join(repoRoot, ".codex", "prompts");
376
+ const missingAgents = REQUIRED_SUPERSPEC_AGENT_ROLES.filter((name) => !existsSync(join(agentsRoot, `${name}.toml`)));
377
+ if (missingAgents.length > 0) {
378
+ reasons.push(reason("superspec_agent_missing", "repo-local superspec native agent definitions are missing; install .codex/agents/*.toml with the superspec distribution", missingAgents));
379
+ }
380
+ const missingPrompts = REQUIRED_SUPERSPEC_AGENT_ROLES.filter((name) => !existsSync(join(promptsRoot, `${name}.md`)));
381
+ if (missingPrompts.length > 0) {
382
+ reasons.push(reason("superspec_prompt_missing", "repo-local superspec role prompts are missing; install .codex/prompts/*.md with the superspec distribution", missingPrompts));
383
+ }
384
+ for (const name of REQUIRED_SUPERSPEC_AGENT_ROLES) {
385
+ const agentPath = join(agentsRoot, `${name}.toml`);
386
+ if (!existsSync(agentPath))
387
+ continue;
388
+ const declared = read_agent_toml_name(agentPath);
389
+ if (declared !== name) {
390
+ reasons.push(reason("superspec_agent_invalid", `superspec native agent ${agentPath} has invalid name ${repr(declared)}`, [agentPath]));
391
+ }
392
+ }
393
+ for (const name of REQUIRED_SUPERSPEC_AGENT_ROLES) {
394
+ const promptPath = join(promptsRoot, `${name}.md`);
395
+ if (!existsSync(promptPath))
396
+ continue;
397
+ if (!readFileSync(promptPath, "utf8").trim()) {
398
+ reasons.push(reason("superspec_prompt_invalid", `superspec role prompt ${promptPath} is empty`, [promptPath]));
399
+ }
400
+ }
401
+ return reasons;
402
+ }
403
+ export function openspec_cli_capability_reasons() {
404
+ if (!commandExists("openspec"))
405
+ return [reason("openspec_cli_unavailable", "openspec CLI is not available in PATH")];
406
+ const problems = [];
407
+ for (const args of REQUIRED_OPENSPEC_CLI_SURFACES) {
408
+ const proc = runCommand("openspec", [...args], { timeout: 15_000 });
409
+ if (proc.error) {
410
+ problems.push(reason("openspec_native_surface_missing", `\`openspec ${args.join(" ")}\` failed: ${proc.error.message}`));
411
+ }
412
+ else if (proc.status !== 0) {
413
+ problems.push(reason("openspec_native_surface_missing", `\`openspec ${args.join(" ")}\` failed: ${(proc.stderr || proc.stdout).trim()}`));
414
+ }
415
+ }
416
+ return problems;
417
+ }
418
+ export function evidence_schema_guard(change, changeRoot, repoRoot, evidences) {
419
+ return [
420
+ ...evidences.flatMap((ev) => validate_evidence_schema(ev, change, changeRoot, repoRoot)),
421
+ ...duplicate_evidence_id_reasons(evidences),
422
+ ...dangling_evidence_ref_reasons(evidences),
423
+ ...supersede_reasons(evidences),
424
+ ...request_changes_round_reasons(evidences),
425
+ ];
426
+ }
427
+ export function check_init(change, status, repoRoot, changeRoot) {
428
+ const gate = "init";
429
+ const amap = artifact_status_map(status);
430
+ const reasons = [];
431
+ reasons.push(...runtime.openspec_init_reasons(repoRoot));
432
+ reasons.push(...runtime.superspec_agent_reasons(repoRoot));
433
+ reasons.push(...runtime.superspec_workflow_skill_reasons(repoRoot));
434
+ let schemaName = status.schemaName ?? status.schema;
435
+ if (isObject(schemaName))
436
+ schemaName = schemaName.name;
437
+ if (schemaName && schemaName !== "spec-driven") {
438
+ reasons.push(reason("non_default_openspec_schema", `superspec v1 expects OpenSpec default spec-driven schema, got ${repr(schemaName)}`));
439
+ }
440
+ const missing = [...OPENSPEC_ARTIFACTS].filter((item) => !(item in amap)).sort();
441
+ const unexpected = Object.keys(amap).filter((item) => !OPENSPEC_ARTIFACTS.has(item)).sort();
442
+ if (missing.length > 0)
443
+ reasons.push(reason("missing_openspec_artifacts", `OpenSpec status missing artifacts: ${renderList(missing)}`));
444
+ if (unexpected.length > 0)
445
+ reasons.push(reason("unexpected_openspec_artifacts", `superspec v1 uses default OpenSpec artifacts only; found: ${renderList(unexpected)}`));
446
+ if (!Array.isArray(status.applyRequires) || !new Set(status.applyRequires).has("tasks")) {
447
+ reasons.push(reason("unexpected_apply_requires", "OpenSpec applyRequires must include native tasks artifact"));
448
+ }
449
+ if (existsSync(join(repoRoot, ".codex", "hooks.json")))
450
+ reasons.push(reason("v1_hook_artifact_present", ".codex/hooks.json belongs to superspec v2"));
451
+ if (existsSync(join(repoRoot, "openspec", "schemas", "superspec")))
452
+ reasons.push(reason("custom_superspec_schema_present", "openspec/schemas/superspec is not part of superspec v1 overlay"));
453
+ if (reasons.length > 0)
454
+ return block(change, gate, reasons, { openspec_summary: amap });
455
+ return allow(change, gate, { openspec_summary: amap, gate_summary: { sidecar_root: ".superspec" } });
456
+ }
457
+ // FIX-4 (audit C-1): role review evidence must pin the current blob of the gate's target artifact,
458
+ // mirroring the invariants_reviewed current-blob check.
459
+ function stale_artifact_review_reasons(reviews, changeRoot, artifactRel, code, gateName) {
460
+ const artifactPath = join(changeRoot, artifactRel);
461
+ const artifactSha = existsSync(artifactPath) && statSync(artifactPath).isFile() ? runtime.file_blob_sha(artifactPath) : "";
462
+ const reasons = [];
463
+ for (const ev of reviews.filter((item) => item.agent_role)) {
464
+ const targets = Array.isArray(ev.target_refs) ? ev.target_refs : [];
465
+ const hasCurrentTarget = targets.some((item) => isObject(item) && item.path === artifactRel && item.blob_sha === artifactSha);
466
+ if (!hasCurrentTarget) {
467
+ reasons.push(reason(code, `${ev._path ?? ev.evidence_id}: ${gateName} evidence must target current ${artifactRel}`));
468
+ }
469
+ }
470
+ return reasons;
471
+ }
472
+ export function check_superspec_gate(change, status, changeRoot, evidences, gateRaw) {
473
+ const gate = normalize_gate(gateRaw);
474
+ const amap = artifact_status_map(status);
475
+ const reasons = [];
476
+ if (gate === "explore_complete") {
477
+ const discovery = sidecar_discovery_path(changeRoot);
478
+ if (!existsSync(discovery) || !statSync(discovery).isFile() || !readFileSync(discovery, "utf8").trim())
479
+ reasons.push(reason("missing_discovery", "sidecar .superspec/artifacts/discovery.md missing or empty"));
480
+ const exploreReviews = live_pass(evidences, { gate: "explore_complete" });
481
+ for (const role of ["critic"]) {
482
+ if (!exploreReviews.some((ev) => ev.agent_role === role))
483
+ reasons.push(reason("missing_native_subagent_evidence", `explore_complete requires native_subagent ${role} report`));
484
+ }
485
+ reasons.push(...stale_artifact_review_reasons(exploreReviews, changeRoot, ".superspec/artifacts/discovery.md", "stale_explore_review", "explore_complete"));
486
+ // DISC Phase 1: material findings raised by explore reviews must be disclosed to the user
487
+ // (main_review_digest + user_review_decision) before the gate can pass.
488
+ reasons.push(...review_disclosure_reasons("explore_complete", changeRoot, evidences));
489
+ }
490
+ else if (gate === "proposal_reviewed") {
491
+ // DISC Phase 2: proposal review is an internal gate, not an advisory note. It is born inside
492
+ // the disclosure loop, so round-tagged critic evidence + digest are unconditionally required.
493
+ const explore = check_superspec_gate(change, status, changeRoot, evidences, "explore_complete");
494
+ if (!explore.allowed) {
495
+ reasons.push(reason("explore_complete_failed", "proposal_reviewed requires explore_complete"));
496
+ reasons.push(...explore.block_reasons);
497
+ }
498
+ if (!is_done(status, "proposal"))
499
+ reasons.push(reason("missing_proposal", "proposal not done in OpenSpec"));
500
+ const proposalReviews = live_pass(evidences, { gate: "proposal_reviewed" });
501
+ if (!proposalReviews.some((ev) => ev.agent_role === "critic"))
502
+ reasons.push(reason("missing_proposal_review", "proposal_reviewed requires native_subagent critic report"));
503
+ reasons.push(...review_disclosure_reasons("proposal_reviewed", changeRoot, evidences));
504
+ }
505
+ else if (gate === "design_complete") {
506
+ const proposal = check_superspec_gate(change, status, changeRoot, evidences, "proposal_reviewed");
507
+ if (!proposal.allowed) {
508
+ reasons.push(reason("proposal_reviewed_failed", "design_complete requires proposal_reviewed"));
509
+ reasons.push(...proposal.block_reasons);
510
+ }
511
+ if (!is_done(status, "design"))
512
+ reasons.push(reason("missing_design", "design not done in OpenSpec"));
513
+ const designReviews = live_pass(evidences, { gate: "design_complete" });
514
+ for (const role of ["architect", "critic", "test-engineer"]) {
515
+ if (!designReviews.some((ev) => ev.agent_role === role))
516
+ reasons.push(reason(`missing_${role}_review`, `design_complete requires native_subagent ${role} report`));
517
+ }
518
+ if (live_pass(evidences, { kind: "human_confirmation", gate: "design_complete" }).length === 0)
519
+ reasons.push(reason("missing_human_confirmation", "design_complete requires human confirmation"));
520
+ reasons.push(...stale_artifact_review_reasons(designReviews, changeRoot, "design.md", "stale_design_review", "design_complete"));
521
+ // DISC Phase 2: design reviews carrying round-tagged findings enter the disclosure loop
522
+ // (legacy design evidence stays grandfathered, P2-3).
523
+ reasons.push(...review_disclosure_reasons("design_complete", changeRoot, evidences));
524
+ }
525
+ else if (gate === "invariants_reviewed") {
526
+ const design = check_superspec_gate(change, status, changeRoot, evidences, "design_complete");
527
+ if (!design.allowed) {
528
+ reasons.push(reason("design_complete_failed", "invariants_reviewed requires design_complete"));
529
+ reasons.push(...design.block_reasons);
530
+ }
531
+ reasons.push(...business_invariant_validation_reasons(changeRoot));
532
+ const invariantReviews = live_pass(evidences, { gate: "invariants_reviewed" });
533
+ const roles = new Set(invariantReviews.map((ev) => ev.agent_role));
534
+ for (const need of ["critic", "test-engineer"]) {
535
+ if (!roles.has(need))
536
+ reasons.push(reason("missing_invariant_review", `invariants_reviewed requires native_subagent ${need} review`));
537
+ }
538
+ if (invariantReviews.length === 0)
539
+ reasons.push(reason("missing_invariant_review", "invariants_reviewed requires passing review evidence"));
540
+ reasons.push(...stale_artifact_review_reasons(invariantReviews, changeRoot, ".superspec/artifacts/business-invariants.md", "stale_invariant_review", "invariants_reviewed"));
541
+ const humanRequired = human_confirmation_business_invariant_ids(changeRoot);
542
+ if (humanRequired.size > 0) {
543
+ const confirmed = new Set();
544
+ for (const ev of live_pass(evidences, { gate: "invariants_reviewed", kind: "human_confirmation" })) {
545
+ for (const id of evidence_invariant_refs(ev))
546
+ confirmed.add(id);
547
+ }
548
+ const missing = [...humanRequired].filter((id) => !confirmed.has(id)).sort();
549
+ if (missing.length > 0)
550
+ reasons.push(reason("missing_human_confirmation", `human-confirmation invariants require explicit confirmation evidence: ${renderList(missing)}`));
551
+ }
552
+ // DISC Phase 3: round-tagged invariant reviews enter the disclosure loop (legacy stays grandfathered).
553
+ reasons.push(...review_disclosure_reasons("invariants_reviewed", changeRoot, evidences));
554
+ }
555
+ else if (gate === "test_contract_drafted") {
556
+ if (!is_done(status, "design"))
557
+ reasons.push(reason("missing_design", "test_contract_drafted requires design done in OpenSpec"));
558
+ const invariants = check_superspec_gate(change, status, changeRoot, evidences, "invariants_reviewed");
559
+ if (!invariants.allowed) {
560
+ reasons.push(reason("invariants_not_reviewed", "test_contract_drafted requires invariants_reviewed"));
561
+ reasons.push(...invariants.block_reasons);
562
+ }
563
+ if (!existsSync(sidecar_test_contract_path(changeRoot))) {
564
+ reasons.push(reason("missing_test_contract", "sidecar .superspec/artifacts/test-contract.md missing"));
565
+ }
566
+ else {
567
+ if (parse_test_contract_ids(changeRoot).size === 0)
568
+ reasons.push(reason("missing_coverage_matrix", "test-contract has no TEST-* entries"));
569
+ const missingScenarios = parse_spec_scenarios(changeRoot).filter((scenario) => !test_contract_covers_scenario(changeRoot, scenario));
570
+ if (missingScenarios.length > 0)
571
+ reasons.push(reason("missing_coverage_matrix", `test-contract missing Scenario coverage: ${renderList(missingScenarios)}`));
572
+ const contractInvariantIds = test_contract_invariant_ids(changeRoot);
573
+ const missingInvariants = [...automated_hard_business_invariant_ids(changeRoot)].filter((id) => !contractInvariantIds.has(id)).sort();
574
+ if (missingInvariants.length > 0)
575
+ reasons.push(reason("invariant_not_honored", `test-contract missing hard business invariants: ${renderList(missingInvariants)}`));
576
+ const postImplementationInvariants = post_implementation_business_invariant_ids(changeRoot);
577
+ const postImplementationMapped = [...contractInvariantIds].filter((id) => postImplementationInvariants.has(id)).sort();
578
+ if (postImplementationMapped.length > 0)
579
+ reasons.push(reason("post_implementation_invariant_backfill", `test-contract cannot map created_after_implementation invariants: ${renderList(postImplementationMapped)}`));
580
+ }
581
+ const draftedReviews = live_pass(evidences, { gate: "test_contract_drafted" });
582
+ const roles = new Set(draftedReviews.map((ev) => ev.agent_role));
583
+ for (const need of ["test-engineer", "critic"]) {
584
+ if (!roles.has(need))
585
+ reasons.push(reason("missing_test_contract_review", `test_contract_drafted requires native_subagent ${need} review`));
586
+ }
587
+ if (draftedReviews.length === 0)
588
+ reasons.push(reason("missing_test_contract_review", "test_contract_drafted requires passing review evidence"));
589
+ reasons.push(...stale_artifact_review_reasons(draftedReviews, changeRoot, ".superspec/artifacts/test-contract.md", "stale_test_contract_review", "test_contract_drafted"));
590
+ // DISC Phase 3: round-tagged test-contract reviews enter the disclosure loop (legacy stays grandfathered).
591
+ reasons.push(...review_disclosure_reasons("test_contract_drafted", changeRoot, evidences));
592
+ }
593
+ else if (gate === "test_contract_honored") {
594
+ const drafted = check_superspec_gate(change, status, changeRoot, evidences, "test_contract_drafted");
595
+ if (!drafted.allowed) {
596
+ reasons.push(reason("test_contract_drafted_failed", "test_contract_honored requires test_contract_drafted"));
597
+ reasons.push(...drafted.block_reasons);
598
+ }
599
+ if (!is_done(status, "tasks"))
600
+ reasons.push(reason("missing_tasks", "test_contract_honored requires tasks done in OpenSpec"));
601
+ const contractIds = parse_test_contract_ids(changeRoot);
602
+ if (contractIds.size === 0)
603
+ reasons.push(reason("missing_test_contract", "test-contract missing or has no TEST-* entries"));
604
+ const validInvariantIds = business_invariant_ids(changeRoot);
605
+ const contractInvariantIds = test_contract_invariant_ids(changeRoot);
606
+ const unknownContractInvariants = [...contractInvariantIds].filter((item) => !validInvariantIds.has(item)).sort();
607
+ if (unknownContractInvariants.length > 0)
608
+ reasons.push(reason("invalid_invariant_ref", `test-contract references unknown business invariant: ${renderList(unknownContractInvariants)}`));
609
+ const tasks = parse_tasks(changeRoot);
610
+ const refs = task_test_refs(tasks);
611
+ const missing = [...contractIds].filter((item) => !refs.has(item)).sort();
612
+ if (missing.length > 0)
613
+ reasons.push(reason("missing_task_test_refs", `test-contract ids not mapped by tasks.md test_refs: ${renderList(missing)}`));
614
+ for (const record of parse_test_contract_records(changeRoot)) {
615
+ const mappedTaskInvariantRefs = new Set();
616
+ for (const task of Object.values(tasks)) {
617
+ if (!splitList(task.attrs.test_refs ?? "").includes(record.test_id))
618
+ continue;
619
+ for (const inv of splitList(task.attrs.invariant_refs ?? ""))
620
+ mappedTaskInvariantRefs.add(inv);
621
+ }
622
+ const missingInvariantRefs = record.invariant_refs.filter((item) => !mappedTaskInvariantRefs.has(item)).sort();
623
+ if (missingInvariantRefs.length > 0) {
624
+ reasons.push(reason("missing_task_invariant_refs", `test-contract ${record.test_id} invariant ids not mapped by matching tasks.md invariant_refs: ${renderList(missingInvariantRefs)}`));
625
+ }
626
+ }
627
+ const unknownEvidence = [...red_green_test_ids(evidences)].filter((item) => !contractIds.has(item)).sort();
628
+ if (unknownEvidence.length > 0)
629
+ reasons.push(reason("test_contract_not_honored", `RED/GREEN evidence references unknown test_id: ${renderList(unknownEvidence)}`));
630
+ const unknownEvidenceInvariants = [...red_green_invariant_ids(evidences)].filter((item) => !validInvariantIds.has(item)).sort();
631
+ if (unknownEvidenceInvariants.length > 0)
632
+ reasons.push(reason("invalid_invariant_ref", `RED/GREEN evidence references unknown invariant_id: ${renderList(unknownEvidenceInvariants)}`));
633
+ reasons.push(...evidence_test_contract_invariant_reasons(live_pass(evidences, { kind: "test_run" }), "*", test_contract_invariant_refs_by_test(changeRoot), "RED/GREEN"));
634
+ }
635
+ else if (gate === "tasks_complete") {
636
+ if (!is_done(status, "tasks"))
637
+ reasons.push(reason("missing_tasks", "tasks not done in OpenSpec"));
638
+ const honored = check_superspec_gate(change, status, changeRoot, evidences, "test_contract_honored");
639
+ if (!honored.allowed)
640
+ reasons.push(reason("test_contract_not_honored", "tasks_complete requires test_contract_honored"));
641
+ const tasks = parse_tasks(changeRoot);
642
+ if (Object.keys(tasks).length === 0)
643
+ reasons.push(reason("invalid_task_graph", "tasks.md has no structured tasks"));
644
+ reasons.push(...write_scope_conflict_reasons(tasks));
645
+ // DISC Phase 3: tasks_complete enters the disclosure loop only once round-tagged review
646
+ // evidence appears (design: no mandatory role review on this gate until then).
647
+ reasons.push(...review_disclosure_reasons("tasks_complete", changeRoot, evidences));
648
+ }
649
+ else if (gate === "propose_complete") {
650
+ for (const art of ["proposal", "specs", "design", "tasks"]) {
651
+ if (!is_done(status, art))
652
+ reasons.push(reason(`missing_${art}`, `${art} not done in OpenSpec`));
653
+ }
654
+ for (const subgate of ["explore_complete", "proposal_reviewed", "design_complete", "invariants_reviewed", "test_contract_drafted", "tasks_complete"]) {
655
+ const sub = check_superspec_gate(change, status, changeRoot, evidences, subgate);
656
+ if (!sub.allowed) {
657
+ reasons.push(reason(`${subgate}_failed`, `propose_complete requires passing internal gate '${subgate}'`));
658
+ reasons.push(...sub.block_reasons);
659
+ }
660
+ }
661
+ }
662
+ else {
663
+ return block(change, gate, [reason("unknown_gate", `unknown superspec gate: ${gate}`)]);
664
+ }
665
+ // FIX-11: per-gate output_ref dedup + cross-gate reuse scope contract for propose-phase reviews.
666
+ reasons.push(...propose_review_output_ref_reasons(changeRoot, evidences, gate));
667
+ if (reasons.length > 0)
668
+ return block(change, gate, reasons, { openspec_summary: amap, next_actions: default_gate_next_actions(gate) });
669
+ return allow(change, gate, { openspec_summary: amap });
670
+ }
671
+ export function check_artifact(change, status, changeRoot, evidences, artifact) {
672
+ const amap = artifact_status_map(status);
673
+ if (!OPENSPEC_ARTIFACTS.has(artifact))
674
+ return block(change, `${artifact}_enter`, [reason("not_openspec_artifact", `${artifact} is not an OpenSpec artifact; use check-enter for superspec gates`)]);
675
+ if (!(artifact in amap))
676
+ return block(change, `${artifact}_enter`, [reason("unknown_artifact", `${artifact} not in openspec status artifacts`)]);
677
+ const reasons = [];
678
+ if (amap[artifact] === "blocked") {
679
+ const found = (status.artifacts ?? []).find((item) => item.id === artifact);
680
+ const missing = found?.missingDeps ?? [];
681
+ reasons.push(reason("openspec_blocked", `${artifact} blocked by: ${renderList(missing)}`, missing));
682
+ }
683
+ const enterGate = ARTIFACT_ENTER_GATE[artifact];
684
+ const gateDecision = enterGate ? check_superspec_gate(change, status, changeRoot, evidences, enterGate) : allow(change, "no_enter_gate");
685
+ if (enterGate && !gateDecision.allowed) {
686
+ reasons.push(reason(`${enterGate}_failed`, `entering ${artifact} requires passing superspec gate '${enterGate}'`));
687
+ reasons.push(...gateDecision.block_reasons);
688
+ }
689
+ const gate = `${artifact}_enter`;
690
+ if (reasons.length > 0)
691
+ return block(change, gate, reasons, { openspec_summary: amap, next_actions: [enterGate ? `satisfy deps then record '${enterGate}' pass evidence` : "complete required OpenSpec dependencies"] });
692
+ return allow(change, gate, { openspec_summary: amap });
693
+ }
694
+ function escapeRegExp(text) {
695
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
696
+ }
697
+ function rewrite_task_checkbox_text(text, taskId, fromChecked, toChecked) {
698
+ const pattern = new RegExp(`^(\\- \\[)( |x|X)(\\]\\s+${escapeRegExp(taskId)}(?:\\s.*)?)$`, "m");
699
+ const match = text.match(pattern);
700
+ if (!match)
701
+ return null;
702
+ const currentChecked = String(match[2]).toLowerCase() === "x";
703
+ if (currentChecked !== fromChecked)
704
+ return null;
705
+ return text.replace(pattern, `$1${toChecked ? "x" : " "}$3`);
706
+ }
707
+ function task_reopen_history(evidences, taskId) {
708
+ return {
709
+ pass: pass_task_reopens(evidences, taskId),
710
+ live: live_task_reopens(evidences, taskId),
711
+ liveResolved: live_task_reopen_resolutions(evidences, taskId),
712
+ unresolvedLive: unresolved_live_task_reopens(evidences, taskId),
713
+ };
714
+ }
715
+ function task_reopen_match_key(ev) {
716
+ return `${String(ev.evidence_id ?? "")}\u0000${String(ev.reopen_id ?? "")}\u0000${String(ev.task_id ?? "")}`;
717
+ }
718
+ function matching_reopen_resolutions(evidences, reopen) {
719
+ const key = task_reopen_match_key(reopen);
720
+ return live_task_reopen_resolutions(evidences)
721
+ .filter((resolution) => `${String(resolution.reopen_evidence_id ?? "")}\u0000${String(resolution.reopen_id ?? "")}\u0000${String(resolution.task_id ?? "")}` === key);
722
+ }
723
+ function task_reopen_global_lifecycle_reasons(changeRoot, evidences) {
724
+ const reasons = [];
725
+ const tasks = parse_tasks(changeRoot);
726
+ const passReopens = pass_task_reopens(evidences);
727
+ const reopensByTask = new Map();
728
+ for (const reopen of passReopens) {
729
+ const taskId = String(reopen.task_id ?? "");
730
+ if (!taskId)
731
+ continue;
732
+ const items = reopensByTask.get(taskId) ?? [];
733
+ items.push(reopen);
734
+ reopensByTask.set(taskId, items);
735
+ }
736
+ for (const [taskId, reopens] of reopensByTask.entries()) {
737
+ if (reopens.length > 1) {
738
+ reasons.push(reason("reopen_lifecycle_exhausted", `task ${taskId} has multiple task_reopen histories in this change; v1 allows at most one`, [taskId]));
739
+ }
740
+ const task = tasks[taskId];
741
+ if (!task) {
742
+ reasons.push(reason("unknown_task", `task_reopen lifecycle references task ${taskId}, but task is not present in tasks.md`, [taskId]));
743
+ continue;
744
+ }
745
+ if (!task.checked) {
746
+ reasons.push(reason("unresolved_task_reopen", `task ${taskId} is reopened and must be checked again before review_ready`, [taskId]));
747
+ }
748
+ for (const reopen of reopens) {
749
+ if (matching_reopen_resolutions(evidences, reopen).length === 0) {
750
+ reasons.push(reason("unresolved_task_reopen", `task ${taskId} has task_reopen without matching live task_reopen_resolved`, [taskId]));
751
+ }
752
+ }
753
+ reasons.push(...task_reopen_resolution_reasons(changeRoot, evidences, taskId));
754
+ }
755
+ return reasons;
756
+ }
757
+ function active_task_reopen_reasons(changeRoot, evidences, task, taskId, phase) {
758
+ const reasons = [];
759
+ const history = task_reopen_history(evidences, taskId);
760
+ if (history.pass.length > 1)
761
+ reasons.push(reason("reopen_lifecycle_exhausted", `task ${taskId} already has reopen history in this change and cannot reopen again in v1`));
762
+ if (history.unresolvedLive.length === 0) {
763
+ reasons.push(reason("missing_task_reopen", `task ${taskId} requires exactly one unresolved task_reopen evidence`));
764
+ return { reopen: null, reasons };
765
+ }
766
+ if (history.unresolvedLive.length > 1) {
767
+ reasons.push(reason("ambiguous_task_reopen", `task ${taskId} has multiple unresolved task_reopen evidences`));
768
+ return { reopen: null, reasons };
769
+ }
770
+ const reopen = history.unresolvedLive[0];
771
+ const liveById = new Map(live_pass(evidences).map((ev) => [String(ev.evidence_id), ev]));
772
+ const liveReviewMainAdjudicationById = new Map(live_pass(evidences, { gate: "review_complete", kind: "main_adjudication" }).map((ev) => [String(ev.evidence_id), ev]));
773
+ const liveReviewSourceGuidanceById = new Map(live_pass(evidences, { gate: "review_complete", kind: "source_guidance" }).map((ev) => [String(ev.evidence_id), ev]));
774
+ const sourceAdjudicationId = String(reopen.source_adjudication_evidence_id ?? "");
775
+ const sourceGuidanceId = String(reopen.source_guidance_evidence_id ?? "");
776
+ const sourceAdjudication = liveReviewMainAdjudicationById.get(sourceAdjudicationId);
777
+ const sourceGuidance = liveReviewSourceGuidanceById.get(sourceGuidanceId);
778
+ const violatedTests = Array.isArray(reopen.violated_test_ids) ? reopen.violated_test_ids.map((item) => String(item)) : [];
779
+ const violatedRequirements = Array.isArray(reopen.violated_requirement_refs) ? reopen.violated_requirement_refs.map((item) => String(item)) : [];
780
+ if (!sourceAdjudication) {
781
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source_adjudication_evidence_id must reference live/pass review_complete main_adjudication`));
782
+ }
783
+ else {
784
+ const decision = String(sourceAdjudication.review_decision ?? "");
785
+ if (!MAIN_ADJUDICATION_DECISIONS.includes(decision)) {
786
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source main_adjudication has unsupported review_decision=${repr(decision)}`));
787
+ }
788
+ if (decision !== "request_changes") {
789
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source main_adjudication must carry review_decision='request_changes'`));
790
+ }
791
+ const route = String(sourceAdjudication.request_changes_route ?? "");
792
+ if (!REQUEST_CHANGES_ROUTES.includes(route)) {
793
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source main_adjudication has unsupported request_changes_route=${repr(route)}`));
794
+ }
795
+ if (route !== "reopen_tasks") {
796
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source main_adjudication must carry request_changes_route='reopen_tasks'`));
797
+ }
798
+ const sourceEvidenceRefs = new Set((Array.isArray(sourceAdjudication.source_evidence_refs) ? sourceAdjudication.source_evidence_refs : []).map((item) => String(item)));
799
+ if (!sourceEvidenceRefs.has(sourceGuidanceId)) {
800
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source main_adjudication must reference source_guidance_evidence_id`));
801
+ }
802
+ const blockingRefs = new Set((Array.isArray(sourceAdjudication.blocking_source_evidence_refs) ? sourceAdjudication.blocking_source_evidence_refs : []).map((item) => String(item)));
803
+ if (!blockingRefs.has(sourceGuidanceId)) {
804
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source main_adjudication must include source_guidance_evidence_id in blocking_source_evidence_refs`));
805
+ }
806
+ const reopenTaskIds = new Set((Array.isArray(sourceAdjudication.reopen_task_ids) ? sourceAdjudication.reopen_task_ids : []).map((item) => String(item)));
807
+ if (!reopenTaskIds.has(taskId)) {
808
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source main_adjudication must include task in reopen_task_ids`));
809
+ }
810
+ }
811
+ if (!sourceGuidance || sourceGuidance.agent_role !== "code-reviewer") {
812
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source_guidance_evidence_id must reference live/pass review_complete code-reviewer source_guidance`));
813
+ }
814
+ else {
815
+ const matchedBlockingFindings = blocking_findings(sourceGuidance)
816
+ .filter((item) => (string_set(item.affected_task_ids) ?? []).includes(taskId));
817
+ if (matchedBlockingFindings.length === 0) {
818
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source_guidance_evidence_id must include a blocking finding whose affected_task_ids covers the task`));
819
+ }
820
+ }
821
+ const declaredTests = new Set(splitList(task.attrs.test_refs ?? ""));
822
+ const invalidTests = violatedTests.filter((item) => !declaredTests.has(item)).sort();
823
+ if (invalidTests.length > 0) {
824
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: violated_test_ids must be drawn from task test_refs: ${renderList(invalidTests)}`, invalidTests));
825
+ }
826
+ const declaredRequirements = new Set(splitList(task.attrs.requirement_refs ?? ""));
827
+ const invalidRequirements = violatedRequirements.filter((item) => !declaredRequirements.has(item)).sort();
828
+ if (invalidRequirements.length > 0) {
829
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: violated_requirement_refs must be drawn from task requirement_refs: ${renderList(invalidRequirements)}`, invalidRequirements));
830
+ }
831
+ if (reopen.scope_expansion !== false) {
832
+ reasons.push(reason("scope_expansion_requires_propose", `task ${taskId}: scope_expansion=true cannot use task_reopen`));
833
+ }
834
+ const requiredSupersedes = Array.isArray(reopen.required_supersede_evidence_ids)
835
+ ? reopen.required_supersede_evidence_ids.map((item) => String(item))
836
+ : [];
837
+ const liveConflicts = requiredSupersedes.filter((item) => liveById.has(item)).sort();
838
+ if (liveConflicts.length > 0) {
839
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: required_supersede_evidence_ids must already be out of live/pass: ${renderList(liveConflicts)}`, liveConflicts));
840
+ }
841
+ if (requiredSupersedes.includes(sourceAdjudicationId) || requiredSupersedes.includes(sourceGuidanceId)) {
842
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: source evidence ids must not be listed in required_supersede_evidence_ids`));
843
+ }
844
+ const tasksPath = join(changeRoot, "tasks.md");
845
+ const tasksText = existsSync(tasksPath) && statSync(tasksPath).isFile() ? readFileSync(tasksPath, "utf8") : "";
846
+ const currentHash = sha256_text(tasksText);
847
+ if (phase === "pre_revert") {
848
+ if (currentHash !== reopen.before_tasks_sha256) {
849
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: current tasks.md hash must match before_tasks_sha256 before authorized revert`));
850
+ }
851
+ const revertedText = rewrite_task_checkbox_text(tasksText, taskId, true, false);
852
+ if (revertedText === null) {
853
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: tasks.md must be transformable by only switching the task checkbox from [x] to [ ]`));
854
+ }
855
+ else if (sha256_text(revertedText) !== reopen.after_tasks_sha256) {
856
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: after_tasks_sha256 must equal the raw-text hash after the authorized [x] -> [ ] revert`));
857
+ }
858
+ }
859
+ else {
860
+ if (currentHash !== reopen.after_tasks_sha256) {
861
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: current tasks.md hash must match after_tasks_sha256 during reopened apply`));
862
+ }
863
+ const restoredText = rewrite_task_checkbox_text(tasksText, taskId, false, true);
864
+ if (restoredText === null) {
865
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: reopened tasks.md must be reversible by only switching the task checkbox from [ ] to [x]`));
866
+ }
867
+ else if (sha256_text(restoredText) !== reopen.before_tasks_sha256) {
868
+ reasons.push(reason("task_reopen_invalid", `task ${taskId}: before_tasks_sha256 must equal the raw-text hash after restoring the task checkbox to [x]`));
869
+ }
870
+ }
871
+ return { reopen, reasons };
872
+ }
873
+ function task_reopen_apply_state(changeRoot, evidences, task, taskId) {
874
+ const history = task_reopen_history(evidences, taskId);
875
+ const liveReviewAdjudications = live_pass(evidences, { gate: "review_complete", kind: "main_adjudication" });
876
+ if (history.unresolvedLive.length > 0) {
877
+ const reopenCheck = active_task_reopen_reasons(changeRoot, evidences, task, taskId, "post_revert");
878
+ if (reopenCheck.reasons.length > 0 || !reopenCheck.reopen)
879
+ return { mode: "blocked", reasons: reopenCheck.reasons };
880
+ return { mode: "reopened", reopen: reopenCheck.reopen };
881
+ }
882
+ if (history.pass.length > 0) {
883
+ return { mode: "blocked", reasons: [reason("missing_task_reopen", `task ${taskId} was reopened in this change and cannot continue unchecked without one live unresolved task_reopen`)] };
884
+ }
885
+ const liveReopenRequests = live_request_changes_reopen_adjudications_for_task(evidences, taskId);
886
+ if (liveReopenRequests.length > 0) {
887
+ return { mode: "blocked", reasons: [reason("missing_task_reopen", `task ${taskId} is referenced by request_changes(reopen_tasks) but has no live unresolved task_reopen`)] };
888
+ }
889
+ const liveChangeUpdateRequests = liveReviewAdjudications.filter((ev) => ev.review_decision === "request_changes" && ev.request_changes_route === "change_update");
890
+ if (liveChangeUpdateRequests.length > 0) {
891
+ return { mode: "blocked", reasons: [reason("change_update_required", `task ${taskId}: live request_changes(change_update) requires returning to propose/change update before apply`)] };
892
+ }
893
+ if (liveReviewAdjudications.length > 0) {
894
+ return { mode: "blocked", reasons: [reason("missing_task_reopen", `task ${taskId} cannot continue unchecked after review without one live unresolved task_reopen`)] };
895
+ }
896
+ return { mode: "ordinary" };
897
+ }
898
+ function task_reopen_resolution_reasons(changeRoot, evidences, taskId) {
899
+ const reasons = [];
900
+ const tasks = parse_tasks(changeRoot);
901
+ const task = tasks[taskId];
902
+ const liveById = new Map(live_pass(evidences).map((ev) => [String(ev.evidence_id ?? ""), ev]));
903
+ const passReopensById = new Map(pass_task_reopens(evidences).map((ev) => [String(ev.evidence_id ?? ""), ev]));
904
+ const resolutions = live_task_reopen_resolutions(evidences, taskId);
905
+ if (resolutions.length === 0)
906
+ return reasons;
907
+ const tasksPath = join(changeRoot, "tasks.md");
908
+ const tasksText = existsSync(tasksPath) && statSync(tasksPath).isFile() ? readFileSync(tasksPath, "utf8") : "";
909
+ const currentTasksHash = sha256_text(tasksText);
910
+ for (const resolution of resolutions) {
911
+ const resolutionPath = String(resolution._path ?? "task_reopen_resolved evidence");
912
+ const reopenEvidenceId = String(resolution.reopen_evidence_id ?? "");
913
+ const reopen = passReopensById.get(reopenEvidenceId);
914
+ if (!reopen || reopen.kind !== "task_reopen") {
915
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: reopen_evidence_id must reference pass task_reopen evidence`, [reopenEvidenceId]));
916
+ continue;
917
+ }
918
+ const reopenTaskId = String(reopen.task_id ?? "");
919
+ const reopenId = String(reopen.reopen_id ?? "");
920
+ const resolutionTaskId = String(resolution.task_id ?? "");
921
+ const resolutionReopenId = String(resolution.reopen_id ?? "");
922
+ if (resolutionTaskId !== reopenTaskId) {
923
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: task_id must match referenced task_reopen task_id=${repr(reopenTaskId)}`, [resolutionTaskId, reopenTaskId]));
924
+ }
925
+ if (resolutionReopenId !== reopenId) {
926
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: reopen_id must match referenced task_reopen reopen_id=${repr(reopenId)}`, [resolutionReopenId, reopenId]));
927
+ }
928
+ if (reopenTaskId !== taskId) {
929
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: referenced task_reopen belongs to ${reopenTaskId}, not ${taskId}`, [reopenTaskId, taskId]));
930
+ continue;
931
+ }
932
+ if (!task) {
933
+ reasons.push(reason("unknown_task", `${resolutionPath}: task ${taskId} not found in tasks.md`, [taskId]));
934
+ continue;
935
+ }
936
+ if (!task.checked) {
937
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: task ${taskId} must be checked before task_reopen_resolved can close reopen`, [taskId]));
938
+ }
939
+ if (String(resolution.after_tasks_sha256 ?? "") !== currentTasksHash) {
940
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: current tasks.md hash must match task_reopen_resolved.after_tasks_sha256`));
941
+ }
942
+ if (String(resolution.after_tasks_sha256 ?? "") !== String(reopen.before_tasks_sha256 ?? "")) {
943
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: after_tasks_sha256 must match referenced task_reopen.before_tasks_sha256 after restoring the task checkbox`));
944
+ }
945
+ const requiredSupersedes = Array.isArray(reopen.required_supersede_evidence_ids)
946
+ ? reopen.required_supersede_evidence_ids.map((item) => String(item))
947
+ : [];
948
+ const liveConflicts = requiredSupersedes.filter((item) => liveById.has(item)).sort();
949
+ if (liveConflicts.length > 0) {
950
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: required_supersede_evidence_ids must be out of live/pass before resolving reopen: ${renderList(liveConflicts)}`, liveConflicts));
951
+ }
952
+ const successorIds = Array.isArray(resolution.successor_completion_evidence_ids)
953
+ ? resolution.successor_completion_evidence_ids.map((item) => String(item)).filter(Boolean)
954
+ : [];
955
+ const successorEvidence = [];
956
+ for (const successorId of successorIds) {
957
+ const successor = liveById.get(successorId);
958
+ if (!successor) {
959
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: successor_completion_evidence_ids entry is not live/pass: ${successorId}`, [successorId]));
960
+ continue;
961
+ }
962
+ successorEvidence.push(successor);
963
+ if (String(successor.task_id ?? "") !== taskId) {
964
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: successor evidence ${successorId} must belong to task ${taskId}`, [successorId, taskId]));
965
+ }
966
+ if (String(successor.reopen_id ?? "") !== reopenId) {
967
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: successor evidence ${successorId} must carry reopen_id=${repr(reopenId)}`, [successorId, reopenId]));
968
+ }
969
+ }
970
+ const tddRequired = (task.attrs.tdd_required ?? "true").toLowerCase() !== "false";
971
+ if (tddRequired) {
972
+ const invalidSuccessorKinds = successorEvidence
973
+ .filter((ev) => normalize_gate(String(ev.gate ?? "")) !== "task_complete" || ev.kind !== "test_run" || ev.semantic_status !== "expected_success")
974
+ .map((ev) => String(ev.evidence_id ?? ev.kind ?? ""))
975
+ .filter(Boolean)
976
+ .sort();
977
+ if (invalidSuccessorKinds.length > 0) {
978
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: tdd_required task successor evidence must be task_complete expected_success test_run: ${renderList(invalidSuccessorKinds)}`, invalidSuccessorKinds));
979
+ }
980
+ const violatedTests = (Array.isArray(reopen.violated_test_ids) ? reopen.violated_test_ids : []).map((item) => String(item)).filter(Boolean);
981
+ const successorGreenIds = new Set(successorEvidence
982
+ .filter((ev) => ev.kind === "test_run" && ev.semantic_status === "expected_success")
983
+ .filter((ev) => String(ev.task_id ?? "") === taskId && String(ev.reopen_id ?? "") === reopenId)
984
+ .map((ev) => String(ev.test_id ?? ""))
985
+ .filter((testId) => violatedTests.includes(testId)));
986
+ const missingSuccessors = violatedTests.filter((testId) => !successorGreenIds.has(testId)).sort();
987
+ if (missingSuccessors.length > 0) {
988
+ reasons.push(reason("missing_reopen_successor", `${resolutionPath}: task ${taskId} requires successor GREEN evidence with reopen_id=${repr(reopenId)} for violated tests: ${renderList(missingSuccessors)}`, missingSuccessors));
989
+ }
990
+ const staleGreen = task_test_evidence(evidences, taskId, "expected_success", "task_complete")
991
+ .filter((ev) => violatedTests.includes(String(ev.test_id ?? "")))
992
+ .filter((ev) => String(ev.reopen_id ?? "") !== reopenId)
993
+ .map((ev) => String(ev.evidence_id ?? ev.test_id ?? ""))
994
+ .filter(Boolean)
995
+ .sort();
996
+ if (staleGreen.length > 0) {
997
+ reasons.push(reason("stale_reopen_successor", `${resolutionPath}: task ${taskId} still has live pre-reopen GREEN evidence for violated tests: ${renderList(staleGreen)}`, staleGreen));
998
+ }
999
+ }
1000
+ else {
1001
+ const invalidSuccessorKinds = successorEvidence
1002
+ .filter((ev) => ev.kind !== "alternative_verification" && ev.kind !== "manual_verification")
1003
+ .map((ev) => String(ev.evidence_id ?? ev.kind ?? ""))
1004
+ .filter(Boolean)
1005
+ .sort();
1006
+ if (invalidSuccessorKinds.length > 0) {
1007
+ reasons.push(reason("task_reopen_resolved_invalid", `${resolutionPath}: tdd_required:false task successor evidence must be alternative/manual verification: ${renderList(invalidSuccessorKinds)}`, invalidSuccessorKinds));
1008
+ }
1009
+ const successorAlt = successorEvidence
1010
+ .filter((ev) => ev.kind === "alternative_verification" || ev.kind === "manual_verification")
1011
+ .filter((ev) => String(ev.task_id ?? "") === taskId && String(ev.reopen_id ?? "") === reopenId);
1012
+ if (successorAlt.length === 0) {
1013
+ reasons.push(reason("missing_reopen_successor", `${resolutionPath}: task ${taskId} requires alternative/manual verification with reopen_id=${repr(reopenId)} after reopen`));
1014
+ }
1015
+ const staleAlt = task_alternative_verification(evidences, taskId)
1016
+ .filter((ev) => String(ev.reopen_id ?? "") !== reopenId)
1017
+ .map((ev) => String(ev.evidence_id ?? ev.kind ?? ""))
1018
+ .filter(Boolean)
1019
+ .sort();
1020
+ if (staleAlt.length > 0) {
1021
+ reasons.push(reason("stale_reopen_successor", `${resolutionPath}: task ${taskId} still has live pre-reopen alternative/manual verification evidence: ${renderList(staleAlt)}`, staleAlt));
1022
+ }
1023
+ }
1024
+ }
1025
+ return reasons;
1026
+ }
1027
+ export function check_task_reopen(change, status, changeRoot, evidences, taskId) {
1028
+ const gate = "task_reopen";
1029
+ const reasons = [];
1030
+ if (!is_done(status, "tasks"))
1031
+ reasons.push(reason("missing_tasks", "tasks.md not done in OpenSpec"));
1032
+ const propose = check_superspec_gate(change, status, changeRoot, evidences, "propose_complete");
1033
+ if (!propose.allowed) {
1034
+ reasons.push(reason("propose_not_complete", "task_reopen requires propose_complete"));
1035
+ reasons.push(...propose.block_reasons);
1036
+ }
1037
+ const tasks = parse_tasks(changeRoot);
1038
+ const task = tasks[taskId];
1039
+ if (!task)
1040
+ return block(change, gate, [...reasons, reason("unknown_task", `task ${taskId} not found in tasks.md`)], { task_id: taskId });
1041
+ if (!task.checked)
1042
+ reasons.push(reason("task_reopen_invalid", `task ${taskId} must still be checked before authorized reopen revert`));
1043
+ reasons.push(...request_changes_round_reasons(evidences));
1044
+ const reopenCheck = active_task_reopen_reasons(changeRoot, evidences, task, taskId, "pre_revert");
1045
+ reasons.push(...reopenCheck.reasons);
1046
+ if (reasons.length > 0)
1047
+ return block(change, gate, reasons, { task_id: taskId, next_actions: [`keep ${taskId} checked, fix task_reopen evidence / supersedes, then rerun check-task-reopen`] });
1048
+ return allow(change, gate, { task_id: taskId });
1049
+ }
1050
+ // FIX-8 (audit A-5): apply work requires the user's explicit isolation/execution-mode choice,
1051
+ // recorded as a human_confirmation that pins the approved tasks.md structure (checkbox-state
1052
+ // insensitive). A structural tasks.md edit after that approval is the mechanical signature of
1053
+ // apply-phase scope expansion (SPEC §14.7) and demands explicit user re-approval — redesign,
1054
+ // split into a new change, or record a scope_expansion confirmation re-pinning the structure.
1055
+ function apply_scope_confirmation_reasons(changeRoot, evidences) {
1056
+ const isolation = live_pass(evidences, { gate: "apply_isolation", kind: "human_confirmation" });
1057
+ const currentHash = tasks_structure_hash(changeRoot);
1058
+ if (isolation.length === 0) {
1059
+ const hashHint = currentHash ? ` with tasks_structure_hash=${currentHash}` : "";
1060
+ return [reason("apply_isolation_unconfirmed", `apply requires the user's explicit isolation/execution-mode choice: record gate="apply_isolation" human_confirmation${hashHint} before task work`)];
1061
+ }
1062
+ if (currentHash === null)
1063
+ return [];
1064
+ const approvals = [...isolation, ...live_pass(evidences, { gate: "scope_expansion", kind: "human_confirmation" })];
1065
+ if (approvals.some((ev) => String(ev.tasks_structure_hash ?? "") === currentHash))
1066
+ return [];
1067
+ return [reason("scope_expansion_unconfirmed", `tasks.md structure changed after the last user-approved apply scope; ask the user to redesign/split the change or re-approve by recording gate="scope_expansion" human_confirmation with tasks_structure_hash=${currentHash}`)];
1068
+ }
1069
+ export function check_task_edit(change, status, changeRoot, evidences, taskId) {
1070
+ const gate = "task_edit";
1071
+ const reasons = [];
1072
+ if (!is_done(status, "tasks"))
1073
+ reasons.push(reason("missing_tasks", "tasks.md not done in OpenSpec"));
1074
+ const propose = check_superspec_gate(change, status, changeRoot, evidences, "propose_complete");
1075
+ if (!propose.allowed) {
1076
+ reasons.push(reason("propose_not_complete", "task_edit requires propose_complete"));
1077
+ reasons.push(...propose.block_reasons);
1078
+ }
1079
+ reasons.push(...apply_scope_confirmation_reasons(changeRoot, evidences));
1080
+ const tasks = parse_tasks(changeRoot);
1081
+ const task = tasks[taskId];
1082
+ if (!task)
1083
+ return block(change, gate, [...reasons, reason("unknown_task", `task ${taskId} not found in tasks.md`)], { task_id: taskId });
1084
+ let reopenMode = { mode: "ordinary" };
1085
+ if (task.checked) {
1086
+ if (task_reopen_history(evidences, taskId).unresolvedLive.length > 0) {
1087
+ reasons.push(reason("task_reopen_pending_revert", `task ${taskId} has unresolved task_reopen and must pass check-task-reopen before editing`));
1088
+ }
1089
+ reasons.push(reason("task_already_done", `task ${taskId} already checked`));
1090
+ }
1091
+ else {
1092
+ reopenMode = task_reopen_apply_state(changeRoot, evidences, task, taskId);
1093
+ if (reopenMode.mode === "blocked")
1094
+ reasons.push(...reopenMode.reasons);
1095
+ }
1096
+ const attrs = task.attrs;
1097
+ const tddRequired = (attrs.tdd_required ?? "true").toLowerCase() !== "false";
1098
+ const tddMode = attrs.tdd_mode ?? "new-behavior";
1099
+ const declared = new Set(splitList(attrs.test_refs ?? ""));
1100
+ const declaredInvariants = new Set(splitList(attrs.invariant_refs ?? ""));
1101
+ const contractIds = parse_test_contract_ids(changeRoot);
1102
+ const validInvariantIds = business_invariant_ids(changeRoot);
1103
+ if (!TDD_MODES.has(tddMode))
1104
+ reasons.push(reason("invalid_tdd_mode", `task ${taskId}: tdd_mode=${repr(tddMode)}`));
1105
+ if (!tddRequired) {
1106
+ const nr = attrs.no_tdd_reason;
1107
+ if (!NO_TDD_REASONS.has(nr))
1108
+ reasons.push(reason("invalid_no_tdd_reason", `task ${taskId}: no_tdd_reason=${repr(nr)}`));
1109
+ }
1110
+ else if (tddMode === "behavior-preserving-refactor") {
1111
+ const characterization = task_test_evidence(evidences, taskId, "expected_success", "task_edit");
1112
+ if (characterization.length === 0)
1113
+ reasons.push(reason("missing_characterization", `task ${taskId} (behavior-preserving-refactor) needs GREEN characterization test first`));
1114
+ reasons.push(...evidence_test_id_reasons(characterization, taskId, declared, contractIds, "characterization"));
1115
+ reasons.push(...declared_test_evidence_reasons(characterization, taskId, declared, "characterization"));
1116
+ reasons.push(...evidence_invariant_ref_reasons(characterization, taskId, declaredInvariants, validInvariantIds, "characterization"));
1117
+ reasons.push(...evidence_test_contract_invariant_reasons(characterization, taskId, test_contract_invariant_refs_by_test(changeRoot), "characterization"));
1118
+ }
1119
+ else {
1120
+ const red = task_test_evidence(evidences, taskId, "expected_failure", "task_edit");
1121
+ if (red.length === 0)
1122
+ reasons.push(reason("missing_red_evidence", `task ${taskId} requires RED evidence (expected_failure) before edit`));
1123
+ reasons.push(...evidence_test_id_reasons(red, taskId, declared, contractIds, "RED"));
1124
+ reasons.push(...declared_test_evidence_reasons(red, taskId, declared, "RED"));
1125
+ reasons.push(...evidence_invariant_ref_reasons(red, taskId, declaredInvariants, validInvariantIds, "RED"));
1126
+ reasons.push(...evidence_test_contract_invariant_reasons(red, taskId, test_contract_invariant_refs_by_test(changeRoot), "RED"));
1127
+ }
1128
+ if (reasons.length > 0) {
1129
+ const reasonSet = reason_codes(reasons);
1130
+ return block(change, gate, reasons, {
1131
+ task_id: taskId,
1132
+ next_actions: action_list(task.checked && task_reopen_history(evidences, taskId).unresolvedLive.length > 0
1133
+ ? `pass check-task-reopen for ${taskId} and perform the authorized [x] -> [ ] revert first`
1134
+ : null, reasonSet.has("change_update_required") ? `return to propose/change update before any further apply work on ${taskId}` : null, reasonSet.has("apply_isolation_unconfirmed") ? "AskUserQuestion for apply isolation/execution mode and record gate=\"apply_isolation\" human_confirmation" : null, reasonSet.has("scope_expansion_unconfirmed") ? "stop: redesign/split the change or record gate=\"scope_expansion\" human_confirmation re-approving tasks.md structure" : null, reopenMode.mode === "blocked" ? `repair or create task_reopen package for ${taskId} before resumed apply` : null, `write failing test + record RED evidence for ${taskId}`),
1135
+ });
1136
+ }
1137
+ return allow(change, gate, { task_id: taskId });
1138
+ }
1139
+ export function check_task_complete(change, status, changeRoot, evidences, taskId) {
1140
+ const gate = "task_complete";
1141
+ const reasons = [];
1142
+ if (!is_done(status, "tasks"))
1143
+ reasons.push(reason("missing_tasks", "tasks.md not done in OpenSpec"));
1144
+ const propose = check_superspec_gate(change, status, changeRoot, evidences, "propose_complete");
1145
+ if (!propose.allowed) {
1146
+ reasons.push(reason("propose_not_complete", "task_complete requires propose_complete"));
1147
+ reasons.push(...propose.block_reasons);
1148
+ }
1149
+ reasons.push(...apply_scope_confirmation_reasons(changeRoot, evidences));
1150
+ const task = parse_tasks(changeRoot)[taskId];
1151
+ if (!task)
1152
+ return block(change, gate, [reason("unknown_task", `task ${taskId} not found`)], { task_id: taskId });
1153
+ const reopenHistory = task_reopen_history(evidences, taskId);
1154
+ if (task.checked && reopenHistory.pass.length > 1) {
1155
+ reasons.push(reason("reopen_lifecycle_exhausted", `task ${taskId} has multiple task_reopen histories in this change; v1 allows at most one`, [taskId]));
1156
+ }
1157
+ if (task.checked && reopenHistory.unresolvedLive.length > 0) {
1158
+ reasons.push(reason("unresolved_task_reopen", `task ${taskId} has unresolved task_reopen and must complete reopened apply before ordinary completion`, [taskId]));
1159
+ }
1160
+ if (task.checked && (reopenHistory.liveResolved.length > 0 || reopenHistory.pass.length > 0)) {
1161
+ reasons.push(...task_reopen_resolution_reasons(changeRoot, evidences, taskId));
1162
+ }
1163
+ const reopenMode = !task.checked ? task_reopen_apply_state(changeRoot, evidences, task, taskId) : { mode: "ordinary" };
1164
+ if (!task.checked && reopenMode.mode === "blocked")
1165
+ reasons.push(...reopenMode.reasons);
1166
+ const attrs = task.attrs;
1167
+ const tddRequired = (attrs.tdd_required ?? "true").toLowerCase() !== "false";
1168
+ const tddMode = attrs.tdd_mode ?? "new-behavior";
1169
+ if (!TDD_MODES.has(tddMode))
1170
+ reasons.push(reason("invalid_tdd_mode", `task ${taskId}: tdd_mode=${repr(tddMode)}`));
1171
+ if (tddRequired) {
1172
+ const green = task_test_evidence(evidences, taskId, "expected_success", "task_complete");
1173
+ if (green.length === 0)
1174
+ reasons.push(reason("missing_green_evidence", `task ${taskId} requires GREEN evidence (expected_success) before completion`));
1175
+ const declared = new Set(splitList(attrs.test_refs ?? ""));
1176
+ const declaredInvariants = new Set(splitList(attrs.invariant_refs ?? ""));
1177
+ if (declared.size === 0)
1178
+ reasons.push(reason("missing_task_test_refs", `task ${taskId} requires test_refs for GREEN evidence`));
1179
+ reasons.push(...evidence_test_id_reasons(green, taskId, declared, parse_test_contract_ids(changeRoot), "GREEN"));
1180
+ reasons.push(...declared_test_evidence_reasons(green, taskId, declared, "GREEN"));
1181
+ reasons.push(...evidence_invariant_ref_reasons(green, taskId, declaredInvariants, business_invariant_ids(changeRoot), "GREEN"));
1182
+ reasons.push(...evidence_test_contract_invariant_reasons(green, taskId, test_contract_invariant_refs_by_test(changeRoot), "GREEN"));
1183
+ if (reopenMode.mode === "reopened") {
1184
+ const reopen = reopenMode.reopen;
1185
+ const reopenId = String(reopen.reopen_id ?? "");
1186
+ const violatedTests = (Array.isArray(reopen.violated_test_ids) ? reopen.violated_test_ids : []).map((item) => String(item)).filter(Boolean);
1187
+ const successorGreen = green.filter((ev) => String(ev.reopen_id ?? "") === reopenId);
1188
+ const successorGreenIds = new Set(successorGreen
1189
+ .map((ev) => String(ev.test_id ?? ""))
1190
+ .filter((testId) => violatedTests.includes(testId)));
1191
+ const missingSuccessors = violatedTests.filter((testId) => !successorGreenIds.has(testId)).sort();
1192
+ if (missingSuccessors.length > 0) {
1193
+ reasons.push(reason("missing_reopen_successor", `task ${taskId} requires successor GREEN evidence with reopen_id=${repr(reopenId)} for violated tests: ${renderList(missingSuccessors)}`, missingSuccessors));
1194
+ }
1195
+ const staleGreen = green
1196
+ .filter((ev) => violatedTests.includes(String(ev.test_id ?? "")))
1197
+ .filter((ev) => String(ev.reopen_id ?? "") !== reopenId)
1198
+ .map((ev) => String(ev.evidence_id ?? ev.test_id ?? ""))
1199
+ .filter(Boolean)
1200
+ .sort();
1201
+ if (staleGreen.length > 0) {
1202
+ reasons.push(reason("stale_reopen_successor", `task ${taskId} still has live pre-reopen GREEN evidence for violated tests: ${renderList(staleGreen)}`, staleGreen));
1203
+ }
1204
+ }
1205
+ }
1206
+ else {
1207
+ const nr = attrs.no_tdd_reason;
1208
+ if (!NO_TDD_REASONS.has(nr))
1209
+ reasons.push(reason("invalid_no_tdd_reason", `task ${taskId}: no_tdd_reason=${repr(nr)}`));
1210
+ const verifications = task_alternative_verification(evidences, taskId);
1211
+ if (verifications.length === 0)
1212
+ reasons.push(reason("missing_alternative_verification", `task ${taskId} has tdd_required:false and needs alternative verification evidence`));
1213
+ if (reopenMode.mode === "reopened") {
1214
+ const reopen = reopenMode.reopen;
1215
+ const reopenId = String(reopen.reopen_id ?? "");
1216
+ const successorAlt = verifications.filter((ev) => String(ev.reopen_id ?? "") === reopenId);
1217
+ if (successorAlt.length === 0) {
1218
+ reasons.push(reason("missing_reopen_successor", `task ${taskId} requires alternative/manual verification with reopen_id=${repr(reopenId)} after reopen`));
1219
+ }
1220
+ const staleAlt = verifications
1221
+ .filter((ev) => String(ev.reopen_id ?? "") !== reopenId)
1222
+ .map((ev) => String(ev.evidence_id ?? ev.kind ?? ""))
1223
+ .filter(Boolean)
1224
+ .sort();
1225
+ if (staleAlt.length > 0) {
1226
+ reasons.push(reason("stale_reopen_successor", `task ${taskId} still has live pre-reopen alternative/manual verification evidence: ${renderList(staleAlt)}`, staleAlt));
1227
+ }
1228
+ }
1229
+ }
1230
+ if (reasons.length > 0) {
1231
+ const reasonSet = reason_codes(reasons);
1232
+ return block(change, gate, reasons, {
1233
+ task_id: taskId,
1234
+ next_actions: action_list(reasonSet.has("change_update_required") ? `return to propose/change update before any further apply work on ${taskId}` : null, reopenMode.mode === "blocked" ? `repair or recreate task_reopen package for ${taskId} before completion` : null, reopenMode.mode === "reopened" ? `record successor evidence with reopen_id for ${taskId} before check-task-complete` : null, `run test to GREEN + record evidence for ${taskId}`),
1235
+ });
1236
+ }
1237
+ return allow(change, gate, { task_id: taskId });
1238
+ }
1239
+ export function check_review_ready(change, status, changeRoot, evidences) {
1240
+ const gate = "review_ready";
1241
+ const reasons = [];
1242
+ const nextActions = [];
1243
+ if (!all_done(status))
1244
+ reasons.push(reason("artifacts_incomplete", "not all OpenSpec artifacts are done"));
1245
+ const propose = check_superspec_gate(change, status, changeRoot, evidences, "propose_complete");
1246
+ if (!propose.allowed) {
1247
+ reasons.push(reason("propose_not_complete", "review_ready requires propose_complete"));
1248
+ reasons.push(...propose.block_reasons);
1249
+ nextActions.push(...propose.next_allowed_actions);
1250
+ if (propose.next_allowed_actions.length === 0)
1251
+ nextActions.push("pass check-apply-ready / propose_complete before entering review");
1252
+ }
1253
+ const tasks = parse_tasks(changeRoot);
1254
+ const unchecked = Object.entries(tasks).filter(([, task]) => !task.checked).map(([taskId]) => taskId);
1255
+ if (unchecked.length > 0) {
1256
+ reasons.push(reason("tasks_incomplete", `unchecked tasks: ${renderList(unchecked)}`));
1257
+ nextActions.push("finish remaining unchecked tasks and mark them complete only after check-task-complete passes");
1258
+ }
1259
+ for (const [taskId, task] of Object.entries(tasks)) {
1260
+ if (!task.checked)
1261
+ continue;
1262
+ const taskDecision = check_task_complete(change, status, changeRoot, evidences, taskId);
1263
+ if (!taskDecision.allowed) {
1264
+ reasons.push(reason("task_evidence_incomplete", `checked task ${taskId} does not satisfy task_complete evidence`));
1265
+ reasons.push(...taskDecision.block_reasons);
1266
+ nextActions.push("rerun check-task-complete for each checked task and backfill missing GREEN or alternative verification evidence");
1267
+ }
1268
+ }
1269
+ const unresolvedReopens = unresolved_live_task_reopens(evidences);
1270
+ if (unresolvedReopens.length > 0) {
1271
+ const reopenTasks = unresolvedReopens.map((ev) => String(ev.task_id ?? "")).filter(Boolean).sort();
1272
+ reasons.push(reason("unresolved_task_reopen", `review_ready blocks while unresolved task_reopen exists: ${renderList(reopenTasks)}`, reopenTasks));
1273
+ nextActions.push("finish reopened apply, mark tasks complete again, and write task_reopen_resolved before re-entering review");
1274
+ }
1275
+ const lifecycleReasons = task_reopen_global_lifecycle_reasons(changeRoot, evidences);
1276
+ if (lifecycleReasons.length > 0) {
1277
+ reasons.push(...lifecycleReasons);
1278
+ nextActions.push("repair task_reopen lifecycle evidence and successor proof before re-entering review");
1279
+ }
1280
+ const [ok] = runtime.openspec_validate(change);
1281
+ if (!ok) {
1282
+ reasons.push(reason("validate_failed", "openspec validate did not pass"));
1283
+ nextActions.push(`fix openspec validate failures for ${change}`);
1284
+ }
1285
+ const repoRoot = get_repo_root(status);
1286
+ const dirtyWriteScopeReasons = runtime.dirty_write_scope_red_reasons(repoRoot, tasks, evidences);
1287
+ const dirtyWorktreeReasons = runtime.dirty_worktree_reasons(repoRoot, changeRoot, evidences);
1288
+ reasons.push(...dirtyWriteScopeReasons);
1289
+ reasons.push(...dirtyWorktreeReasons);
1290
+ if (dirtyWriteScopeReasons.length > 0)
1291
+ nextActions.push("record RED evidence for changed write_scope files before review");
1292
+ if (dirtyWorktreeReasons.length > 0)
1293
+ nextActions.push("record branch_handling human confirmation for unrelated dirty files, or clean them before review");
1294
+ if (reasons.length > 0)
1295
+ return block(change, gate, reasons, { next_actions: action_list(...nextActions) });
1296
+ return allow(change, gate);
1297
+ }
1298
+ export function check_review_complete(change, status, changeRoot, evidences) {
1299
+ const gate = "review_complete";
1300
+ const pre = check_review_ready(change, status, changeRoot, evidences);
1301
+ const reasons = [];
1302
+ const repoRoot = get_repo_root(status);
1303
+ if (!pre.allowed) {
1304
+ reasons.push(reason("review_not_ready", "review_ready gate not satisfied"));
1305
+ reasons.push(...pre.block_reasons);
1306
+ }
1307
+ const reviews = live_pass(evidences, { gate: "review_complete" });
1308
+ reasons.push(...duplicate_output_ref_reasons(changeRoot, reviews));
1309
+ const sourceGuidance = reviews.filter((ev) => ev.kind === "source_guidance");
1310
+ const sourceGuidanceRoles = new Set(sourceGuidance.map((ev) => String(ev.agent_role)));
1311
+ const missingReviewRoles = REVIEW_GUIDANCE_ROLES.filter((need) => !sourceGuidanceRoles.has(need));
1312
+ for (const need of REVIEW_GUIDANCE_ROLES) {
1313
+ if (!sourceGuidanceRoles.has(need))
1314
+ reasons.push(reason("missing_source_guidance", `review_complete requires ${need} source_guidance evidence`));
1315
+ }
1316
+ for (const ev of sourceGuidance) {
1317
+ for (const field of REVIEW_EVIDENCE_REQUIRED_FIELDS) {
1318
+ if (!ev[field])
1319
+ reasons.push(reason("review_evidence_incomplete", `${ev._path}: missing ${field}`));
1320
+ }
1321
+ if (ev.reviewed_files !== undefined && (!Array.isArray(ev.reviewed_files) || ev.reviewed_files.length === 0))
1322
+ reasons.push(reason("review_evidence_incomplete", `${ev._path}: reviewed_files must be a non-empty list`));
1323
+ reasons.push(...runtime.review_diff_coverage_reasons(repoRoot, ev));
1324
+ if (!Array.isArray(ev.rollback_targets) || ev.rollback_targets.length === 0)
1325
+ reasons.push(reason("missing_rollback_target", `${ev._path}: no rollback_targets`));
1326
+ }
1327
+ const mainAdjudications = reviews.filter((ev) => ev.kind === "main_adjudication");
1328
+ if (mainAdjudications.length === 1 && mainAdjudications[0].review_decision === "request_changes") {
1329
+ const adjudication = mainAdjudications[0];
1330
+ const requestChangeReasons = [...reasons, reason("review_requests_changes", `${adjudication._path}: review_complete is allow-only; this review round ended with request_changes(${String(adjudication.request_changes_route ?? "missing_route")})`)];
1331
+ const guidanceIds = new Set(sourceGuidance.map((ev) => String(ev.evidence_id)));
1332
+ const referencedIds = new Set((Array.isArray(adjudication.source_evidence_refs) ? adjudication.source_evidence_refs : []).map((item) => String(item)));
1333
+ const missingGuidance = [...guidanceIds].filter((id) => !referencedIds.has(id)).sort();
1334
+ if (missingGuidance.length > 0) {
1335
+ requestChangeReasons.push(reason("source_guidance_unreferenced", `${adjudication._path}: main_adjudication must reference every live source_guidance evidence_id: ${renderList(missingGuidance)}`, missingGuidance));
1336
+ }
1337
+ const unknownGuidanceRefs = [...referencedIds].filter((id) => !guidanceIds.has(id)).sort();
1338
+ if (unknownGuidanceRefs.length > 0) {
1339
+ requestChangeReasons.push(reason("source_guidance_unreferenced", `${adjudication._path}: source_evidence_refs must reference live source_guidance evidence only: ${renderList(unknownGuidanceRefs)}`, unknownGuidanceRefs));
1340
+ }
1341
+ const blockingIds = new Set((Array.isArray(adjudication.blocking_source_evidence_refs) ? adjudication.blocking_source_evidence_refs : []).map((item) => String(item)));
1342
+ const unknownBlockingRefs = [...blockingIds].filter((id) => !guidanceIds.has(id)).sort();
1343
+ if (unknownBlockingRefs.length > 0) {
1344
+ requestChangeReasons.push(reason("source_guidance_unreferenced", `${adjudication._path}: blocking_source_evidence_refs must reference live source_guidance evidence only: ${renderList(unknownBlockingRefs)}`, unknownBlockingRefs));
1345
+ }
1346
+ requestChangeReasons.push(...validate_evidence_schema(adjudication, change, changeRoot, repoRoot));
1347
+ requestChangeReasons.push(...sourceGuidance.flatMap((ev) => validate_evidence_schema(ev, change, changeRoot, repoRoot)));
1348
+ requestChangeReasons.push(...request_changes_round_reasons(evidences));
1349
+ const requestChangeOnly = requestChangeReasons.length === 1
1350
+ && requestChangeReasons[0].code === "review_requests_changes";
1351
+ return block(change, gate, requestChangeReasons, {
1352
+ next_actions: action_list(...review_complete_actions(change, requestChangeReasons, pre, { missing_review_roles: missingReviewRoles }), ...(requestChangeOnly ? request_changes_handoff_actions(adjudication) : [])),
1353
+ });
1354
+ }
1355
+ const verifications = final_verification_evidences(evidences);
1356
+ const verificationProofs = live_pass(evidences, { gate: "review_complete" })
1357
+ .filter((ev) => ev.kind === "verification_review" || ev.kind === "final_test");
1358
+ const verifyReports = verifications.filter((ev) => ev.kind === "verification_review");
1359
+ const verifyRoles = new Set(verifyReports.map((ev) => ev.agent_role));
1360
+ const missingVerificationRoles = FINAL_VERIFICATION_ROLES.filter((need) => !verifyRoles.has(need));
1361
+ for (const need of FINAL_VERIFICATION_ROLES) {
1362
+ if (!verifyRoles.has(need))
1363
+ reasons.push(reason("missing_final_verification_review", `missing verification_review by '${need}'`));
1364
+ }
1365
+ for (const ev of verifyReports) {
1366
+ for (const field of VERIFY_EVIDENCE_REQUIRED_FIELDS) {
1367
+ if (!ev[field])
1368
+ reasons.push(reason("verification_evidence_incomplete", `${ev._path}: missing ${field}`));
1369
+ }
1370
+ reasons.push(...verify_reference_reasons(changeRoot, ev, evidences));
1371
+ reasons.push(...invariant_matrix_coverage_reasons(changeRoot, ev, evidences));
1372
+ if (![undefined, null, "none", "accepted"].includes(ev.scope_drift))
1373
+ reasons.push(reason("scope_drift", `${ev._path}: scope_drift=${repr(ev.scope_drift)}`));
1374
+ }
1375
+ const finalTests = verificationProofs.filter((ev) => ev.kind === "final_test");
1376
+ if (finalTests.length === 0) {
1377
+ reasons.push(reason("missing_final_tests", "review_complete requires final_test pass evidence"));
1378
+ }
1379
+ // FIX-8 (audit A-5): a failed verification is a user decision point (fix vs accept deviation,
1380
+ // SPEC §14.4). History cannot be erased: every fail-status verification evidence — superseded
1381
+ // or not — must be covered by a live verify_failure_handling confirmation's confirmed_refs.
1382
+ const failedVerifications = evidences.filter((ev) => normalize_gate(String(ev.gate ?? "")) === gate
1383
+ && (ev.kind === "verification_review" || ev.kind === "final_test")
1384
+ && ev.status === "fail");
1385
+ if (failedVerifications.length > 0) {
1386
+ const dispositions = new Set(live_pass(evidences, { gate: "verify_failure_handling", kind: "human_confirmation" })
1387
+ .flatMap((ev) => (Array.isArray(ev.confirmed_refs) ? ev.confirmed_refs.map((item) => String(item)) : [])));
1388
+ const unhandled = failedVerifications
1389
+ .map((ev) => String(ev.evidence_id ?? ev._path ?? "unknown"))
1390
+ .filter((id) => !dispositions.has(id))
1391
+ .sort();
1392
+ if (unhandled.length > 0) {
1393
+ reasons.push(reason("verify_failure_unconfirmed", `failed verification evidence requires explicit user disposition (fix or accept deviation) via gate="verify_failure_handling" human_confirmation confirming: ${renderList(unhandled)}`, unhandled));
1394
+ }
1395
+ }
1396
+ if (mainAdjudications.length === 0) {
1397
+ reasons.push(reason("missing_main_adjudication", "review_complete requires main_adjudication evidence authored by the main thread"));
1398
+ }
1399
+ else if (mainAdjudications.length > 1) {
1400
+ reasons.push(reason("ambiguous_main_adjudication", `review_complete requires exactly one live main_adjudication, found ${mainAdjudications.length}`));
1401
+ }
1402
+ else {
1403
+ const adjudication = mainAdjudications[0];
1404
+ if (adjudication.review_decision !== "allow") {
1405
+ reasons.push(reason("main_adjudication_invalid", `${adjudication._path}: review_complete only accepts main_adjudication.review_decision='allow'`));
1406
+ }
1407
+ const guidanceIds = new Set(sourceGuidance.map((ev) => String(ev.evidence_id)));
1408
+ const referencedIds = new Set((Array.isArray(adjudication.source_evidence_refs) ? adjudication.source_evidence_refs : []).map((item) => String(item)));
1409
+ const missingGuidance = [...guidanceIds].filter((id) => !referencedIds.has(id)).sort();
1410
+ if (missingGuidance.length > 0) {
1411
+ reasons.push(reason("source_guidance_unreferenced", `${adjudication._path}: main_adjudication must reference every live source_guidance evidence_id: ${renderList(missingGuidance)}`, missingGuidance));
1412
+ }
1413
+ const unknownGuidanceRefs = [...referencedIds].filter((id) => !guidanceIds.has(id)).sort();
1414
+ if (unknownGuidanceRefs.length > 0) {
1415
+ reasons.push(reason("source_guidance_unreferenced", `${adjudication._path}: source_evidence_refs must reference live source_guidance evidence only: ${renderList(unknownGuidanceRefs)}`, unknownGuidanceRefs));
1416
+ }
1417
+ const verificationIds = new Set(verificationProofs.map((ev) => String(ev.evidence_id)));
1418
+ const referencedVerificationIds = new Set((Array.isArray(adjudication.verification_evidence_refs) ? adjudication.verification_evidence_refs : []).map((item) => String(item)));
1419
+ const missingVerification = [...verificationIds].filter((id) => !referencedVerificationIds.has(id)).sort();
1420
+ if (missingVerification.length > 0) {
1421
+ reasons.push(reason("verification_evidence_unreferenced", `${adjudication._path}: main_adjudication must reference every live verification_review/final_test evidence_id: ${renderList(missingVerification)}`, missingVerification));
1422
+ }
1423
+ const verificationSetComplete = finalTests.length > 0 && missingVerificationRoles.length === 0;
1424
+ if (verificationSetComplete) {
1425
+ const unknownVerificationRefs = [...referencedVerificationIds].filter((id) => !verificationIds.has(id)).sort();
1426
+ if (unknownVerificationRefs.length > 0) {
1427
+ reasons.push(reason("verification_evidence_unreferenced", `${adjudication._path}: verification_evidence_refs must reference live verification_review/final_test evidence only: ${renderList(unknownVerificationRefs)}`, unknownVerificationRefs));
1428
+ }
1429
+ }
1430
+ reasons.push(...main_adjudication_source_guidance_reasons(adjudication, sourceGuidance));
1431
+ }
1432
+ const [ok] = runtime.openspec_validate(change);
1433
+ if (!ok) {
1434
+ reasons.push(reason("validate_failed", "openspec validate did not pass"));
1435
+ }
1436
+ if (reasons.length > 0) {
1437
+ return block(change, gate, reasons, {
1438
+ next_actions: review_complete_actions(change, reasons, pre, {
1439
+ missing_review_roles: missingReviewRoles,
1440
+ missing_verification_roles: missingVerificationRoles,
1441
+ }),
1442
+ });
1443
+ }
1444
+ return allow(change, gate);
1445
+ }
1446
+ export function check_verify_complete(change, status, changeRoot, evidences) {
1447
+ return check_review_complete(change, status, changeRoot, evidences);
1448
+ }
1449
+ export function check_archive_ready(change, status, changeRoot, evidences) {
1450
+ const gate = "archive_ready";
1451
+ const reasons = [];
1452
+ if (!all_done(status))
1453
+ reasons.push(reason("artifacts_incomplete", "not all artifacts done"));
1454
+ const review = check_review_complete(change, status, changeRoot, evidences);
1455
+ const reviewCodes = reason_codes(review.block_reasons);
1456
+ if (!review.allowed) {
1457
+ reasons.push(reason("review_gate_failed", "archive_ready requires review_complete gate to pass"));
1458
+ reasons.push(...review.block_reasons);
1459
+ }
1460
+ const [ok] = runtime.openspec_validate(change);
1461
+ if (!ok && !reviewCodes.has("validate_failed")) {
1462
+ reasons.push(reason("validate_failed", "openspec validate did not pass"));
1463
+ }
1464
+ if (live_pass(evidences, { gate: "archive_ready", kind: "human_confirmation" }).length === 0) {
1465
+ reasons.push(reason("missing_final_confirmation", "archive_ready requires human confirmation evidence"));
1466
+ }
1467
+ if (reasons.length > 0)
1468
+ return block(change, gate, reasons, { next_actions: archive_ready_actions(change, reasons, review) });
1469
+ return allow(change, gate, { gate_summary: { archive_manifest: toPosix(relative(changeRoot, archive_manifest_path(changeRoot))) } });
1470
+ }