pincer-workflow 0.4.1 → 0.6.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 (63) hide show
  1. package/README.md +9 -7
  2. package/bin/pincer.js +58 -5
  3. package/package.json +3 -3
  4. package/template/.agents/skills/pincer-code/SKILL.md +117 -12
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +57 -10
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +48 -8
  7. package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
  8. package/template/.agents/skills/pincer-release/SKILL.md +32 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +25 -2
  10. package/template/.claude/commands/pincer-code.md +117 -12
  11. package/template/.claude/commands/pincer-evaluate.md +57 -10
  12. package/template/.claude/commands/pincer-narrow.md +48 -8
  13. package/template/.claude/commands/pincer-plan.md +12 -4
  14. package/template/.claude/commands/pincer-release.md +32 -3
  15. package/template/.claude/commands/pincer-status.md +25 -2
  16. package/template/.claude/hooks/hook-policy.cjs +24 -3
  17. package/template/.claude/references/prd-template.md +11 -4
  18. package/template/.claude/references/ticket-template.md +4 -0
  19. package/template/.codex/README.md +3 -2
  20. package/template/.github/prompts/pincer-code.prompt.md +117 -12
  21. package/template/.github/prompts/pincer-evaluate.prompt.md +57 -10
  22. package/template/.github/prompts/pincer-narrow.prompt.md +48 -8
  23. package/template/.github/prompts/pincer-plan.prompt.md +12 -4
  24. package/template/.github/prompts/pincer-release.prompt.md +32 -3
  25. package/template/.github/prompts/pincer-status.prompt.md +25 -2
  26. package/template/AGENTS.md +22 -0
  27. package/template/docs/dry-run-checklist.md +70 -6
  28. package/template/docs/release-checklist.md +5 -2
  29. package/template/docs/runtime-contracts.md +1683 -0
  30. package/template/scripts/pincer-evidence.cjs +13 -229
  31. package/template/scripts/pincer-runtime/adopt.cjs +132 -0
  32. package/template/scripts/pincer-runtime/agreement.cjs +240 -0
  33. package/template/scripts/pincer-runtime/authorization.cjs +167 -0
  34. package/template/scripts/pincer-runtime/changes.cjs +517 -0
  35. package/template/scripts/pincer-runtime/checks.cjs +48 -0
  36. package/template/scripts/pincer-runtime/coverage.cjs +361 -0
  37. package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
  38. package/template/scripts/pincer-runtime/evidence.cjs +676 -0
  39. package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
  40. package/template/scripts/pincer-runtime/gates.cjs +73 -0
  41. package/template/scripts/pincer-runtime/identity.cjs +163 -0
  42. package/template/scripts/pincer-runtime/impact.cjs +177 -0
  43. package/template/scripts/pincer-runtime/io.cjs +41 -0
  44. package/template/scripts/pincer-runtime/lifecycle.cjs +311 -0
  45. package/template/scripts/pincer-runtime/locator.cjs +158 -0
  46. package/template/scripts/pincer-runtime/migrate.cjs +204 -0
  47. package/template/scripts/pincer-runtime/parse.cjs +316 -0
  48. package/template/scripts/pincer-runtime/phases.cjs +245 -0
  49. package/template/scripts/pincer-runtime/readiness.cjs +97 -0
  50. package/template/scripts/pincer-runtime/requirements.cjs +255 -0
  51. package/template/scripts/pincer-runtime/resume.cjs +205 -0
  52. package/template/scripts/pincer-runtime/routing.cjs +54 -0
  53. package/template/scripts/pincer-runtime/runner.cjs +242 -0
  54. package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
  55. package/template/scripts/pincer-runtime/source.cjs +129 -0
  56. package/template/scripts/pincer-runtime/state.cjs +314 -0
  57. package/template/scripts/pincer-runtime/status.cjs +514 -0
  58. package/template/scripts/pincer-runtime/transaction.cjs +200 -0
  59. package/template/scripts/pincer-runtime/transitions.cjs +134 -0
  60. package/template/scripts/pincer-runtime.cjs +661 -0
  61. package/template/scripts/pincer-status.sh +11 -162
  62. package/template/scripts/pincer-ticket.sh +19 -139
  63. package/template/scripts/pincer-ticket-lib.sh +0 -321
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+ // PINCER runtime — phase-specific coverage (docs/runtime-contracts.md, "Strict
3
+ // coverage" → "Phase-specific coverage"). One pure computation over the validated
4
+ // graph, consumed by `coverage`, `change complete`, `evidence export`, `ready`,
5
+ // status and resume: `structure` (every obligation linked or authorized as not
6
+ // delivered, nothing missing from the baseline), `implementation` (structure plus
7
+ // every ticket done and ready under the v5 rules) and `candidate` (the evaluated
8
+ // candidate's reconciled evidence). The three are separate fields; none implies
9
+ // another, and a linked check, a done ticket or a passing syntax check never
10
+ // becomes a delivery or adequacy verdict. A change without the capability is
11
+ // labeled `unverified`. Nothing here writes, launches or judges semantics.
12
+ const changes = require('./changes.cjs');
13
+ const routing = require('./routing.cjs');
14
+ const agreement = require('./agreement.cjs');
15
+ const authorization = require('./authorization.cjs');
16
+ const coverage = require('./coverage.cjs');
17
+ const dispositions = require('./dispositions.cjs');
18
+ const requirements = require('./requirements.cjs');
19
+ const locator = require('./locator.cjs');
20
+ const state = require('./state.cjs');
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ const STRUCTURE_ORDER = ['INPUT_INVALID', 'INVENTORY_INVALID', 'COVERAGE_INVALID', 'COVERAGE_INCOMPLETE', 'SCOPE_UNAUTHORIZED', 'OBLIGATION_MISSING'];
25
+ const byOrder = (a, b) => STRUCTURE_ORDER.indexOf(a.code) - STRUCTURE_ORDER.indexOf(b.code);
26
+
27
+ const unverified = reason => ({ strict: false, label: 'unverified', reason, inventory: null, map: null, graph: null, scope: [], structure: null, implementation: null, candidate: null, blockers: [] });
28
+
29
+ // compute(root, record, { gathered, verdict }) — `gathered` is status.render(...).gathered
30
+ // for the record (computed here when absent); `verdict` the authorization verdict.
31
+ function compute(root, record, { gathered = null, verdict = null } = {}) {
32
+ if (!record) return unverified('no change record');
33
+ if (!changes.isStrict(record)) return unverified(`strict coverage not adopted (node scripts/pincer-runtime.cjs coverage adopt --preview --change ${record.change})`);
34
+ const out = { strict: true, label: 'strict', reason: null, inventory: null, map: null, graph: null, scope: [], structure: { complete: false, problems: [] }, implementation: { complete: false, scenarios: {}, problems: [] }, candidate: { evaluated: false, candidate: null, manifest: null, delivery: null, adequacy: null, scenarios: {}, problems: [] }, blockers: [] };
35
+ const priorInventory = gid => agreement.inventoryOf(root, record, record.agreements.find(g => g.id === gid) || null);
36
+ const cov = coverage.load(root, record, { priorInventory });
37
+ out.inventory = cov.inventory ? { digest: cov.inventory.digest, requirements: Object.fromEntries(Object.entries(cov.inventory.requirements).map(([id, r]) => [id, { title: r.title, line: r.line, end: r.end, scenarios: r.scenarios }])), scenarios: Object.fromEntries(Object.entries(cov.inventory.scenarios).map(([id, s]) => [id, { requirement: s.requirement, line: s.line, end: s.end }])) } : null;
38
+ out.map = cov.map ? { path: cov.map.file, digest: cov.map.digest, checks: Object.fromEntries(Object.entries(cov.map.map.checks).map(([id, c]) => [id, { kind: c.kind, required: c.required, declared: coverage.definitionDigest(c) }])) } : null;
39
+ if (cov.code && cov.code !== 'COVERAGE_INCOMPLETE') {
40
+ out.structure.problems = cov.problems.map(detail => ({ code: cov.code, detail, ids: [] }));
41
+ out.blockers = out.structure.problems.map(p => ({ code: p.code, detail: p.detail }));
42
+ return out;
43
+ }
44
+ out.graph = cov.graph;
45
+ const v = verdict || authorization.verdict(root, record);
46
+ const scope = dispositions.scopeProblems(record, cov.graph, v);
47
+ out.scope = scope.resolved;
48
+ const problems = [...cov.graph.problems, ...scope.problems, ...dispositions.obligationProblems(dispositions.baseline(root, record), cov.inventory, cov.map.map)].sort(byOrder);
49
+ out.structure = { complete: problems.length === 0, problems };
50
+ // Implementation: every in-scope scenario's tickets done and ready; every ticket of the change done and ready.
51
+ const g = gathered || require('./status.cjs').render(root, { change: record.change }).gathered;
52
+ // A fresh clone (no local attempt history) of a completed change validates the saved
53
+ // candidate record only: a done ticket's missing local attempt is its stated limit, not
54
+ // unfinished work. Before completion the v5 rule holds: a fresh clone must verify.
55
+ const localUnavailable = !state.hasIndex(root) && record.lifecycle.state === 'completed';
56
+ out.implementation.limitations = localUnavailable ? ['local verification history unavailable; done tickets rely on the saved candidate evidence until verified here'] : [];
57
+ const ticketState = id => {
58
+ const t = g && g.tickets ? g.tickets.find(x => x.fields.ticket === id) : null;
59
+ if (!t) return { id, status: null, ready: false, code: 'EVIDENCE_MISSING', detail: 'no ticket file' };
60
+ const r = g.computeReadiness(t);
61
+ if (localUnavailable && t.fields.status === 'done' && !r.ready && r.reasons[0].code === 'EVIDENCE_MISSING') return { id, status: 'done', ready: true, code: null, detail: null, limitation: 'no local attempt history' };
62
+ return { id, status: t.fields.status, ready: r.ready, code: r.ready ? null : r.reasons[0].code, detail: r.ready ? null : `${r.reasons[0].detail} — ${r.reasons[0].next}` };
63
+ };
64
+ const implProblems = [];
65
+ const seen = new Set();
66
+ for (const id of requirements.sortIds(Object.keys(cov.graph.scenarios))) {
67
+ const s = cov.graph.scenarios[id];
68
+ const tickets = s.tickets.map(ticketState);
69
+ const unfinished = tickets.find(t => t.status !== 'done');
70
+ const unverifiedT = tickets.find(t => t.status === 'done' && !t.ready);
71
+ const state = unfinished ? 'unfinished' : unverifiedT ? 'unverified' : 'complete';
72
+ const detail = unfinished ? `${unfinished.id} is ${unfinished.status || 'missing'}, not done` : unverifiedT ? `${unverifiedT.id} ${unverifiedT.code}: ${unverifiedT.detail}` : null;
73
+ out.implementation.scenarios[id] = { scope: 'in-scope', requirement: s.requirement, implementation: state, tickets, checks: s.checks, detail };
74
+ if (state !== 'complete' && !seen.has(detail)) { seen.add(detail); implProblems.push({ code: unfinished ? 'LIFECYCLE_BLOCKED' : unverifiedT.code, detail: `${id}: ${detail}`, ids: [id, (unfinished || unverifiedT).id] }); }
75
+ }
76
+ for (const id of requirements.sortIds(Object.keys(cov.graph.scope))) {
77
+ const s = cov.graph.scope[id];
78
+ out.implementation.scenarios[id] = { scope: s.disposition, requirement: s.requirement, implementation: 'not applicable', tickets: [], checks: [], detail: `${s.disposition} by decision ${s.decision}` };
79
+ }
80
+ // Enabling tickets and any other ticket of the change must be done and ready too.
81
+ for (const id of Object.keys(cov.tickets || {})) {
82
+ const t = ticketState(id);
83
+ if (t.status === 'done' && t.ready) continue;
84
+ const detail = t.status !== 'done' ? `${id} is ${t.status}, not done` : `${id} ${t.code}: ${t.detail}`;
85
+ if (!implProblems.some(p => p.ids.includes(id))) implProblems.push({ code: t.status !== 'done' ? 'LIFECYCLE_BLOCKED' : t.code, detail, ids: [id] });
86
+ }
87
+ out.implementation.problems = implProblems;
88
+ out.implementation.complete = out.structure.complete && implProblems.length === 0;
89
+ candidateCoverage(root, record, out.candidate);
90
+ // A missing or stale evaluation blocks only a completed change: before completion no candidate is expected.
91
+ out.blockers = [...out.structure.problems, ...implProblems, ...(record.lifecycle.state === 'completed' ? out.candidate.problems : [])].map(p => ({ code: p.code, detail: p.detail }));
92
+ return out;
93
+ }
94
+
95
+ // Candidate coverage: the change's latest evaluation (schema 3) reconciled by the
96
+ // locator, with per-scenario dispositions and the codes that block release.
97
+ function candidateCoverage(root, record, out) {
98
+ const loc = locator.current(root, record);
99
+ if (loc.state === 'missing') { out.problems = [{ code: 'EVIDENCE_MISSING', detail: 'candidate: not evaluated', ids: [] }]; return; }
100
+ out.evaluated = true; out.candidate = loc.candidate || null; out.manifest = loc.manifest || null;
101
+ let m = null;
102
+ try { m = JSON.parse(fs.readFileSync(path.join(root, loc.manifest), 'utf8')); } catch { m = null; }
103
+ const problems = [];
104
+ if (loc.state === 'stale') problems.push({ code: 'CANDIDATE_STALE', detail: loc.text, ids: [] });
105
+ if (!m || m.schema !== 3) { problems.push({ code: 'EVIDENCE_MISSING', detail: `candidate evidence is ${m && m.schema ? `schema ${m.schema}` : 'unreadable'}, not the strict schema 3; evaluate again`, ids: [] }); out.problems = problems; return; }
106
+ const checkOf = Object.fromEntries((m.checks || []).filter(c => c && c.id).map(c => [c.id, c]));
107
+ for (const row of m.scenarios || []) {
108
+ if (!row || !row.id) continue;
109
+ const checks = (row.checks || []).map(id => { const c = checkOf[id] || {}; return { id, kind: c.kind || null, required: c.required ?? null, result: c.result || null }; });
110
+ const failing = checks.filter(c => c.required && c.result !== 'passed');
111
+ out.scenarios[row.id] = { disposition: row.disposition, checks, detail: row.disposition === 'blocked' ? `blocked by ${failing.map(c => `${c.id} (${c.kind} ${c.result || 'missing'})`).join(', ') || 'a missing check'}` : row.disposition === 'delivered' ? 'every required check passed' : `${row.disposition} by decision ${row.decision} (${row.authorization || 'unauthorized'})` };
112
+ for (const c of failing) problems.push({ code: c.kind === 'command' ? ({ failed: 'CHECK_FAILED', unverified: 'ATTEMPT_ERROR' }[c.result] || 'CHECK_FAILED') : 'REVIEW_MISSING', detail: `${row.id}: ${c.id} (${c.kind}) is ${c.result || 'missing'}`, ids: [row.id, c.id] });
113
+ }
114
+ out.delivery = m.delivery || null;
115
+ out.adequacy = m.adequacy || null;
116
+ if (!m.adequacy || m.adequacy.verdict !== 'adequate') problems.push({ code: 'ADEQUACY_REQUIRED', detail: m.adequacy ? `the reviewer judged the checks inadequate: ${m.adequacy.note}` : 'no adequacy judgment recorded', ids: [] });
117
+ out.problems = problems;
118
+ }
119
+
120
+ // The first blocking problem for a phase, in the contract's order, or null.
121
+ function firstBlocker(report, phase) {
122
+ if (!report.strict) return null;
123
+ if (report.structure.problems.length) return report.structure.problems[0];
124
+ if (phase === 'structure') return null;
125
+ if (report.implementation.problems.length) return report.implementation.problems[0];
126
+ if (phase === 'implementation') return null;
127
+ return report.candidate.problems[0] || null;
128
+ }
129
+
130
+ // --- The coverage report (docs/runtime-contracts.md, "Coverage and impact commands") ------
131
+ const RUNTIME_CMD = 'node scripts/pincer-runtime.cjs';
132
+ // One ordered next action for a strict change: the first blocking code, else the
133
+ // phase that is due (verify/start/complete/evaluate/release), or nothing to do.
134
+ function nextAction(report, record, { verdict = null, running = [], selected = true } = {}) {
135
+ const cmd = (action, command, extra = {}) => ({ action, command, ticket: null, check: null, decision: null, ...extra });
136
+ if (!report.strict) return cmd('adopt strict coverage when wanted', `${RUNTIME_CMD} coverage adopt --preview --change ${record.change}`);
137
+ const id = record.change;
138
+ // Rules 2 and 3 outrank everything below, including the agreement verdict: a
139
+ // historical change is never routed to execution or to authorization, and a
140
+ // running attempt is waited for rather than raced.
141
+ const ahead = routing.preface({ id, lifecycle: record.lifecycle, base: record.base, running });
142
+ if (ahead) return { ...cmd(ahead.action, ahead.command), ...ahead };
143
+ if (verdict && verdict.verdict !== 'current') {
144
+ if (verdict.verdict === 'DECISION_REQUIRED') return cmd("record the user's decision", `${RUNTIME_CMD} change decide ${id} --resolve ${verdict.open[0]} --reference <text> --excerpt <text>`, { decision: verdict.open[0] });
145
+ if (['AUTHORIZATION_REQUIRED', 'AGREEMENT_CHANGED'].includes(verdict.verdict)) return cmd("record the user's authorization of the current agreement", `${RUNTIME_CMD} change authorize ${id} --agreement ${verdict.current} --reference <text> --excerpt <text>`);
146
+ if (verdict.verdict === 'COVERAGE_INVALID') return cmd('repair the coverage map', verdict.detail);
147
+ if (verdict.verdict === 'INVENTORY_INVALID') return cmd('repair the PRD definitions', verdict.detail);
148
+ return cmd('repair the agreement inputs', `${verdict.verdict}: ${verdict.detail}`);
149
+ }
150
+ const p = report.structure.problems[0];
151
+ if (p) {
152
+ if (p.code === 'INVENTORY_INVALID') return cmd('repair the PRD definitions', p.detail);
153
+ if (p.code === 'COVERAGE_INVALID') return cmd('repair the coverage map', p.detail);
154
+ if (p.code === 'COVERAGE_INCOMPLETE') return cmd('author the missing coverage rows, then authorize', `edit .prd/coverage/${id}.json: ${p.detail}; then ${RUNTIME_CMD} change authorize ${id} --agreement <digest> …`);
155
+ if (p.code === 'SCOPE_UNAUTHORIZED') return cmd('record the scope decision and its user authorization', `${RUNTIME_CMD} change decide ${id} --summary <text> / --resolve D-NN …, then change authorize ${id} --agreement <digest> --decision D-NN …`, { decision: p.decision || null });
156
+ if (p.code === 'OBLIGATION_MISSING') return cmd('restore the obligation, or record the decision and the removed tombstone', p.detail);
157
+ return cmd('repair the coverage inputs', `${p.code}: ${p.detail}`);
158
+ }
159
+ const staged = routing.lifecycleAction({ id, lifecycle: record.lifecycle });
160
+ if (staged) return { ...cmd(staged.action, staged.command), ...staged };
161
+ const i = report.implementation.problems[0];
162
+ if (i) {
163
+ const ticket = i.ids.find(x => /^T-/.test(x)) || null;
164
+ if (i.code === 'LIFECYCLE_BLOCKED' && ticket) return cmd(`finish ${ticket}`, `scripts/pincer-ticket.sh start ${ticket} → verify ${ticket} → done ${ticket}`, { ticket });
165
+ if (ticket) return cmd(`re-verify ${ticket} (${i.code})`, `scripts/pincer-ticket.sh verify ${ticket}`, { ticket });
166
+ return cmd('finish the mapped work', i.detail);
167
+ }
168
+ if (record.lifecycle.state === 'active') return cmd('complete the change', `${RUNTIME_CMD} change complete ${id}`);
169
+ const c = report.candidate.problems[0];
170
+ if (c) {
171
+ const check = c.ids.find(x => /^C-/.test(x)) || null;
172
+ if (c.code === 'EVIDENCE_MISSING' && !report.candidate.evaluated) return cmd('evaluate the candidate', '/pincer-evaluate (declare the candidate, run every declared check, record the reviews and the adequacy judgment, export)');
173
+ if (['CHECK_FAILED', 'ATTEMPT_ERROR'].includes(c.code) && check) return cmd(`re-run ${check} and re-evaluate`, `${RUNTIME_CMD} check ${check} --candidate <sha>, then /pincer-evaluate`, { check });
174
+ if (c.code === 'REVIEW_MISSING' && check) return cmd(`perform and record the review ${check}`, `record ${check} with its candidate-bound artifact in the draft, then /pincer-evaluate`, { check });
175
+ if (c.code === 'ADEQUACY_REQUIRED') return cmd("record the reviewer's adequacy judgment", '/pincer-evaluate with adequacy { verdict, note } in the draft');
176
+ return cmd('evaluate the candidate again', `${c.code}: ${c.detail}`);
177
+ }
178
+ return cmd('read-only release audit', '/pincer-release');
179
+ }
180
+
181
+ // report(root, record, { gathered, verdict, generated }) → coverage JSON schema 1.
182
+ function report(root, record, { gathered = null, verdict = null, generated = null, selected = true } = {}) {
183
+ const authorization = require('./authorization.cjs');
184
+ const running = record ? require('./transaction.cjs').runningAttempts(root, record.change) : [];
185
+ const v = verdict || (record ? authorization.verdict(root, record) : null);
186
+ const r = compute(root, record, { gathered, verdict: v });
187
+ const reviewed = v && v.authorized && record ? (() => { const g = record.agreements.find(x => x.id === v.authorized.agreement); return g ? { agreement: g.id, authorization: v.authorized.id, digest: g.digest } : null; })() : null;
188
+ const base = record && r.strict ? require('./dispositions.cjs').baseline(root, record) : null;
189
+ const out = {
190
+ schema: 1, runtime: changes.RUNTIME_STRICT, generated: generated || require('./fsutil.cjs').nowIso(), root, mode: 'changes', change: record ? record.change : null,
191
+ strict: r.strict, label: r.label, reason: r.reason,
192
+ inventory: r.inventory ? { digest: r.inventory.digest, requirements: Object.entries(r.inventory.requirements).map(([id, x]) => ({ id, title: x.title, line: x.line, end: x.end, scenarios: x.scenarios })), scenarios: Object.entries(r.inventory.scenarios).map(([id, x]) => ({ id, requirement: x.requirement, line: x.line, end: x.end })) } : null,
193
+ map: r.map ? { path: r.map.path, digest: r.map.digest, checks: Object.entries(r.map.checks).map(([id, c]) => ({ id, ...c })) } : null,
194
+ agreement: { current: v && v.current ? v.current : null, reviewed, verdict: v ? v.verdict : null },
195
+ baseline: base ? { agreements: base.agreements, scenarios: Object.keys(base.scenarios) } : null,
196
+ structure: r.structure, implementation: r.implementation, candidate: r.candidate, scope: r.scope,
197
+ blockers: [
198
+ ...(running.length ? [{ code: 'ATTEMPT_RUNNING', detail: `attempt ${running[0].id} of change ${record.change} is running${running[0].alive === false ? ' (its owner is no longer running)' : ''}` }] : []),
199
+ ...(v && v.verdict !== 'current' ? [{ code: v.verdict, detail: v.detail }] : []), ...r.blockers],
200
+ next: routing.qualify(nextAction(r, record, { verdict: v, running, selected }), { id: record ? record.change : null, selected }),
201
+ };
202
+ return out;
203
+ }
204
+
205
+ function render(j) {
206
+ const lines = [];
207
+ const short = s => (typeof s === 'string' ? s.slice(0, 12) : '—');
208
+ lines.push(`PINCER coverage · ${j.generated} · ${j.root}`);
209
+ lines.push(`Change ${j.change || 'none'} · coverage ${j.label}${j.reason ? ` (${j.reason})` : ''}`);
210
+ if (j.strict) {
211
+ lines.push(`Agreement current ${short(j.agreement.current)} · reviewed ${j.agreement.reviewed ? `${j.agreement.reviewed.agreement} (${j.agreement.reviewed.authorization}, ${short(j.agreement.reviewed.digest)})` : 'none'} · verdict ${j.agreement.verdict}`);
212
+ lines.push(`Inventory ${j.inventory ? `${j.inventory.requirements.length} requirement(s), ${j.inventory.scenarios.length} scenario(s) · ${short(j.inventory.digest)}` : 'unreadable'}`);
213
+ lines.push(`Map ${j.map ? `${j.map.path} · ${short(j.map.digest)} · checks ${j.map.checks.map(c => `${c.id} (${c.kind}${c.required ? ', required' : ''})`).join(', ')}` : 'unreadable'}`);
214
+ lines.push(`Structure ${j.structure.complete ? 'complete' : `incomplete: ${j.structure.problems.map(p => `${p.code} ${p.detail}`).join('; ')}`}`);
215
+ const sc = Object.entries(j.implementation.scenarios);
216
+ lines.push(`Implementation ${j.implementation.complete ? 'complete' : 'incomplete'} · ${sc.filter(([, s]) => s.implementation === 'complete').length}/${sc.filter(([, s]) => s.scope === 'in-scope').length} in-scope scenario(s) complete${sc.some(([, s]) => s.scope !== 'in-scope') ? ` · ${sc.filter(([, s]) => s.scope !== 'in-scope').map(([id, s]) => `${id} ${s.scope}`).join(', ')}` : ''}`);
217
+ for (const [id, s] of sc) lines.push(` ${id.padEnd(6)} ${s.scope === 'in-scope' ? s.implementation.padEnd(14) : s.scope.padEnd(14)} ${s.scope === 'in-scope' ? `tickets ${s.tickets.map(t => `${t.id} ${t.status || 'missing'}${t.ready ? '' : t.code ? ` (${t.code})` : ''}`).join(', ')} · checks ${s.checks.join(', ')}` : s.detail}${j.candidate.scenarios[id] ? ` · candidate ${j.candidate.scenarios[id].disposition}` : ''}`);
218
+ const c = j.candidate;
219
+ lines.push(`Candidate ${c.evaluated ? `${c.candidate ? c.candidate.slice(0, 7) : '?'} · delivery original ${c.delivery ? c.delivery.original : '?'}, agreed ${c.delivery ? c.delivery.agreed : '?'} · adequacy ${c.adequacy ? `${c.adequacy.verdict} ("${c.adequacy.note}")` : 'not recorded'}` : 'not evaluated · adequacy: not recorded'}${c.problems.length ? ` · ${c.problems.map(p => `${p.code} ${p.detail}`).join('; ')}` : ''}`);
220
+ if (j.scope.length) lines.push(`Scope ${j.scope.map(s => `${s.id} ${s.disposition} by ${s.decision} (${s.authorization}; reviewer judgment: "${s.excerpt}" must support it)`).join('; ')}`);
221
+ }
222
+ lines.push(`Blockers ${j.blockers.length ? j.blockers.map(b => `${b.code} ${b.detail}`).join('\n ') : 'none'}`);
223
+ lines.push(`Next ${j.next.action}: ${j.next.command}`);
224
+ return `${lines.join('\n')}\n`;
225
+ }
226
+
227
+ // A one-line summary for status and resume.
228
+ function summary(r) {
229
+ if (!r.strict) return { strict: false, label: 'unverified', reason: r.reason, structure: null, implementation: null, candidate: null };
230
+ const sc = Object.values(r.implementation.scenarios);
231
+ return {
232
+ strict: true, label: 'strict', reason: null,
233
+ structure: { complete: r.structure.complete, problems: r.structure.problems.map(p => ({ code: p.code, detail: p.detail })) },
234
+ implementation: { complete: r.implementation.complete, scenarios: { total: sc.length, complete: sc.filter(s => s.implementation === 'complete').length, unfinished: sc.filter(s => s.implementation === 'unfinished').length, unverified: sc.filter(s => s.implementation === 'unverified').length, dispositioned: sc.filter(s => s.scope !== 'in-scope').length } },
235
+ candidate: r.candidate.evaluated ? { evaluated: true, delivery: r.candidate.delivery, adequacy: r.candidate.adequacy ? r.candidate.adequacy.verdict : null } : { evaluated: false, delivery: null, adequacy: null },
236
+ };
237
+ }
238
+ function summaryLine(r, record) {
239
+ if (!r.strict) return `Coverage unverified · ${r.reason}`;
240
+ const s = summary(r);
241
+ const reviewed = record && record.authorizations.length ? `${record.authorizations.at(-1).agreement} (${record.authorizations.at(-1).id})` : 'none';
242
+ return `Coverage strict · agreement ${reviewed} · structure ${s.structure.complete ? 'complete' : `incomplete (${s.structure.problems[0].code})`} · implementation ${s.implementation.scenarios.complete}/${s.implementation.scenarios.total - s.implementation.scenarios.dispositioned} scenarios${s.implementation.scenarios.dispositioned ? ` (${s.implementation.scenarios.dispositioned} dispositioned)` : ''} · candidate ${s.candidate.evaluated ? `${s.candidate.delivery ? `delivery original ${s.candidate.delivery.original}, agreed ${s.candidate.delivery.agreed}` : 'delivery not recorded (evidence predates strict coverage)'}, adequacy ${s.candidate.adequacy || 'not recorded'}` : 'not evaluated'}`;
243
+ }
244
+
245
+ module.exports = { STRUCTURE_ORDER, compute, firstBlocker, nextAction, report, render, summary, summaryLine };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+ // PINCER runtime — the one readiness computation (docs/runtime-contracts.md,
3
+ // "Readiness and reason codes"). Pure functions over parsed inputs: the human
4
+ // status, the JSON status, `ready`, `done`, `start` and release all consume
5
+ // these so they cannot disagree. Nothing here reads the clock, executes a
6
+ // command or writes a file.
7
+ const parse = require('./parse.cjs');
8
+ const { validateAttempt } = require('./state.cjs');
9
+
10
+ const reason = (code, detail, next) => ({ code, detail, next });
11
+
12
+ // Legacy mode: the v0.4.1 rules over the ticket's own receipts. Returns
13
+ // { ready, reasons, legacyMessage } where legacyMessage is the exact wording
14
+ // the Bash helper printed for a done ticket that needs attention.
15
+ function legacyTicketReadiness(text, fields) {
16
+ const receipt = fields.verified || '';
17
+ const attempt = fields.last_check || '';
18
+ const hash = parse.legacyBlockHash(text);
19
+ const fail = (message, code, detail = message) => ({ ready: false, legacyMessage: message, reasons: [reason(code, detail, 're-run verify')] });
20
+ if (!attempt) return fail('missing latest verification outcome — re-run verify', 'EVIDENCE_MISSING', 'no recorded verification attempt');
21
+ const parts = attempt.split(/[ \t]+/);
22
+ if (!(parts.length === 3 && parse.TIMESTAMP.test(parts[0]) && parts[1] === 'passed' && /^[a-f0-9]{12}$/.test(parts[2])) || parts[2] !== hash) {
23
+ const outcomeCode = parts[1] === 'running' ? 'ATTEMPT_RUNNING' : parts[1] === 'interrupted' ? 'ATTEMPT_INTERRUPTED' : parts[1] === 'failed' ? 'CHECK_FAILED' : 'CHECK_CHANGED';
24
+ const code = parts[1] === 'passed' && parts[2] !== hash ? 'CHECK_CHANGED' : outcomeCode;
25
+ return fail(`latest verification: ${attempt} — re-run verify`, code, code === 'CHECK_CHANGED' ? 'the Verification block changed after the latest pass' : `latest verification: ${attempt}`);
26
+ }
27
+ if (!receipt) return fail('done without a verification receipt — re-run verify', 'EVIDENCE_MISSING', 'done without a verification receipt');
28
+ const rparts = receipt.split(/[ \t]+/);
29
+ if (!(rparts.length === 2 && parse.TIMESTAMP.test(rparts[0]) && /^[a-f0-9]{12}$/.test(rparts[1])) || rparts[1] !== hash) {
30
+ return fail('stale or malformed verification receipt — re-run verify', 'CHECK_CHANGED', 'stale or malformed verification receipt');
31
+ }
32
+ if (parse.unticked(text).length) return fail('unticked acceptance criteria — complete and re-run verify', 'CRITERIA_UNTICKED', 'unticked acceptance criteria');
33
+ return { ready: true, reasons: [] };
34
+ }
35
+
36
+ // Migrated mode: readiness derives from the latest attempt for the ticket's
37
+ // context and the current inputs. `current` carries the digests computed now;
38
+ // `sourceProblems` are snapshot problems (secret path, unsupported input);
39
+ // `contextKey` is the key the attempt was read for and `pointedId` the id the
40
+ // index names for it (the record must match both); the attempt's artifacts
41
+ // carry `missing`/`altered` from state.inspectArtifacts.
42
+ function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt, current, sourceProblems = [], changedPaths = [], contextKey = null, pointedId = null, mode = 'migrated', strict = false }) {
43
+ const reasons = [];
44
+ for (const p of sourceProblems) reasons.push(reason(p.code, p.detail, p.code === 'SECRET_PATH' ? 'remove or ignore the secret file' : 'remove the input or change the configuration'));
45
+ if (reasons.length) return { ready: false, reasons };
46
+ if (!attempt) {
47
+ if (legacyReceipt) return { ready: false, reasons: [reason('LEGACY_RECEIPT', `migrated legacy receipt (${legacyReceipt.verified || legacyReceipt.last_check || 'present'}) is history, not runtime evidence`, 'verify')] };
48
+ return { ready: false, reasons: [reason('EVIDENCE_MISSING', 'no runtime attempt recorded', 'verify')] };
49
+ }
50
+ const id = typeof attempt.id === 'string' && attempt.id ? attempt.id : '?';
51
+ // The record must be complete and written for this context before any
52
+ // outcome is honored: a stripped or foreign record is never a pass.
53
+ const invalid = validateAttempt(attempt, contextKey, pointedId);
54
+ if (invalid) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} ${invalid}`, 'verify')] };
55
+ // A record written before the migration to change records keeps its identity
56
+ // as history; it never becomes current evidence for the new change context.
57
+ if (mode === 'changes' && attempt.schema === 1) return { ready: false, reasons: [reason('HISTORICAL_EVIDENCE', `attempt ${id} was recorded under schema ${attempt.schema} (before this project used change records) and is history, not current evidence`, 'verify')] };
58
+ // Strict coverage (schema 3 attempts): an attempt recorded before adoption is history;
59
+ // a strict attempt read for a change without the capability is never evidence.
60
+ if (mode === 'changes' && strict && attempt.schema !== 3) return { ready: false, reasons: [reason('HISTORICAL_EVIDENCE', `attempt ${id} was recorded under schema ${attempt.schema} (before this change adopted strict coverage) and is history, not current evidence`, 'verify')] };
61
+ if (mode === 'changes' && !strict && attempt.schema === 3) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} is a strict-coverage (schema 3) attempt; this change has not adopted strict coverage`, 'verify')] };
62
+ if (mode !== 'changes' && attempt.schema !== 1) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} is a change-record (schema ${attempt.schema}) attempt; this project is not in changes mode`, 'verify')] };
63
+ switch (attempt.outcome) {
64
+ case 'running': return { ready: false, reasons: [reason('ATTEMPT_RUNNING', `attempt ${id} is running`, 'wait, or run recover if its owner died')] };
65
+ case 'interrupted': return { ready: false, reasons: [reason('ATTEMPT_INTERRUPTED', `attempt ${id} was interrupted`, 'verify')] };
66
+ case 'timed_out': return { ready: false, reasons: [reason('ATTEMPT_TIMED_OUT', `attempt ${id} timed out after ${attempt.check && attempt.check.timeout_seconds} s`, 'fix or raise timeout, then verify')] };
67
+ case 'error': return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id}: ${attempt.error || 'could not be recorded'}`, 'inspect the record, then verify')] };
68
+ case 'failed': return { ready: false, reasons: [reason('CHECK_FAILED', `attempt ${id} failed (exit ${attempt.exit_code ?? attempt.signal ?? '?'})`, 'fix, then verify')] };
69
+ case 'passed': break;
70
+ default: return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} has unknown outcome ${JSON.stringify(attempt.outcome)}`, 'verify')] };
71
+ }
72
+ if (current.prdRevision && attempt.context && attempt.context.prd_revision !== current.prdRevision) {
73
+ reasons.push(reason('REVISION_CHANGED', 'the PRD revision changed since the passing attempt', 'register --rebind, then verify'));
74
+ }
75
+ if (attempt.check && attempt.check.digest !== parse.checkDigest(text, timeout)) {
76
+ reasons.push(reason('CHECK_CHANGED', 'the Verification block or timeout changed since the passing attempt', 'verify'));
77
+ }
78
+ if (attempt.context && attempt.context.ticket_digest !== parse.ticketDigest(text)) {
79
+ reasons.push(reason('SOURCE_CHANGED', 'the ticket\'s authored content changed since the passing attempt', 'verify'));
80
+ }
81
+ if (current.sourceDigest && attempt.source && attempt.source.after !== current.sourceDigest) {
82
+ const shown = changedPaths.slice(0, 5).join(', ') + (changedPaths.length > 5 ? `, … (${changedPaths.length} paths)` : '');
83
+ reasons.push(reason('SOURCE_CHANGED', `source changed since the passing attempt${shown ? `: ${shown}` : ''}`, 'verify'));
84
+ }
85
+ const streams = ['stdout', 'stderr'];
86
+ if (streams.some(k => attempt.artifacts[k].missing)) {
87
+ reasons.push(reason('EVIDENCE_MISSING', 'the attempt\'s captured log is missing from local state', 'verify'));
88
+ }
89
+ const altered = streams.filter(k => attempt.artifacts[k].altered);
90
+ if (altered.length) {
91
+ reasons.push(reason('EVIDENCE_MISSING', `the attempt's captured ${altered.join(' and ')} log was altered after the run and no longer matches the recorded digest`, 'verify'));
92
+ }
93
+ if (parse.unticked(text).length) reasons.push(reason('CRITERIA_UNTICKED', 'unticked acceptance criteria', 'tick verified criteria'));
94
+ return { ready: reasons.length === 0, reasons };
95
+ }
96
+
97
+ module.exports = { reason, legacyTicketReadiness, migratedTicketReadiness };
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+ // PINCER runtime — the requirement inventory (docs/runtime-contracts.md, "Strict
3
+ // coverage" → "Requirement inventory"). Parses a PRD into the complete set of
4
+ // requirement and scenario definitions under the frozen, deliberately bounded
5
+ // grammar: ATX headings `### R-NN — Title` define requirements, bold list items
6
+ // `- **S-NN:** text` inside a requirement section define scenarios, fenced code,
7
+ // table rows and quotes are reference contexts, and anything that looks like a
8
+ // definition in another shape is refused. The PRD prose is authoritative: this
9
+ // module derives a view and never edits, renumbers or infers a definition. No
10
+ // partial inventory is returned for a PRD with a grammar problem.
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const parse = require('./parse.cjs');
14
+
15
+ const PROJECTION_VERSION = 1;
16
+ const ID = '[A-Z][A-Z0-9]{0,7}-[0-9]{1,6}';
17
+ const ID_RE = new RegExp(`^${ID}$`);
18
+ const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*$/;
19
+ const REQUIREMENT_DEF = new RegExp(`^(${ID})(?:[ \\t]+(?:—|–|-)[ \\t]+|:[ \\t]+)(.+)$`);
20
+ const STARTS_WITH_ID = new RegExp(`^(${ID})(?![A-Z0-9-])`);
21
+ const ITEM = /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]+(.*)$/;
22
+ const SCENARIO_DEF = new RegExp(`^\\*\\*(${ID})(:?)\\*\\*(:?)(?:[ \\t]+(?:—|–|-)[ \\t]+|[ \\t]+|$)(.*)$`);
23
+ const SCENARIO_LIKE = new RegExp(`^(?:\\*\\*)?${ID}[:.]|^\\*\\*${ID}\\*\\*[ \\t]*$`);
24
+ const FENCE = /^[ \t]*`{3,}/;
25
+ const TILDE = /^[ \t]*~~~/;
26
+ const REFERENCE_CONTEXT = /^[ \t]*[|>]/;
27
+ const CONTINUATION = /^(?:[ ]{2,}|\t)(\S.*)$/;
28
+
29
+ // Prefix in byte order, then numerically: R-02 < R-10, AC-1 < R-01.
30
+ function compareIds(a, b) {
31
+ const [pa, na] = split(a), [pb, nb] = split(b);
32
+ if (pa !== pb) return pa < pb ? -1 : 1;
33
+ return na - nb;
34
+ }
35
+ const split = id => { const i = id.lastIndexOf('-'); return [id.slice(0, i), Number(id.slice(i + 1))]; };
36
+ const sortIds = ids => [...ids].sort(compareIds);
37
+
38
+ const rtrim = s => s.replace(/[ \t]+$/, '');
39
+ // Right-trimmed lines, leading/trailing blank lines removed, blank runs collapsed.
40
+ function normalizeBlock(lines) {
41
+ const out = [];
42
+ for (const raw of lines) {
43
+ const line = rtrim(raw);
44
+ if (line === '' && (out.length === 0 || out[out.length - 1] === '')) continue;
45
+ out.push(line);
46
+ }
47
+ while (out.length && out[out.length - 1] === '') out.pop();
48
+ return out.join('\n');
49
+ }
50
+
51
+ // Parse a PRD text. Returns { ok: true, inventory } or { ok: false, problems }
52
+ // (each problem a string; the code is always INVENTORY_INVALID). `prd` is the
53
+ // repository-relative PRD path recorded in the projection.
54
+ function parseInventory(text, { prd } = {}) {
55
+ const rows = parse.lines(text);
56
+ const problems = [];
57
+ const defined = new Map(); // id -> { kind, line }
58
+ const requirements = {}; // id -> { title, lines: [], digest, line, end, scenarios: [] }
59
+ const scenarios = {}; // id -> { requirement, lines: [], digest, line, end }
60
+ const order = [];
61
+ let i = 0;
62
+ if (rows[0] === '---') { i = 1; while (i < rows.length && rows[i] !== '---') i++; i++; }
63
+ let fenceLine = null, current = null, lastScenario = null;
64
+ // Blank lines seen while a scenario is still open. A blank line does not end a
65
+ // list item, so they are held until the next line decides where they belong:
66
+ // to the scenario, when an indented line follows and the item had a second
67
+ // paragraph, or to the requirement, when anything else follows.
68
+ const pending = [];
69
+ const endScenario = () => {
70
+ if (current) for (const blank of pending) requirements[current].lines.push(blank);
71
+ pending.length = 0;
72
+ lastScenario = null;
73
+ };
74
+ // Close the open section at the line before `boundary` (1-based; rows.length + 1 at
75
+ // the end of the file): its span ends at its last nonblank line.
76
+ const closeRequirement = boundary => {
77
+ if (!current) return;
78
+ const r = requirements[current];
79
+ let end = boundary - 1;
80
+ while (end > r.line && rows[end - 1].trim() === '') end--;
81
+ r.end = end;
82
+ if (!r.scenarios.length) problems.push(`requirement ${current} at line ${r.line} has no scenario`);
83
+ endScenario(); current = null;
84
+ };
85
+ const define = (kind, id, line) => {
86
+ const prior = defined.get(id);
87
+ if (prior) { problems.push(`duplicate definition ${id} at line ${line}`); return false; }
88
+ defined.set(id, { kind, line });
89
+ return true;
90
+ };
91
+ for (; i < rows.length; i++) {
92
+ const line = rows[i], n = i + 1;
93
+ if (fenceLine !== null) {
94
+ if (FENCE.test(line)) fenceLine = null;
95
+ if (current) requirements[current].lines.push(line);
96
+ continue;
97
+ }
98
+ if (TILDE.test(line)) { problems.push(`tilde fences are unsupported (line ${n})`); endScenario(); if (current) requirements[current].lines.push(line); continue; }
99
+ if (FENCE.test(line)) { fenceLine = n; endScenario(); if (current) requirements[current].lines.push(line); continue; }
100
+ if (REFERENCE_CONTEXT.test(line)) { endScenario(); if (current) requirements[current].lines.push(line); continue; }
101
+ const heading = line.match(HEADING);
102
+ if (heading) {
103
+ endScenario();
104
+ const level = heading[1].length, textOf = heading[2];
105
+ const def = textOf.match(REQUIREMENT_DEF);
106
+ if (def && level >= 2 && level <= 4 && def[2].trim() !== '') {
107
+ closeRequirement(n);
108
+ if (define('requirement', def[1], n)) {
109
+ current = def[1];
110
+ requirements[current] = { title: def[2].trim(), lines: [], line: n, end: n, level, scenarios: [] };
111
+ order.push(current);
112
+ }
113
+ continue;
114
+ }
115
+ if (STARTS_WITH_ID.test(textOf)) { problems.push(`unsupported requirement definition syntax at line ${n}; use "### R-NN — Title"`); closeRequirement(n); continue; }
116
+ if (current && level <= requirements[current].level) { closeRequirement(n); continue; }
117
+ if (current) requirements[current].lines.push(line);
118
+ continue;
119
+ }
120
+ const item = line.match(ITEM);
121
+ if (item) {
122
+ endScenario();
123
+ const body = item[3].replace(/^\[[ xX]\][ \t]+/, '');
124
+ const def = body.match(SCENARIO_DEF);
125
+ const numbered = /^[0-9]/.test(item[2]);
126
+ if (def && !numbered) {
127
+ if (def[4].trim() === '') { problems.push(`empty definition ${def[1]} at line ${n}`); continue; }
128
+ if (!current) { problems.push(`orphan scenario ${def[1]} at line ${n}`); continue; }
129
+ if (define('scenario', def[1], n)) {
130
+ scenarios[def[1]] = { requirement: current, lines: [def[4].trim()], line: n, end: n };
131
+ requirements[current].scenarios.push(def[1]);
132
+ lastScenario = def[1];
133
+ }
134
+ continue;
135
+ }
136
+ if (SCENARIO_LIKE.test(body) || (def && numbered)) { problems.push(`unsupported scenario definition syntax at line ${n}; use "- **S-NN:** text"`); continue; }
137
+ if (current) requirements[current].lines.push(line);
138
+ continue;
139
+ }
140
+ if (lastScenario && line.trim() === '') { pending.push(line); continue; }
141
+ const cont = lastScenario ? line.match(CONTINUATION) : null;
142
+ if (cont) {
143
+ const s = scenarios[lastScenario];
144
+ while (pending.length) { pending.pop(); s.lines.push(''); }
145
+ s.lines.push(rtrim(cont[1])); s.end = n; continue;
146
+ }
147
+ endScenario();
148
+ if (current) requirements[current].lines.push(line);
149
+ }
150
+ if (fenceLine !== null) problems.push(`unclosed fence opened at line ${fenceLine}`);
151
+ closeRequirement(rows.length + 1);
152
+ if (!order.length) problems.push('no requirement definitions');
153
+ if (problems.length) return { ok: false, problems };
154
+ const inventory = { prd: prd || null, requirements: {}, scenarios: {} };
155
+ for (const id of sortIds(order)) {
156
+ const r = requirements[id];
157
+ const text = normalizeBlock([r.title, ...r.lines]);
158
+ inventory.requirements[id] = { title: r.title, text, digest: parse.sha256(`requirement ${id}\n${text}\n`), line: r.line, end: r.end, scenarios: sortIds(r.scenarios) };
159
+ }
160
+ for (const id of sortIds(Object.keys(scenarios))) {
161
+ const s = scenarios[id];
162
+ const text = rtrim(s.lines.join('\n'));
163
+ inventory.scenarios[id] = { requirement: s.requirement, text, digest: parse.sha256(`scenario ${id}\n${text}\n`), line: s.line, end: s.end };
164
+ }
165
+ inventory.projection = projectionText(inventory);
166
+ inventory.digest = parse.sha256(inventory.projection);
167
+ return { ok: true, inventory };
168
+ }
169
+
170
+ // The exact inventory projection (each line terminated by \n).
171
+ function projectionText(inventory) {
172
+ const lines = [`pincer inventory ${PROJECTION_VERSION}`, `prd ${inventory.prd}`];
173
+ for (const id of sortIds(Object.keys(inventory.requirements))) lines.push(`requirement ${id} ${inventory.requirements[id].digest}`);
174
+ for (const id of sortIds(Object.keys(inventory.scenarios))) lines.push(`scenario ${id} ${inventory.scenarios[id].requirement} ${inventory.scenarios[id].digest}`);
175
+ return `${lines.join('\n')}\n`;
176
+ }
177
+
178
+ // Read and parse a PRD of the repository. Returns { ok, inventory } or
179
+ // { ok: false, code: 'INPUT_INVALID' | 'INVENTORY_INVALID', problems }.
180
+ function readInventory(root, prdRef) {
181
+ const v = parse.validatePrd(root, prdRef);
182
+ if (!v.ok) return { ok: false, code: 'INPUT_INVALID', problems: v.problems.map(p => `${prdRef}: ${p}`) };
183
+ const result = parseInventory(v.text, { prd: prdRef });
184
+ if (!result.ok) return { ok: false, code: 'INVENTORY_INVALID', problems: result.problems.map(p => `${prdRef}: ${p}`), prdResult: v };
185
+ return { ok: true, inventory: result.inventory, prdResult: v };
186
+ }
187
+
188
+ // Recompute a snapshot inventory's projection from its own digests (readSnapshot).
189
+ function projectionOf({ prd, requirements, scenarios }) {
190
+ return projectionText({ prd, requirements, scenarios });
191
+ }
192
+
193
+ // The snapshot shape (agreement snapshot schema 2 and evidence coverage/inventory.json).
194
+ function snapshotOf(inventory) {
195
+ const requirements = {}, scenarios = {};
196
+ for (const id of Object.keys(inventory.requirements)) { const r = inventory.requirements[id]; requirements[id] = { title: r.title, text: r.text, digest: r.digest, line: r.line, end: r.end, scenarios: [...r.scenarios] }; }
197
+ for (const id of Object.keys(inventory.scenarios)) { const s = inventory.scenarios[id]; scenarios[id] = { requirement: s.requirement, text: s.text, digest: s.digest, line: s.line, end: s.end }; }
198
+ return { digest: inventory.digest, projection: inventory.projection, requirements, scenarios };
199
+ }
200
+
201
+ // Validate a snapshot inventory read from a file: shape, per-definition digests
202
+ // recompute from the texts, the projection recomputes and hashes to `digest`.
203
+ // Returns null or a problem string.
204
+ function validateSnapshot(snap, prd) {
205
+ const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
206
+ const SHA = /^[0-9a-f]{64}$/;
207
+ if (!isObject(snap)) return 'inventory must be an object';
208
+ for (const k of ['digest', 'projection', 'requirements', 'scenarios']) if (!(k in snap)) return `inventory.${k} is missing`;
209
+ for (const k of Object.keys(snap)) if (!['digest', 'projection', 'requirements', 'scenarios'].includes(k)) return `inventory.${k} is not allowed`;
210
+ if (!isObject(snap.requirements) || !isObject(snap.scenarios)) return 'inventory.requirements and inventory.scenarios must be objects';
211
+ for (const [id, r] of Object.entries(snap.requirements)) {
212
+ if (!ID_RE.test(id) || !isObject(r) || typeof r.title !== 'string' || typeof r.text !== 'string' || !SHA.test(r.digest || '') || !Number.isInteger(r.line) || !Number.isInteger(r.end) || !Array.isArray(r.scenarios)) return `inventory.requirements[${id}] is malformed`;
213
+ if (parse.sha256(`requirement ${id}\n${r.text}\n`) !== r.digest) return `requirement ${id} text does not hash to its recorded digest`;
214
+ for (const s of r.scenarios) if (!snap.scenarios[s] || snap.scenarios[s].requirement !== id) return `requirement ${id} lists scenario ${s}, which is not its scenario`;
215
+ }
216
+ for (const [id, s] of Object.entries(snap.scenarios)) {
217
+ if (!ID_RE.test(id) || !isObject(s) || typeof s.text !== 'string' || !SHA.test(s.digest || '') || !Number.isInteger(s.line) || !Number.isInteger(s.end) || !snap.requirements[s.requirement]) return `inventory.scenarios[${id}] is malformed`;
218
+ if (parse.sha256(`scenario ${id}\n${s.text}\n`) !== s.digest) return `scenario ${id} text does not hash to its recorded digest`;
219
+ if (!snap.requirements[s.requirement].scenarios.includes(id)) return `scenario ${id} is not listed by its requirement ${s.requirement}`;
220
+ }
221
+ const projection = projectionText({ prd, requirements: snap.requirements, scenarios: snap.scenarios });
222
+ if (projection !== snap.projection) return 'the inventory projection does not recompute from its definitions';
223
+ if (parse.sha256(projection) !== snap.digest) return 'the inventory digest does not match its projection';
224
+ return null;
225
+ }
226
+
227
+ // Structural difference between two inventories (snapshots or computed): what
228
+ // impact and deletion detection read. Never a semantic judgment.
229
+ function difference(from, to) {
230
+ const reqBefore = Object.keys(from.requirements), reqAfter = Object.keys(to.requirements);
231
+ const scBefore = Object.keys(from.scenarios), scAfter = Object.keys(to.scenarios);
232
+ const requirements = { added: sortIds(reqAfter.filter(id => !from.requirements[id])), removed: sortIds(reqBefore.filter(id => !to.requirements[id])), changed: [], unchanged: [] };
233
+ for (const id of sortIds(reqAfter.filter(id => from.requirements[id]))) {
234
+ const a = from.requirements[id], b = to.requirements[id];
235
+ const parts = [];
236
+ if (a.title !== b.title) parts.push('title');
237
+ if (a.digest !== b.digest && a.text !== b.text) parts.push('text');
238
+ if (JSON.stringify(sortIds(a.scenarios)) !== JSON.stringify(sortIds(b.scenarios))) parts.push('scenarios');
239
+ if (parts.length) requirements.changed.push({ id, parts }); else requirements.unchanged.push(id);
240
+ }
241
+ const scenarios = { added: sortIds(scAfter.filter(id => !from.scenarios[id])), removed: sortIds(scBefore.filter(id => !to.scenarios[id])), changed: [], unchanged: [] };
242
+ for (const id of sortIds(scAfter.filter(id => from.scenarios[id]))) {
243
+ const a = from.scenarios[id], b = to.scenarios[id];
244
+ const parts = [];
245
+ if (a.digest !== b.digest) parts.push('text');
246
+ if (a.requirement !== b.requirement) parts.push('requirement');
247
+ if (parts.length) scenarios.changed.push({ id, parts }); else scenarios.unchanged.push(id);
248
+ }
249
+ return { same: from.digest === to.digest, requirements, scenarios };
250
+ }
251
+
252
+ module.exports = { PROJECTION_VERSION, ID, ID_RE, compareIds, sortIds, parseInventory, projectionText, projectionOf, readInventory, snapshotOf, validateSnapshot, difference, normalizeBlock };
253
+
254
+ // Convenience for callers that hold a file path rather than a repository.
255
+ module.exports.parseFile = (file, prd) => parseInventory(fs.readFileSync(file, 'utf8'), { prd: prd || path.basename(file) });