@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,671 @@
1
+ // Disclosure fixed-point loop (docs/designs/REVIEW_DISCLOSURE_FIXED_POINT_DESIGN.md, Phases 1-3:
2
+ // explore_complete, proposal_reviewed, design_complete, invariants_reviewed,
3
+ // test_contract_drafted, tasks_complete).
4
+ // Material findings (scope / non_goal / acceptance / business_semantics / design_boundary) raised by
5
+ // role reviews must reach the user through main_review_digest + user_review_decision evidence; the
6
+ // main thread is never allowed to silently close them. Legacy evidence without review_round_id or
7
+ // findings[] stays grandfathered: the disclosure checks only activate once round-tagged evidence or
8
+ // a digest exists for the gate (P2-3) — except for gates born after the disclosure loop
9
+ // (DISCLOSURE_REQUIRED_GATES), which have no legacy population and therefore no grandfather path.
10
+ import { existsSync, readFileSync, statSync } from "node:fs";
11
+ import { join, relative } from "node:path";
12
+ import { isObject, reason, renderList, repr, runtime, safe_within, toPosix, walkFiles } from "./util.js";
13
+ import { normalize_gate } from "./openspec.js";
14
+ // Explicit target map (design §7); later phases extend this table gate by gate.
15
+ // Entries containing "*" are globs enumerated at check time; set equality (P1-6) means a spec
16
+ // file added after the digest makes the digest stale even though every pinned blob still matches.
17
+ export const REVIEW_TARGETS_BY_GATE = {
18
+ explore_complete: [".superspec/artifacts/discovery.md"],
19
+ proposal_reviewed: ["proposal.md", ".superspec/artifacts/discovery.md"],
20
+ design_complete: ["proposal.md", "design.md", "specs/**/*.md", ".superspec/artifacts/discovery.md"],
21
+ invariants_reviewed: [".superspec/artifacts/business-invariants.md", "design.md", "specs/**/*.md"],
22
+ test_contract_drafted: [".superspec/artifacts/test-contract.md", ".superspec/artifacts/business-invariants.md", "design.md", "specs/**/*.md"],
23
+ tasks_complete: ["tasks.md", ".superspec/artifacts/test-contract.md", ".superspec/artifacts/business-invariants.md", "design.md", "specs/**/*.md"],
24
+ };
25
+ // Gates introduced together with (or after) the disclosure loop: round-tagged review evidence plus
26
+ // a digest are mandatory, otherwise an agent could file old-style evidence to dodge disclosure.
27
+ export const DISCLOSURE_REQUIRED_GATES = new Set(["proposal_reviewed"]);
28
+ // Legal disposition routes (design §11, P1-4). The global set bounds the schema; the per-gate
29
+ // table bounds which escape hatches a gate may use (e.g. proposal findings that prove discovery
30
+ // incomplete must route return_explore, never reopen_tasks).
31
+ export const DISCLOSURE_ROUTES = new Set([
32
+ "stay_same_gate_fix",
33
+ "stay_same_gate_user_decision",
34
+ "return_explore",
35
+ "return_explore_or_proposal_reviewed",
36
+ "return_test_contract_drafted",
37
+ "reopen_tasks",
38
+ "change_update",
39
+ "escalate_round_budget",
40
+ ]);
41
+ export const DISCLOSURE_ROUTES_BY_GATE = {
42
+ explore_complete: new Set(["stay_same_gate_fix", "stay_same_gate_user_decision", "escalate_round_budget"]),
43
+ proposal_reviewed: new Set(["stay_same_gate_fix", "stay_same_gate_user_decision", "return_explore", "escalate_round_budget"]),
44
+ design_complete: new Set(["stay_same_gate_fix", "stay_same_gate_user_decision", "return_explore_or_proposal_reviewed", "escalate_round_budget"]),
45
+ invariants_reviewed: new Set(["stay_same_gate_fix", "stay_same_gate_user_decision", "return_explore_or_proposal_reviewed", "escalate_round_budget"]),
46
+ test_contract_drafted: new Set(["stay_same_gate_fix", "stay_same_gate_user_decision", "return_explore_or_proposal_reviewed", "escalate_round_budget"]),
47
+ tasks_complete: new Set(["stay_same_gate_fix", "return_test_contract_drafted", "escalate_round_budget"]),
48
+ };
49
+ export const MATERIAL_CATEGORIES = new Set(["scope", "non_goal", "acceptance", "business_semantics", "design_boundary"]);
50
+ export const FINDING_CATEGORIES = new Set([...MATERIAL_CATEGORIES, "test_gap", "implementation", "evidence", "process"]);
51
+ export const FINDING_TYPES = new Set(["blocker", "scope_risk", "open_question", "agent_assumption", "non_blocking_finding"]);
52
+ export const FINDING_DISPOSITIONS = new Set(["fixed", "false_positive", "accepted_deviation", "user_decided", "needs_user_decision"]);
53
+ export const USER_DECISION_OPTIONS = new Set(["option_a", "option_b", "option_c", "option_d_custom"]);
54
+ // R5 round economy: beyond this many full rounds without convergence the loop must escalate.
55
+ export const REVIEW_ROUND_BUDGET = 3;
56
+ export function review_round_number(gate, roundId) {
57
+ const match = /^(.+)-r(\d+)$/.exec(roundId);
58
+ if (!match || match[1] !== gate)
59
+ return null;
60
+ return Number(match[2]);
61
+ }
62
+ function string_list(value) {
63
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.length > 0) : [];
64
+ }
65
+ function pinned_map(refs) {
66
+ const out = new Map();
67
+ for (const item of Array.isArray(refs) ? refs : []) {
68
+ if (isObject(item) && typeof item.path === "string" && item.path)
69
+ out.set(item.path, String(item.blob_sha ?? ""));
70
+ }
71
+ return out;
72
+ }
73
+ function non_empty_string(value) {
74
+ return typeof value === "string" && value.trim().length > 0;
75
+ }
76
+ function label(ev) {
77
+ return String(ev._path ?? ev.evidence_id ?? "evidence");
78
+ }
79
+ function glob_to_regexp(pattern) {
80
+ // "**/" spans zero or more directory levels; "*" never crosses a slash.
81
+ const sentinel = pattern.replace(/\*\*\//g, "\u0000").replace(/\*/g, "\u0001");
82
+ const escaped = sentinel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83
+ const source = escaped.replace(/\u0000/g, "(?:.*/)?").replace(/\u0001/g, "[^/]*");
84
+ return new RegExp(`^${source}$`);
85
+ }
86
+ // Enumerates the gate's target set right now: static paths must exist (a missing artifact is the
87
+ // gate's own missing_* reason, so we bail to null and skip stale checks); glob entries contribute
88
+ // every current match so set equality catches files added after a digest (P1-6).
89
+ export function enumerate_review_targets(gate, changeRoot) {
90
+ const patterns = REVIEW_TARGETS_BY_GATE[normalize_gate(gate)];
91
+ if (!patterns)
92
+ return null;
93
+ const out = new Map();
94
+ for (const pattern of patterns) {
95
+ if (pattern.includes("*")) {
96
+ const matcher = glob_to_regexp(pattern);
97
+ const staticPrefix = pattern.slice(0, pattern.indexOf("*")).replace(/[^/]*$/, "");
98
+ for (const abs of walkFiles(join(changeRoot, staticPrefix))) {
99
+ const rel = toPosix(relative(changeRoot, abs));
100
+ if (matcher.test(rel))
101
+ out.set(rel, String(runtime.file_blob_sha(abs)));
102
+ }
103
+ }
104
+ else {
105
+ const abs = join(changeRoot, pattern);
106
+ if (!existsSync(abs) || !statSync(abs).isFile())
107
+ return null;
108
+ out.set(pattern, String(runtime.file_blob_sha(abs)));
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ // ── Schema validation (wired into validate_evidence_schema) ─────────────────
114
+ // findings[] entries are produced by the reviewer and are the identity source of truth (P0-2).
115
+ export function findings_schema_reasons(ev) {
116
+ const problems = [];
117
+ const fail = (message) => problems.push(reason("review_finding_invalid", `${label(ev)}: ${message}`));
118
+ if (!Array.isArray(ev.findings)) {
119
+ fail("findings must be an array");
120
+ return problems;
121
+ }
122
+ const gate = normalize_gate(String(ev.gate ?? ""));
123
+ if (!non_empty_string(ev.review_round_id) || review_round_number(gate, String(ev.review_round_id)) === null) {
124
+ fail(`evidence carrying findings[] must have review_round_id of the form ${gate}-r<N>`);
125
+ }
126
+ if (ev.acknowledged_accepted_deviation_uids !== undefined && !Array.isArray(ev.acknowledged_accepted_deviation_uids)) {
127
+ fail("acknowledged_accepted_deviation_uids must be a string array");
128
+ }
129
+ for (const [idx, item] of ev.findings.entries()) {
130
+ if (!isObject(item)) {
131
+ fail(`findings[${idx}] must be an object`);
132
+ continue;
133
+ }
134
+ if (!non_empty_string(item.finding_id))
135
+ fail(`findings[${idx}] missing finding_id`);
136
+ if (!non_empty_string(item.summary))
137
+ fail(`findings[${idx}] missing summary (verbatim disclosure source, P1-1)`);
138
+ if (!FINDING_TYPES.has(String(item.finding_type)))
139
+ fail(`findings[${idx}] finding_type=${repr(item.finding_type)} not in ${renderList([...FINDING_TYPES].sort())}`);
140
+ if (!FINDING_CATEGORIES.has(String(item.category)))
141
+ fail(`findings[${idx}] category=${repr(item.category)} not in ${renderList([...FINDING_CATEGORIES].sort())}`);
142
+ const material = item.material_categories;
143
+ if (material !== undefined && !Array.isArray(material))
144
+ fail(`findings[${idx}] material_categories must be an array`);
145
+ const materialList = string_list(material);
146
+ for (const cat of materialList) {
147
+ if (!MATERIAL_CATEGORIES.has(cat))
148
+ fail(`findings[${idx}] material category ${repr(cat)} not in ${renderList([...MATERIAL_CATEGORIES].sort())}`);
149
+ }
150
+ if (materialList.length > 0 && !non_empty_string(item.decision_scope_key))
151
+ fail(`findings[${idx}] material finding requires decision_scope_key`);
152
+ const expectedUid = `${gate}:${ev.evidence_id}:${item.finding_id}`;
153
+ if (String(item.finding_uid ?? "") !== expectedUid)
154
+ fail(`findings[${idx}] finding_uid must equal ${expectedUid}`);
155
+ if (item.supersedes_finding_uids !== undefined && !Array.isArray(item.supersedes_finding_uids))
156
+ fail(`findings[${idx}] supersedes_finding_uids must be a string array`);
157
+ }
158
+ return problems;
159
+ }
160
+ export function review_digest_schema_reasons(ev) {
161
+ const problems = [];
162
+ const fail = (message) => problems.push(reason("review_digest_invalid", `${label(ev)}: ${message}`));
163
+ const gate = normalize_gate(String(ev.gate ?? ""));
164
+ // design §8: review_complete uses main_adjudication as its disclosure carrier, never a second digest.
165
+ if (gate === "review_complete")
166
+ fail("main_review_digest is forbidden on review_complete; main_adjudication is the final-review disclosure carrier");
167
+ if (String(ev.created_by ?? "") !== "main-thread")
168
+ fail("main_review_digest must be created_by main-thread");
169
+ if (ev.agent_role !== undefined)
170
+ fail("main_review_digest is a main-thread artifact and must not carry agent_role");
171
+ if (!non_empty_string(ev.review_round_id) || review_round_number(gate, String(ev.review_round_id)) === null) {
172
+ fail(`review_round_id must be of the form ${gate}-r<N>`);
173
+ }
174
+ const pins = pinned_map(ev.target_refs);
175
+ if (!Array.isArray(ev.target_refs) || pins.size === 0 || pins.size !== ev.target_refs.length || [...pins.values()].some((sha) => !sha)) {
176
+ fail("target_refs must be a non-empty list of {path, blob_sha} pins");
177
+ }
178
+ if (string_list(ev.source_review_evidence_refs).length === 0)
179
+ fail("source_review_evidence_refs must reference every role review of this round");
180
+ if (!Array.isArray(ev.previous_digest_refs))
181
+ fail("previous_digest_refs must be an array (empty only for round 1)");
182
+ if (!Array.isArray(ev.finding_dispositions)) {
183
+ fail("finding_dispositions must be an array");
184
+ return problems;
185
+ }
186
+ let pending = false;
187
+ for (const [idx, item] of ev.finding_dispositions.entries()) {
188
+ if (!isObject(item)) {
189
+ fail(`finding_dispositions[${idx}] must be an object`);
190
+ continue;
191
+ }
192
+ for (const field of ["finding_id", "finding_uid", "origin_review_evidence_id", "summary", "rationale", "route", "route_reason"]) {
193
+ if (!non_empty_string(item[field]))
194
+ fail(`finding_dispositions[${idx}] missing ${field}`);
195
+ }
196
+ if (non_empty_string(item.route) && !DISCLOSURE_ROUTES.has(String(item.route))) {
197
+ fail(`finding_dispositions[${idx}] route=${repr(item.route)} not in ${renderList([...DISCLOSURE_ROUTES].sort())}`);
198
+ }
199
+ if (!FINDING_TYPES.has(String(item.finding_type)))
200
+ fail(`finding_dispositions[${idx}] finding_type=${repr(item.finding_type)} invalid`);
201
+ if (!FINDING_CATEGORIES.has(String(item.category)))
202
+ fail(`finding_dispositions[${idx}] category=${repr(item.category)} invalid`);
203
+ const materialList = string_list(item.material_categories);
204
+ if (materialList.some((cat) => !MATERIAL_CATEGORIES.has(cat)))
205
+ fail(`finding_dispositions[${idx}] material_categories outside ${renderList([...MATERIAL_CATEGORIES].sort())}`);
206
+ if (materialList.length > 0 && !non_empty_string(item.decision_scope_key))
207
+ fail(`finding_dispositions[${idx}] material disposition requires decision_scope_key`);
208
+ const disposition = String(item.disposition ?? "");
209
+ if (!FINDING_DISPOSITIONS.has(disposition)) {
210
+ fail(`finding_dispositions[${idx}] disposition=${repr(item.disposition)} not in ${renderList([...FINDING_DISPOSITIONS].sort())}`);
211
+ continue;
212
+ }
213
+ // per-disposition proof shape (design §4.2 proof table)
214
+ if (disposition === "fixed" && string_list(item.artifact_update_refs).length === 0 && string_list(item.evidence_refs).length === 0) {
215
+ fail(`finding_dispositions[${idx}] fixed requires artifact_update_refs or evidence_refs`);
216
+ }
217
+ if (disposition === "false_positive" && (!Array.isArray(item.source_refs) || item.source_refs.length === 0)) {
218
+ fail(`finding_dispositions[${idx}] false_positive requires source_refs`);
219
+ }
220
+ if (disposition === "user_decided" && string_list(item.user_decision_refs).length === 0) {
221
+ fail(`finding_dispositions[${idx}] user_decided requires user_decision_refs`);
222
+ }
223
+ if (disposition === "needs_user_decision")
224
+ pending = true;
225
+ }
226
+ if (pending && ev.status === "pass")
227
+ fail("digest with needs_user_decision dispositions must have status blocked, not pass");
228
+ return problems;
229
+ }
230
+ export function user_decision_schema_reasons(ev) {
231
+ const problems = [];
232
+ const fail = (message) => problems.push(reason("user_decision_invalid", `${label(ev)}: ${message}`));
233
+ const gate = normalize_gate(String(ev.gate ?? ""));
234
+ if (String(ev.created_by ?? "") !== "user")
235
+ fail("user_review_decision must be created_by user");
236
+ if (!USER_DECISION_OPTIONS.has(String(ev.decision)))
237
+ fail(`decision=${repr(ev.decision)} not in ${renderList([...USER_DECISION_OPTIONS].sort())}`);
238
+ if (string_list(ev.finding_uids).length === 0)
239
+ fail("finding_uids must name the exact finding_uid(s) being decided");
240
+ if (!non_empty_string(ev.decision_scope_key))
241
+ fail("decision_scope_key is required");
242
+ if (ev.material_categories !== undefined && string_list(ev.material_categories).some((cat) => !MATERIAL_CATEGORIES.has(cat))) {
243
+ fail(`material_categories outside ${renderList([...MATERIAL_CATEGORIES].sort())}`);
244
+ }
245
+ if (pinned_map(ev.confirmed_refs).size === 0)
246
+ fail("confirmed_refs must pin the artifact blob the user confirmed");
247
+ if (!non_empty_string(ev.review_round_id) || review_round_number(gate, String(ev.review_round_id)) === null) {
248
+ fail(`review_round_id must be of the form ${gate}-r<N>`);
249
+ }
250
+ if (String(ev.decision) === "option_d_custom") {
251
+ // D is a first-class disposition, not a free-text note (design §4.3).
252
+ if (!non_empty_string(ev.user_text))
253
+ fail("option_d_custom requires user_text with the user's verbatim words");
254
+ const structured = ev.structured_decision;
255
+ if (!isObject(structured)) {
256
+ fail("option_d_custom requires structured_decision");
257
+ }
258
+ else {
259
+ for (const field of ["scope", "non_goals", "acceptance_impact", "test_impact"]) {
260
+ if (!Array.isArray(structured[field]))
261
+ fail(`structured_decision.${field} must be an array (empty when unaffected)`);
262
+ }
263
+ for (const field of ["requires_artifact_update", "requires_rereview"]) {
264
+ if (typeof structured[field] !== "boolean")
265
+ fail(`structured_decision.${field} must be a boolean`);
266
+ }
267
+ }
268
+ }
269
+ return problems;
270
+ }
271
+ export function standing_authorization_schema_reasons(ev) {
272
+ const problems = [];
273
+ const fail = (message) => problems.push(reason("standing_authorization_invalid", `${label(ev)}: ${message}`));
274
+ if (String(ev.created_by ?? "") !== "user")
275
+ fail("review_standing_authorization must be created_by user; the main thread can never grant itself authority");
276
+ if (!non_empty_string(ev.confirmation_text))
277
+ fail("confirmation_text is required");
278
+ const allowed = string_list(ev.allowed_categories);
279
+ if (!Array.isArray(ev.allowed_categories) || allowed.length === 0)
280
+ fail("allowed_categories must be a non-empty explicit list");
281
+ if (!Array.isArray(ev.excluded_categories))
282
+ fail("excluded_categories must be an explicit array (empty allowed)");
283
+ const excluded = string_list(ev.excluded_categories);
284
+ for (const cat of [...allowed, ...excluded]) {
285
+ if (!FINDING_CATEGORIES.has(cat))
286
+ fail(`category ${repr(cat)} not in ${renderList([...FINDING_CATEGORIES].sort())}`);
287
+ }
288
+ const overlap = allowed.filter((cat) => excluded.includes(cat));
289
+ if (overlap.length > 0)
290
+ fail(`allowed/excluded categories conflict on ${renderList(overlap.sort())}; excluded wins, fix the authorization`);
291
+ if (string_list(ev.valid_gates).length === 0)
292
+ fail("valid_gates must be a non-empty list");
293
+ if (ev.expires_at !== undefined && ev.expires_at !== null && (!non_empty_string(ev.expires_at) || Number.isNaN(Date.parse(String(ev.expires_at))))) {
294
+ fail(`expires_at must be null or a parseable timestamp: ${repr(ev.expires_at)}`);
295
+ }
296
+ return problems;
297
+ }
298
+ // Scans ALL evidence (including superseded) so finding history can never disappear;
299
+ // dispositions come from every digest, latest round wins (live beats superseded within a round).
300
+ export function build_finding_ledger(gate, evidences, beforeRound = Number.POSITIVE_INFINITY) {
301
+ const gateNorm = normalize_gate(gate);
302
+ const gateEvs = evidences.filter((ev) => isObject(ev) && !ev._invalid && normalize_gate(String(ev.gate ?? "")) === gateNorm);
303
+ const entries = new Map();
304
+ const supersededBy = new Map();
305
+ for (const ev of gateEvs) {
306
+ if (!Array.isArray(ev.findings))
307
+ continue;
308
+ const round = review_round_number(gateNorm, String(ev.review_round_id ?? "")) ?? 1;
309
+ if (round >= beforeRound)
310
+ continue;
311
+ for (const item of ev.findings) {
312
+ if (!isObject(item))
313
+ continue;
314
+ const material = string_list(item.material_categories);
315
+ const uid = String(item.finding_uid ?? `${gateNorm}:${ev.evidence_id}:${item.finding_id}`);
316
+ if (!entries.has(uid)) {
317
+ entries.set(uid, {
318
+ uid,
319
+ finding_id: String(item.finding_id ?? ""),
320
+ origin: String(ev.evidence_id ?? ""),
321
+ round,
322
+ finding_type: String(item.finding_type ?? ""),
323
+ category: String(item.category ?? ""),
324
+ material,
325
+ scope_key: String(item.decision_scope_key ?? ""),
326
+ summary: String(item.summary ?? ""),
327
+ mandatory: String(item.finding_type) === "blocker" || material.length > 0,
328
+ superseded_by: null,
329
+ disposition: null,
330
+ disposition_round: 0,
331
+ });
332
+ }
333
+ for (const oldUid of string_list(item.supersedes_finding_uids))
334
+ supersededBy.set(oldUid, uid);
335
+ }
336
+ }
337
+ const orderedDigests = gateEvs
338
+ .filter((ev) => ev.kind === "main_review_digest")
339
+ .map((dg) => ({ dg, round: review_round_number(gateNorm, String(dg.review_round_id ?? "")) ?? 1 }))
340
+ .filter(({ round }) => round < beforeRound)
341
+ .sort((a, b) => (a.round - b.round) || ((a.dg.status === "superseded" ? 0 : 1) - (b.dg.status === "superseded" ? 0 : 1)));
342
+ for (const { dg, round } of orderedDigests) {
343
+ for (const item of Array.isArray(dg.finding_dispositions) ? dg.finding_dispositions : []) {
344
+ if (!isObject(item) || typeof item.finding_uid !== "string")
345
+ continue;
346
+ const entry = entries.get(item.finding_uid);
347
+ if (entry) {
348
+ entry.disposition = item;
349
+ entry.disposition_round = round;
350
+ }
351
+ }
352
+ }
353
+ for (const [oldUid, successor] of supersededBy) {
354
+ const entry = entries.get(oldUid);
355
+ if (entry && entries.has(successor))
356
+ entry.superseded_by = successor;
357
+ }
358
+ return [...entries.values()].sort((a, b) => a.uid.localeCompare(b.uid));
359
+ }
360
+ // Deterministic render injected into round k>1 reviewer prompts; the guard byte-compares it (R3).
361
+ export function render_finding_ledger(gate, entries) {
362
+ const lines = [`[SUPERSPEC-FINDING-LEDGER gate=${normalize_gate(gate)} findings=${entries.length}]`];
363
+ for (const entry of entries) {
364
+ const disposition = entry.superseded_by
365
+ ? `superseded_by=${entry.superseded_by}`
366
+ : entry.disposition
367
+ ? String(entry.disposition.disposition ?? "unknown")
368
+ : "open";
369
+ lines.push(`- ${entry.uid} | type=${entry.finding_type} | category=${entry.category} | material=[${entry.material.join(",")}] | disposition=${disposition} | ${entry.summary}`);
370
+ }
371
+ lines.push("[/SUPERSPEC-FINDING-LEDGER]");
372
+ return lines.join("\n");
373
+ }
374
+ // ── Cross-evidence binding checks ───────────────────────────────────────────
375
+ function user_decision_binding_reasons(gate, entry, disp, refId, byId) {
376
+ const fail = (why) => [reason("user_decision_unbound", `${gate}: ${entry.uid}: user decision ${refId} ${why}`, [entry.uid, refId])];
377
+ const target = byId.get(refId);
378
+ if (!target)
379
+ return fail("does not exist");
380
+ if (target.kind !== "user_review_decision")
381
+ return fail(`is kind ${repr(target.kind)}, not user_review_decision`);
382
+ if (String(target.created_by ?? "") !== "user")
383
+ return fail("must be created_by user");
384
+ if (target.status !== "pass")
385
+ return fail(`must be status pass, got ${repr(target.status)}`);
386
+ if (!string_list(target.finding_uids).includes(entry.uid))
387
+ return fail("does not name this finding_uid (bare finding_id matching is insufficient)");
388
+ if (entry.scope_key && String(target.decision_scope_key ?? "") !== entry.scope_key)
389
+ return fail(`decision_scope_key ${repr(target.decision_scope_key)} does not match the finding (${entry.scope_key})`);
390
+ const decidedMaterial = new Set(string_list(target.material_categories));
391
+ if (!entry.material.every((cat) => decidedMaterial.has(cat)))
392
+ return fail("does not cover every material category of the finding");
393
+ const decisionRound = review_round_number(gate, String(target.review_round_id ?? ""));
394
+ if (decisionRound !== null && decisionRound < entry.round)
395
+ return fail("belongs to an earlier round than the origin finding (structural ordering, P2-1)");
396
+ // The user must have confirmed the exact artifact version the origin review pinned.
397
+ const originPins = pinned_map(byId.get(entry.origin)?.target_refs);
398
+ const confirmed = pinned_map(target.confirmed_refs);
399
+ let pinOk = confirmed.size > 0;
400
+ for (const [path, sha] of confirmed) {
401
+ if (originPins.size > 0 && originPins.has(path) && originPins.get(path) !== sha)
402
+ pinOk = false;
403
+ }
404
+ if (!pinOk)
405
+ return fail("confirmed_refs must pin the artifact blob the origin review saw");
406
+ const problems = [];
407
+ if (String(target.decision) === "option_d_custom" && isObject(target.structured_decision)) {
408
+ const structured = target.structured_decision;
409
+ if (structured.requires_artifact_update === true && string_list(disp.artifact_update_refs).length === 0) {
410
+ problems.push(reason("artifact_update_required", `${gate}: ${entry.uid}: user decision ${refId} requires an artifact update; the consuming disposition must cite artifact_update_refs`, [entry.uid, refId]));
411
+ }
412
+ if (structured.requires_rereview === true && (decisionRound === null || entry.disposition_round <= decisionRound)) {
413
+ problems.push(reason("rereview_required", `${gate}: ${entry.uid}: user decision ${refId} requires a re-review; the consuming digest round must be structurally later than the decision round (P2-1)`, [entry.uid, refId]));
414
+ }
415
+ }
416
+ return problems;
417
+ }
418
+ function standing_authorization_binding_reasons(gate, entry, refId, byId) {
419
+ const fail = (why) => [reason("standing_authorization_unbound", `${gate}: ${entry.uid}: standing authorization ${refId} ${why}`, [entry.uid, refId])];
420
+ const target = byId.get(refId);
421
+ if (!target)
422
+ return fail("does not exist");
423
+ if (target.kind !== "review_standing_authorization")
424
+ return fail(`is kind ${repr(target.kind)}, not review_standing_authorization`);
425
+ if (String(target.created_by ?? "") !== "user")
426
+ return fail("must be created_by user");
427
+ if (target.status !== "pass")
428
+ return fail(`must be status pass, got ${repr(target.status)}`);
429
+ if (entry.finding_type === "blocker")
430
+ return fail("can never cover a blocker; blockers need a fix or an explicit user decision");
431
+ if (!string_list(target.valid_gates).includes(gate))
432
+ return fail(`does not list ${gate} in valid_gates`);
433
+ const allowed = new Set(string_list(target.allowed_categories));
434
+ for (const cat of string_list(target.excluded_categories))
435
+ allowed.delete(cat);
436
+ if (!allowed.has(entry.category))
437
+ return fail(`does not allow category ${repr(entry.category)} (excluded wins over allowed; generic confirmation text grants nothing)`);
438
+ if (!entry.material.every((cat) => allowed.has(cat)))
439
+ return fail("does not allow every material category of the finding");
440
+ if (target.expires_at !== undefined && target.expires_at !== null) {
441
+ const ts = Date.parse(String(target.expires_at));
442
+ if (Number.isNaN(ts) || ts <= Date.now())
443
+ return fail(`expired at ${repr(target.expires_at)}`);
444
+ }
445
+ return [];
446
+ }
447
+ // ── Gate-level disclosure check (design §6, review_disclosure_complete) ─────
448
+ export function review_disclosure_reasons(gate, changeRoot, evidences) {
449
+ const targetPaths = REVIEW_TARGETS_BY_GATE[gate];
450
+ if (!targetPaths)
451
+ return [];
452
+ const valid = evidences.filter((ev) => isObject(ev) && !ev._invalid);
453
+ const gateEvs = valid.filter((ev) => normalize_gate(String(ev.gate ?? "")) === gate);
454
+ const reviews = gateEvs.filter((ev) => Boolean(ev.agent_role) && (ev.review_round_id !== undefined || Array.isArray(ev.findings)));
455
+ const digests = gateEvs.filter((ev) => ev.kind === "main_review_digest");
456
+ // Grandfathering (P2-3): changes whose review evidence predates the disclosure loop keep the
457
+ // legacy judgment; new round-tagged evidence or any digest switches the full check on.
458
+ // Gates born after the loop have no legacy population, so the loop is unconditionally required.
459
+ if (reviews.length === 0 && digests.length === 0) {
460
+ if (!DISCLOSURE_REQUIRED_GATES.has(gate))
461
+ return [];
462
+ return [reason("missing_review_digest", `${gate}: the disclosure loop is mandatory on this gate; need round-tagged role review evidence (review_round_id ${gate}-r1, findings[]) plus a main_review_digest`)];
463
+ }
464
+ const out = [];
465
+ const dead = new Set(valid.filter((ev) => ev.status === "superseded" && ev.supersedes).map((ev) => String(ev.supersedes)));
466
+ const byId = new Map();
467
+ for (const ev of valid) {
468
+ if (typeof ev.evidence_id === "string" && ev.evidence_id)
469
+ byId.set(ev.evidence_id, ev);
470
+ }
471
+ // Round continuity (§5 rule 8): <gate>-r1..rN with no gaps, judged over ALL evidence so a
472
+ // deleted middle round cannot hide.
473
+ const roundNumbers = new Set();
474
+ let malformedRound = false;
475
+ for (const ev of [...reviews, ...digests]) {
476
+ const num = review_round_number(gate, String(ev.review_round_id ?? ""));
477
+ if (num === null) {
478
+ malformedRound = true;
479
+ out.push(reason("review_round_discontinuous", `${label(ev)}: review_round_id must be of the form ${gate}-r<N>: ${repr(ev.review_round_id)}`));
480
+ }
481
+ else {
482
+ roundNumbers.add(num);
483
+ }
484
+ }
485
+ const latestRound = roundNumbers.size > 0 ? Math.max(...roundNumbers) : 0;
486
+ if (!malformedRound && latestRound > 0) {
487
+ const missing = [];
488
+ for (let k = 1; k <= latestRound; k++) {
489
+ if (!roundNumbers.has(k))
490
+ missing.push(`${gate}-r${k}`);
491
+ }
492
+ if (missing.length > 0)
493
+ out.push(reason("review_round_discontinuous", `${gate}: review rounds must be continuous r1..r${latestRound}; missing ${renderList(missing)}`, missing));
494
+ }
495
+ // Digest chain (previous_digest_refs) must link every digest round to its predecessor.
496
+ const digestsByRound = new Map();
497
+ for (const dg of digests) {
498
+ const num = review_round_number(gate, String(dg.review_round_id ?? ""));
499
+ if (num === null)
500
+ continue;
501
+ digestsByRound.set(num, [...(digestsByRound.get(num) ?? []), dg]);
502
+ }
503
+ for (const [num, list] of [...digestsByRound.entries()].sort((a, b) => a[0] - b[0])) {
504
+ if (num <= 1)
505
+ continue;
506
+ const prevIds = new Set((digestsByRound.get(num - 1) ?? []).map((dg) => String(dg.evidence_id)));
507
+ for (const dg of list) {
508
+ const refs = string_list(dg.previous_digest_refs);
509
+ if (prevIds.size === 0) {
510
+ out.push(reason("digest_chain_broken", `${label(dg)}: round ${num} digest has no round ${num - 1} digest to chain to`));
511
+ }
512
+ else if (!refs.some((id) => prevIds.has(id))) {
513
+ out.push(reason("digest_chain_broken", `${label(dg)}: previous_digest_refs must include the round ${num - 1} digest (${renderList([...prevIds].sort())})`));
514
+ }
515
+ }
516
+ }
517
+ // Lazy stale check (R1) with set equality (P1-6): pinned set must equal the currently
518
+ // enumerated target set (globs expanded), path by path and blob by blob.
519
+ const currentSet = enumerate_review_targets(gate, changeRoot);
520
+ const setMatches = (refs) => {
521
+ if (currentSet === null)
522
+ return true;
523
+ const pinned = pinned_map(refs);
524
+ if (pinned.size !== currentSet.size)
525
+ return false;
526
+ for (const [rel, sha] of currentSet) {
527
+ if (pinned.get(rel) !== sha)
528
+ return false;
529
+ }
530
+ return true;
531
+ };
532
+ const liveReviews = reviews.filter((ev) => ev.status === "pass" && !dead.has(String(ev.evidence_id)));
533
+ for (const ev of liveReviews) {
534
+ if (!setMatches(ev.target_refs)) {
535
+ out.push(reason("review_round_stale", `${label(ev)}: live ${gate} review must pin the current target set (${renderList(targetPaths)}); supersede it and run a new round`));
536
+ }
537
+ }
538
+ // The latest round needs a live digest: that digest IS the user-visible disclosure.
539
+ const latestDigests = (digestsByRound.get(latestRound) ?? []).filter((dg) => !dead.has(String(dg.evidence_id)) && (dg.status === "pass" || dg.status === "blocked"));
540
+ const latestDigest = latestDigests.find((dg) => dg.status === "pass") ?? latestDigests[0] ?? null;
541
+ const latestRoundReviews = liveReviews.filter((ev) => review_round_number(gate, String(ev.review_round_id ?? "")) === latestRound);
542
+ if (latestRound > 0 && !latestDigest) {
543
+ out.push(reason("missing_review_digest", `${gate}: round ${gate}-r${latestRound} has no live main_review_digest; findings must be disclosed to the user before the gate can pass`));
544
+ }
545
+ if (latestDigest) {
546
+ if (!setMatches(latestDigest.target_refs)) {
547
+ out.push(reason("review_digest_stale", `${label(latestDigest)}: digest target_refs must equal the current target set (${renderList(targetPaths)})`));
548
+ }
549
+ const dispositionUids = new Set((Array.isArray(latestDigest.finding_dispositions) ? latestDigest.finding_dispositions : [])
550
+ .filter(isObject)
551
+ .map((item) => String(item.finding_uid ?? "")));
552
+ const sourceRefs = new Set(string_list(latestDigest.source_review_evidence_refs));
553
+ for (const ev of latestRoundReviews) {
554
+ if (!sourceRefs.has(String(ev.evidence_id))) {
555
+ out.push(reason("review_digest_invalid", `${label(latestDigest)}: digest must reference round ${latestRound} review ${ev.evidence_id} in source_review_evidence_refs`));
556
+ }
557
+ for (const item of Array.isArray(ev.findings) ? ev.findings : []) {
558
+ if (!isObject(item))
559
+ continue;
560
+ const uid = String(item.finding_uid ?? "");
561
+ if (!dispositionUids.has(uid)) {
562
+ out.push(reason("finding_undisclosed", `${label(latestDigest)}: finding ${uid} from ${ev.evidence_id} has no disposition in the round ${latestRound} digest`, [uid]));
563
+ }
564
+ }
565
+ }
566
+ }
567
+ // Append-only ledger closure: every mandatory finding in history needs a terminal disposition
568
+ // with user anchors for material findings (rules 6-11).
569
+ const ledger = build_finding_ledger(gate, evidences);
570
+ const ledgerUids = new Set(ledger.map((entry) => entry.uid));
571
+ const acceptedMaterialUids = [];
572
+ for (const entry of ledger) {
573
+ if (entry.superseded_by)
574
+ continue;
575
+ const disp = entry.disposition;
576
+ if (!disp) {
577
+ if (entry.mandatory) {
578
+ out.push(reason("finding_unresolved", `${gate}: ${entry.uid} (${entry.finding_type}) has no terminal disposition in any main_review_digest; history cannot be erased by a clean rerun`, [entry.uid]));
579
+ }
580
+ continue;
581
+ }
582
+ const dispositionKind = String(disp.disposition ?? "");
583
+ if (dispositionKind === "needs_user_decision") {
584
+ out.push(reason("needs_user_decision_pending", `${gate}: ${entry.uid} is waiting for the user's A/B/C/D decision; the main thread must stop and disclose, not self-resolve`, [entry.uid]));
585
+ continue;
586
+ }
587
+ // Disposition identity consistency (P0-2): classification belongs to the reviewer.
588
+ const mismatches = [];
589
+ if (String(disp.finding_type ?? "") !== entry.finding_type)
590
+ mismatches.push("finding_type");
591
+ if (String(disp.category ?? "") !== entry.category)
592
+ mismatches.push("category");
593
+ const dispMaterial = string_list(disp.material_categories);
594
+ if (dispMaterial.slice().sort().join(",") !== entry.material.slice().sort().join(","))
595
+ mismatches.push("material_categories");
596
+ if (entry.material.length > 0 && String(disp.decision_scope_key ?? "") !== entry.scope_key)
597
+ mismatches.push("decision_scope_key");
598
+ if (mismatches.length > 0) {
599
+ out.push(reason("finding_identity_mismatch", `${gate}: disposition for ${entry.uid} rewrites ${renderList(mismatches)}; identity fields must match the origin finding verbatim (P0-2)`, [entry.uid]));
600
+ }
601
+ if (entry.material.length > 0 && String(disp.summary ?? "") !== entry.summary) {
602
+ out.push(reason("finding_summary_not_verbatim", `${gate}: material disposition for ${entry.uid} must copy the origin finding summary verbatim so the user sees the reviewer's words (P1-1)`, [entry.uid]));
603
+ }
604
+ if (dispositionKind === "accepted_deviation" && entry.material.length > 0)
605
+ acceptedMaterialUids.push(entry.uid);
606
+ const decisionRefs = string_list(disp.user_decision_refs);
607
+ const authRefs = string_list(disp.standing_authorization_refs);
608
+ const baselineRefs = string_list(disp.baseline_decision_refs);
609
+ if (entry.material.length > 0 && decisionRefs.length === 0 && authRefs.length === 0 && baselineRefs.length === 0) {
610
+ out.push(reason("user_decision_unbound", `${gate}: material finding ${entry.uid} (${dispositionKind}) must cite user_decision_refs, standing_authorization_refs, or baseline_decision_refs; the main thread cannot close material findings on its own`, [entry.uid]));
611
+ }
612
+ for (const refId of decisionRefs)
613
+ out.push(...user_decision_binding_reasons(gate, entry, disp, refId, byId));
614
+ for (const refId of authRefs)
615
+ out.push(...standing_authorization_binding_reasons(gate, entry, refId, byId));
616
+ for (const refId of baselineRefs) {
617
+ const target = byId.get(refId);
618
+ if (!target || String(target.created_by ?? "") !== "user") {
619
+ out.push(reason("user_decision_unbound", `${gate}: ${entry.uid}: baseline decision ${refId} must resolve to user-created evidence`, [entry.uid, refId]));
620
+ }
621
+ }
622
+ }
623
+ // Digests must not dispose findings that never existed in any review, and every disposition
624
+ // route must be legal for this gate (P1-4): e.g. a proposal finding proving discovery is
625
+ // incomplete must route return_explore instead of being patched in place.
626
+ const legalRoutes = DISCLOSURE_ROUTES_BY_GATE[gate];
627
+ for (const dg of digests) {
628
+ for (const item of Array.isArray(dg.finding_dispositions) ? dg.finding_dispositions : []) {
629
+ if (!isObject(item))
630
+ continue;
631
+ if (typeof item.finding_uid === "string" && item.finding_uid && !ledgerUids.has(item.finding_uid)) {
632
+ out.push(reason("review_digest_invalid", `${label(dg)}: disposition references unknown finding_uid ${item.finding_uid}`, [item.finding_uid]));
633
+ }
634
+ const route = String(item.route ?? "");
635
+ if (legalRoutes && route && !legalRoutes.has(route)) {
636
+ out.push(reason("finding_route_invalid", `${label(dg)}: route ${repr(route)} is not legal on ${gate}; allowed routes: ${renderList([...legalRoutes].sort())}`, [String(item.finding_uid ?? "")]));
637
+ }
638
+ }
639
+ }
640
+ // Accepted material deviations must be re-acknowledged by the clean round (P1-2).
641
+ if (acceptedMaterialUids.length > 0) {
642
+ for (const ev of latestRoundReviews) {
643
+ const acked = new Set(string_list(ev.acknowledged_accepted_deviation_uids));
644
+ const missing = acceptedMaterialUids.filter((uid) => !acked.has(uid)).sort();
645
+ if (missing.length > 0) {
646
+ out.push(reason("accepted_deviation_unacknowledged", `${label(ev)}: clean-round review must list accepted deviations ${renderList(missing)} in acknowledged_accepted_deviation_uids (P1-2)`, missing));
647
+ }
648
+ }
649
+ }
650
+ // Re-review prompt injection (R3): round k>1 prompts must embed the tool-rendered ledger.
651
+ if (latestRound > 1) {
652
+ const expected = render_finding_ledger(gate, build_finding_ledger(gate, evidences, latestRound));
653
+ for (const ev of latestRoundReviews) {
654
+ const promptRel = typeof ev.prompt_ref === "string" ? ev.prompt_ref : "";
655
+ const promptAbs = promptRel ? safe_within(changeRoot, promptRel) : null;
656
+ const content = promptAbs && existsSync(promptAbs) && statSync(promptAbs).isFile() ? readFileSync(promptAbs, "utf8") : "";
657
+ if (!content.includes(expected)) {
658
+ out.push(reason("ledger_injection_missing", `${label(ev)}: round ${latestRound} prompt must embed the tool-rendered finding ledger for rounds < ${latestRound} (R3); regenerate the prompt via render_finding_ledger`));
659
+ }
660
+ }
661
+ }
662
+ // A blocked digest with nothing pending is a bookkeeping contradiction; fail closed.
663
+ if (latestDigest && latestDigest.status !== "pass" && out.length === 0) {
664
+ out.push(reason("review_digest_invalid", `${label(latestDigest)}: digest status is ${repr(latestDigest.status)} but no findings are pending; re-issue the digest with status pass`));
665
+ }
666
+ // Round budget (R5): beyond the budget the only legal route is escalation to the user.
667
+ if (latestRound > REVIEW_ROUND_BUDGET && out.length > 0) {
668
+ out.push(reason("round_budget_exhausted", `${gate}: ${latestRound} review rounds exceed the budget of ${REVIEW_ROUND_BUDGET} without convergence; route=escalate_round_budget — stop iterating and put the open findings in front of the user`));
669
+ }
670
+ return out;
671
+ }