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,676 @@
1
+ 'use strict';
2
+ // PINCER runtime — candidate evidence (docs/runtime-contracts.md, "Evidence
3
+ // schema 2"). Schema 1 validation lives here unchanged from v0.4.1 so status,
4
+ // release and the pincer-evidence.cjs entry point share one implementation.
5
+ //
6
+ // What validation establishes: that the locally authored record is internally
7
+ // consistent — schema, references, candidate association, required results,
8
+ // artifact existence and digests, repository containment. It is NOT independent
9
+ // attestation that the recorded commands ran or that images depict the stated
10
+ // application; that judgment stays with the reviewer.
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const crypto = require('node:crypto');
14
+ const { execFileSync } = require('node:child_process');
15
+ const { validateAttempt, contextKey } = require('./state.cjs');
16
+ const requirements = require('./requirements.cjs');
17
+ const coverage = require('./coverage.cjs');
18
+ const { tryGit } = require('./fsutil.cjs');
19
+
20
+ const SCHEMA = 1;
21
+ const HEX40 = /^[0-9a-f]{40}$/;
22
+ const SHA256 = /^[0-9a-f]{64}$/;
23
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
24
+ const PRD_REF = /^\.prd\/prd-v([1-9][0-9]{0,8})\.md$/;
25
+ const MANIFEST_AT = /^\.prd\/evidence\/prd-v([1-9][0-9]{0,8})\/([0-9a-f]{40})\/manifest\.json$/;
26
+ // Requirement IDs are the PRD's own: R-01 from the template, or a supplied PRD's
27
+ // REQ-1 / AC-12 style, kept verbatim rather than renamed.
28
+ const REQ_ID = /^[A-Z][A-Z0-9]{0,7}-[0-9]{1,6}$/;
29
+ const TICKET_ID = /^T-[0-9]{2,6}$/;
30
+ const CHECK_ID = /^C-[0-9]{2,6}$/;
31
+ const IMAGE = /\.(png|jpe?g|webp)$/i;
32
+ const MAX_TEXT = 2000; // guard against pasted environment dumps
33
+ const TOP_KEYS = ['schema', 'prd', 'base', 'candidate', 'created', 'environment', 'coverage_review', 'requirements', 'checks', 'visual_review', 'artifacts'];
34
+ const ENV_KEYS = ['os', 'node', 'tools', 'limitations'];
35
+ const REQ_KEYS = ['id', 'disposition', 'tickets', 'checks', 'note', 'authorized_by'];
36
+ const CHECK_KEYS = ['id', 'kind', 'required', 'result', 'command', 'timestamp', 'artifacts', 'scenario', 'viewport', 'observed', 'note'];
37
+ const DISPOSITIONS = ['delivered', 'blocked', 'deferred'];
38
+ const KINDS = ['command', 'visual', 'review'];
39
+ const RESULTS = ['passed', 'failed', 'unverified'];
40
+ // Schema 2 (docs/runtime-contracts.md, "Evidence schema 2"): a change binding,
41
+ // provenance per check, and attempt provenance on runtime command checks.
42
+ // Schema 3 (docs/runtime-contracts.md, "Evidence schema 3"): the complete reconciled
43
+ // coverage of the candidate — inventory and map snapshots, scenario rows, derived
44
+ // dispositions, the adequacy judgment and the delivery summary.
45
+ const SCHEMAS = [1, 2, 3];
46
+ const TOP_KEYS_2 = [...TOP_KEYS, 'change'];
47
+ const TOP_KEYS_3 = [...TOP_KEYS_2, 'coverage', 'scenarios', 'adequacy', 'delivery'];
48
+ const REQ_KEYS_3 = ['id', 'disposition', 'tickets', 'checks', 'scenarios', 'decision', 'authorization', 'note'];
49
+ const SCENARIO_KEYS = ['id', 'requirement', 'disposition', 'tickets', 'checks', 'decision', 'authorization', 'note'];
50
+ const DISPOSITIONS_3 = ['delivered', 'deferred', 'removed', 'blocked'];
51
+ const COVERAGE_KEYS = ['agreement', 'authorization', 'inventory', 'map', 'snapshots'];
52
+ const ADEQUACY = ['adequate', 'inadequate'];
53
+ const CHECK_KEYS_2 = [...CHECK_KEYS, 'provenance', 'attempt'];
54
+ const CHECK_KEYS_3 = [...CHECK_KEYS_2, 'declared'];
55
+ const PROVENANCE = ['runtime', 'authored'];
56
+ const ATTEMPT_KEYS = ['id', 'sequence', 'outcome', 'exit_code', 'started', 'finished', 'source_before', 'source_after', 'check_digest', 'runner', 'cwd', 'log_sha256', 'truncated'];
57
+ const OUTCOMES = ['passed', 'failed', 'timed_out', 'interrupted', 'error'];
58
+ const CHANGE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
59
+ const resultFor = outcome => (outcome === 'passed' ? 'passed' : outcome === 'error' ? 'unverified' : 'failed');
60
+
61
+ const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
62
+ const shortText = v => typeof v === 'string' && v.length <= MAX_TEXT;
63
+ const nonempty = v => shortText(v) && v.trim() !== '';
64
+ const toPosix = p => p.split(path.sep).join('/');
65
+
66
+ function repoRoot() {
67
+ if (process.env.CLAUDE_PROJECT_DIR) return path.resolve(process.env.CLAUDE_PROJECT_DIR);
68
+ try {
69
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
70
+ } catch {
71
+ return process.cwd();
72
+ }
73
+ }
74
+
75
+ // Resolve through symlinks above the repository (macOS /var -> /private/var)
76
+ // without requiring the leaf to exist yet.
77
+ function realpathDeep(p) {
78
+ try { return fs.realpathSync(p); } catch {
79
+ const parent = path.dirname(p);
80
+ return parent === p ? p : path.join(realpathDeep(parent), path.basename(p));
81
+ }
82
+ }
83
+
84
+ function digestFile(file) {
85
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
86
+ }
87
+
88
+ // Why a repository-relative path is unsafe, or null when it is acceptable.
89
+ function unsafePath(p) {
90
+ if (p.startsWith('/')) return 'absolute paths are not allowed';
91
+ if (/^[A-Za-z]:/.test(p)) return 'drive-letter paths are not allowed';
92
+ if (p.includes('\\')) return 'backslashes are not allowed; use repository-relative POSIX paths';
93
+ if (p.split('/').some(s => s === '' || s === '.' || s === '..')) return 'path must be normalized and repository-relative (no "..", "." or empty segments)';
94
+ return null;
95
+ }
96
+
97
+ function validate(manifestArg, opts, rootArg) {
98
+ const root = realpathDeep(rootArg || repoRoot());
99
+ const abs = realpathDeep(path.resolve(manifestArg));
100
+ const rel = toPosix(path.relative(root, abs));
101
+ const at = rel.match(MANIFEST_AT);
102
+ if (!at) return [`manifest must live at .prd/evidence/prd-vN/<candidate>/manifest.json inside the repository (got ${rel})`];
103
+ const [, dirVersion, dirCandidate] = at;
104
+ const dirRel = path.posix.dirname(rel);
105
+
106
+ let raw;
107
+ try { raw = fs.readFileSync(abs, 'utf8'); } catch { return ['missing — run /pincer-evaluate to write evidence for this candidate']; }
108
+ let doc;
109
+ try { doc = JSON.parse(raw); } catch (error) { return [`malformed JSON (${error.message})`]; }
110
+ if (!isObject(doc)) return ['malformed: the manifest must be a JSON object'];
111
+ if (!SCHEMAS.includes(doc.schema)) return [`unknown evidence schema ${JSON.stringify(doc.schema)} — this runtime validates schemas ${SCHEMAS.slice(0, -1).join(', ')} and ${SCHEMAS.at(-1)}`];
112
+ const schema2 = doc.schema >= 2;
113
+ const schema3 = doc.schema === 3;
114
+ const topKeys = schema3 ? TOP_KEYS_3 : schema2 ? TOP_KEYS_2 : TOP_KEYS;
115
+ const checkKeys = schema3 ? CHECK_KEYS_3 : schema2 ? CHECK_KEYS_2 : CHECK_KEYS;
116
+ const reqKeys = schema3 ? REQ_KEYS_3 : REQ_KEYS;
117
+ const dispositions = schema3 ? DISPOSITIONS_3 : DISPOSITIONS;
118
+ opts.limitations = [];
119
+
120
+ const problems = [];
121
+ const problem = message => problems.push(message);
122
+ for (const key of Object.keys(doc)) if (!topKeys.includes(key)) problem(`unknown top-level key "${key}"`);
123
+ for (const key of topKeys) if (!(key in doc)) problem(`missing "${key}"`);
124
+ if (schema2) {
125
+ const change = doc.change;
126
+ if (!isObject(change)) problem('change must be an object {id, prd_revision, base}');
127
+ else {
128
+ for (const key of Object.keys(change)) if (!['id', 'prd_revision', 'base'].includes(key)) problem(`change.${key} is not allowed`);
129
+ if (typeof change.id !== 'string' || !CHANGE_ID.test(change.id)) problem('change.id must be a change ID ([a-z0-9][a-z0-9-]{0,63})');
130
+ if (typeof change.prd_revision !== 'string' || !SHA256.test(change.prd_revision)) problem('change.prd_revision must be a 64-hex SHA-256 digest');
131
+ if (typeof change.base !== 'string' || !HEX40.test(change.base)) problem('change.base must be a full 40-hex commit ID');
132
+ }
133
+ }
134
+
135
+ const prd = typeof doc.prd === 'string' ? doc.prd.match(PRD_REF) : null;
136
+ if (!prd) problem('prd must be a reference of the form .prd/prd-vN.md');
137
+ else if (prd[1] !== dirVersion) problem(`wrong PRD: manifest names ${doc.prd} but lives under prd-v${dirVersion}`);
138
+ if (opts.prd && doc.prd !== opts.prd) problem(`wrong PRD: manifest is for ${doc.prd}, expected ${opts.prd}`);
139
+
140
+ for (const key of ['base', 'candidate']) {
141
+ if (typeof doc[key] !== 'string' || !HEX40.test(doc[key])) problem(`${key} must be a full 40-hex commit ID`);
142
+ }
143
+ if (typeof doc.candidate === 'string' && HEX40.test(doc.candidate) && doc.candidate !== dirCandidate) {
144
+ problem(`wrong candidate: manifest names ${doc.candidate} but lives under ${dirCandidate}`);
145
+ }
146
+ if (opts.candidate && doc.candidate !== opts.candidate) problem(`wrong candidate: manifest is for ${doc.candidate}, expected ${opts.candidate}`);
147
+ if (opts.base && doc.base !== opts.base) problem(`wrong base: manifest records ${doc.base}, expected ${opts.base}`);
148
+ if (typeof doc.created !== 'string' || !ISO_UTC.test(doc.created)) problem('created must be an ISO-8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ)');
149
+
150
+ const env = doc.environment;
151
+ if (!isObject(env)) problem('environment must be an object with os, node, tools and limitations');
152
+ else {
153
+ for (const key of ['os', 'node']) if (!nonempty(env[key])) problem(`environment.${key} must be a short nonempty string (redacted summary, not a dump)`);
154
+ for (const key of ['tools', 'limitations']) {
155
+ if (!Array.isArray(env[key]) || !env[key].every(nonempty)) problem(`environment.${key} must be an array of short strings`);
156
+ }
157
+ for (const key of Object.keys(env)) if (!ENV_KEYS.includes(key)) problem(`environment.${key} is not allowed — persist redacted summaries only`);
158
+ }
159
+ if (!nonempty(doc.coverage_review)) problem('coverage_review must be a nonempty string recording the reviewer judgment on requirement coverage');
160
+
161
+ // Artifacts: repository-contained regular files with matching digests.
162
+ const artifacts = new Map();
163
+ if (!Array.isArray(doc.artifacts)) problem('artifacts must be an array of {path, sha256}');
164
+ else doc.artifacts.forEach((entry, index) => {
165
+ const label = `artifacts[${index}]`;
166
+ if (!isObject(entry)) { problem(`${label} must be an object {path, sha256}`); return; }
167
+ for (const key of Object.keys(entry)) if (!['path', 'sha256'].includes(key)) problem(`${label}.${key} is not allowed`);
168
+ const p = entry.path;
169
+ if (typeof p !== 'string' || p === '') { problem(`${label}.path must be a nonempty string`); return; }
170
+ if (artifacts.has(p)) problem(`duplicate artifact path ${p}`);
171
+ artifacts.set(p, false);
172
+ const why = unsafePath(p);
173
+ if (why) { problem(`artifact ${p}: ${why}`); return; }
174
+ if (!p.startsWith(`${dirRel}/`)) { problem(`artifact ${p}: outside the evidence directory ${dirRel}/`); return; }
175
+ const digestOk = typeof entry.sha256 === 'string' && SHA256.test(entry.sha256);
176
+ if (!digestOk) problem(`artifact ${p}: sha256 must be a full 64-hex digest`);
177
+ const segments = p.split('/');
178
+ let current = root;
179
+ for (let i = 0; i < segments.length; i++) {
180
+ current = path.join(current, segments[i]);
181
+ let stat;
182
+ try { stat = fs.lstatSync(current); } catch { problem(`artifact ${p}: missing`); return; }
183
+ const last = i === segments.length - 1;
184
+ if (stat.isSymbolicLink()) {
185
+ problem(last ? `artifact ${p}: is a symlink (only regular files inside the repository are accepted)` : `artifact ${p}: path component ${segments.slice(0, i + 1).join('/')} is a symlink`);
186
+ return;
187
+ }
188
+ if (!last && !stat.isDirectory()) { problem(`artifact ${p}: ${segments.slice(0, i + 1).join('/')} is not a directory`); return; }
189
+ if (last && !stat.isFile()) { problem(`artifact ${p}: not a regular file`); return; }
190
+ }
191
+ if (digestOk && digestFile(current) !== entry.sha256) problem(`artifact ${p}: digest mismatch — the file changed after the evidence was recorded`);
192
+ });
193
+
194
+ // Checks: what was run or judged, with results and artifact references.
195
+ const checks = new Map();
196
+ let visualChecks = 0;
197
+ if (!Array.isArray(doc.checks)) problem('checks must be an array');
198
+ else doc.checks.forEach((check, index) => {
199
+ const label = `checks[${index}]`;
200
+ if (!isObject(check)) { problem(`${label} must be an object`); return; }
201
+ const id = typeof check.id === 'string' && CHECK_ID.test(check.id) ? check.id : null;
202
+ if (!id) problem(`${label}.id must be a check ID such as C-01`);
203
+ else if (checks.has(id)) problem(`duplicate check ID ${id}`);
204
+ else checks.set(id, check);
205
+ const name = id || label;
206
+ for (const key of Object.keys(check)) if (!checkKeys.includes(key)) problem(`check ${name}: unknown key "${key}"`);
207
+ if (!KINDS.includes(check.kind)) problem(`check ${name}: kind must be one of ${KINDS.join(', ')}`);
208
+ if (schema2) validateProvenance(check, name, doc, problem);
209
+ if (schema3 && !(typeof check.declared === 'string' && SHA256.test(check.declared))) problem(`check ${name}: declared must be the 64-hex definition digest from the coverage map`);
210
+ if (schema3 && (check.kind === 'review' || check.kind === 'visual') && Array.isArray(check.artifacts) && check.artifacts.length === 0) problem(`check ${name}: a ${check.kind} obligation needs at least one candidate-bound artifact`);
211
+ if (typeof check.required !== 'boolean') problem(`check ${name}: required must be true or false`);
212
+ if (!RESULTS.includes(check.result)) problem(`check ${name}: result must be one of ${RESULTS.join(', ')}`);
213
+ if (typeof check.timestamp !== 'string' || !ISO_UTC.test(check.timestamp)) problem(`check ${name}: timestamp must be an ISO-8601 UTC timestamp`);
214
+ if (check.kind === 'command' && !nonempty(check.command)) problem(`check ${name}: command kind requires the command that was run`);
215
+ for (const key of ['command', 'scenario', 'viewport', 'observed', 'note']) {
216
+ if (key in check && !shortText(check[key])) problem(`check ${name}: ${key} must be a short string`);
217
+ }
218
+ let images = 0;
219
+ if (!Array.isArray(check.artifacts)) problem(`check ${name}: artifacts must be an array of repository-relative paths`);
220
+ else for (const p of check.artifacts) {
221
+ if (typeof p !== 'string') { problem(`check ${name}: artifact reference must be a string`); continue; }
222
+ if (!artifacts.has(p)) problem(`check ${name}: references unlisted artifact ${p} (dangling reference)`);
223
+ else artifacts.set(p, true);
224
+ if (IMAGE.test(p)) images++;
225
+ }
226
+ if (check.kind === 'visual') {
227
+ visualChecks++;
228
+ for (const key of ['scenario', 'viewport', 'observed']) if (!nonempty(check[key])) problem(`check ${name}: visual check requires ${key}`);
229
+ // A passed visual check must show its image; an unverified one (tool
230
+ // unavailable) is recorded honestly without one and, when required, blocks.
231
+ if (check.result === 'passed' && images === 0) problem(`check ${name}: a passed visual check requires a saved image artifact (.png, .jpg or .webp)`);
232
+ }
233
+ if (check.required === true && check.result !== 'passed') {
234
+ problem(`required check ${name} is ${check.result} — readiness is blocked until it passes on a new candidate or the requirement is deferred with authorization`);
235
+ }
236
+ });
237
+ const strictSnapshots = schema3 && isObject(doc.coverage) && isObject(doc.coverage.snapshots) ? Object.values(doc.coverage.snapshots).filter(p => typeof p === 'string') : [];
238
+ for (const [p, referenced] of artifacts) if (!referenced && !strictSnapshots.includes(p)) problem(`artifact ${p}: not referenced by any check`);
239
+
240
+ // Requirements: every ID dispositioned; deferrals authorized; checks resolve.
241
+ const requirements = new Set();
242
+ if (!Array.isArray(doc.requirements) || doc.requirements.length === 0) problem('requirements must be a nonempty array — every PRD requirement needs a disposition');
243
+ else doc.requirements.forEach((req, index) => {
244
+ const label = `requirements[${index}]`;
245
+ if (!isObject(req)) { problem(`${label} must be an object`); return; }
246
+ const id = typeof req.id === 'string' && REQ_ID.test(req.id) ? req.id : null;
247
+ if (!id) problem(`${label}.id must be a requirement ID such as R-01 or the PRD's own REQ-1 (uppercase prefix, dash, digits)`);
248
+ else if (requirements.has(id)) problem(`duplicate requirement ID ${id}`);
249
+ else requirements.add(id);
250
+ const name = id || label;
251
+ for (const key of Object.keys(req)) if (!reqKeys.includes(key)) problem(`requirement ${name}: unknown key "${key}"`);
252
+ if (!dispositions.includes(req.disposition)) problem(`requirement ${name}: disposition must be one of ${dispositions.join(', ')}`);
253
+ if (!Array.isArray(req.tickets) || !req.tickets.every(t => typeof t === 'string' && TICKET_ID.test(t))) problem(`requirement ${name}: tickets must be an array of ticket IDs such as T-01`);
254
+ if (!Array.isArray(req.checks)) problem(`requirement ${name}: checks must be an array of check IDs`);
255
+ else for (const c of req.checks) {
256
+ if (typeof c !== 'string' || !checks.has(c)) problem(`requirement ${name}: references unknown check ${JSON.stringify(c)} (dangling reference)`);
257
+ }
258
+ for (const key of ['note', 'authorized_by']) if (key in req && req[key] !== null && !shortText(req[key])) problem(`requirement ${name}: ${key} must be a short string`);
259
+ if (!schema3 && req.disposition === 'deferred' && !nonempty(req.authorized_by)) problem(`requirement ${name}: deferred requires authorized_by naming the explicit user authorization`);
260
+ if (!schema3 && req.disposition === 'delivered' && Array.isArray(req.checks) && req.checks.length === 0) problem(`requirement ${name}: delivered requires at least one check`);
261
+ if (req.disposition === 'blocked') problem(`requirement ${name} is blocked — readiness is blocked until it is delivered on a new candidate or deferred with authorization`);
262
+ });
263
+
264
+ const visual = doc.visual_review;
265
+ if (!isObject(visual) || typeof visual.applicable !== 'boolean') problem('visual_review must be {applicable: boolean, reason?: string}');
266
+ else {
267
+ for (const key of Object.keys(visual)) if (!['applicable', 'reason'].includes(key)) problem(`visual_review.${key} is not allowed`);
268
+ if (visual.applicable === false && !nonempty(visual.reason)) problem('visual_review.reason is required when visual review is not applicable (say why)');
269
+ if (visual.applicable === true && visualChecks === 0) problem('visual_review.applicable is true but no visual check is recorded');
270
+ if ('reason' in visual && !shortText(visual.reason)) problem('visual_review.reason must be a short string');
271
+ }
272
+
273
+ if (schema3) validateStrict(doc, { root, rel, dirRel, checks, requirements: requirementsSeen(doc), opts }, problem);
274
+
275
+ if (problems.length === 0 && opts.files) {
276
+ opts.list = [rel, ...doc.artifacts.map(a => a.path)];
277
+ }
278
+ return problems;
279
+ }
280
+ const requirementsSeen = doc => new Map((Array.isArray(doc.requirements) ? doc.requirements : []).filter(r => isObject(r) && typeof r.id === 'string').map(r => [r.id, r]));
281
+
282
+ // --- Schema 3: reconciled candidate coverage --------------------------------------
283
+ // The manifest cannot choose its own obligations: the scenario and requirement rows
284
+ // are exactly the inventory snapshot's set plus tombstones, the links are the map
285
+ // snapshot's, every declared check appears with its declaration digest, dispositions
286
+ // are derived from the outcomes, and (inside a repository) the snapshots recompute
287
+ // from the committed candidate's own PRD, map and change record.
288
+ function validateStrict(doc, { root, dirRel, checks, requirements: reqRows, opts }, problem) {
289
+ const cov = doc.coverage;
290
+ if (!isObject(cov)) { problem('coverage must be an object { agreement, authorization, inventory, map, snapshots }'); return; }
291
+ for (const k of Object.keys(cov)) if (!COVERAGE_KEYS.includes(k)) problem(`coverage.${k} is not allowed`);
292
+ for (const k of COVERAGE_KEYS) if (!(k in cov)) problem(`coverage.${k} is missing`);
293
+ for (const k of ['agreement', 'inventory', 'map']) if (typeof cov[k] !== 'string' || !SHA256.test(cov[k])) problem(`coverage.${k} must be a 64-hex digest`);
294
+ if (typeof cov.authorization !== 'string' || !/^A-[0-9]{2,6}$/.test(cov.authorization)) problem('coverage.authorization must name the authorization A-NN that covered the evaluation');
295
+ const changeId = isObject(doc.change) && typeof doc.change.id === 'string' ? doc.change.id : null;
296
+ const expectedSnapshots = { inventory: `${dirRel}/coverage/inventory.json`, map: `${dirRel}/coverage/map.json` };
297
+ if (!isObject(cov.snapshots)) { problem('coverage.snapshots must be { inventory, map }'); return; }
298
+ for (const k of ['inventory', 'map']) if (cov.snapshots[k] !== expectedSnapshots[k]) problem(`coverage.snapshots.${k} must be ${expectedSnapshots[k]}`);
299
+ for (const k of Object.keys(cov.snapshots)) if (!['inventory', 'map'].includes(k)) problem(`coverage.snapshots.${k} is not allowed`);
300
+ const listed = new Set((Array.isArray(doc.artifacts) ? doc.artifacts : []).filter(isObject).map(a => a.path));
301
+ for (const k of ['inventory', 'map']) if (!listed.has(expectedSnapshots[k])) problem(`coverage snapshot ${expectedSnapshots[k]} must be a listed artifact`);
302
+ // Snapshots: the inventory recomputes from its definitions; the map normalizes to its digest.
303
+ let inv = null, mapDoc = null, mapNormalized = null;
304
+ const readSnap = k => { try { return JSON.parse(fs.readFileSync(path.join(root, expectedSnapshots[k]), 'utf8')); } catch (error) { problem(`coverage snapshot ${expectedSnapshots[k]}: ${error.code === 'ENOENT' ? 'missing' : `malformed JSON (${error.message})`}`); return null; } };
305
+ const invSnap = readSnap('inventory'), mapSnap = readSnap('map');
306
+ if (invSnap) {
307
+ if (!isObject(invSnap) || invSnap.schema !== 1 || invSnap.prd !== doc.prd) problem(`coverage snapshot ${expectedSnapshots.inventory}: must be schema 1 for ${doc.prd}`);
308
+ else {
309
+ const bad = requirements.validateSnapshot({ digest: invSnap.digest, projection: invSnap.projection, requirements: invSnap.requirements, scenarios: invSnap.scenarios }, doc.prd);
310
+ if (bad) problem(`coverage snapshot ${expectedSnapshots.inventory}: ${bad}`);
311
+ else if (invSnap.digest !== cov.inventory) problem(`coverage.inventory ${String(cov.inventory).slice(0, 12)} does not equal the inventory snapshot digest ${invSnap.digest.slice(0, 12)}`);
312
+ else inv = invSnap;
313
+ }
314
+ }
315
+ if (mapSnap) {
316
+ if (!isObject(mapSnap) || mapSnap.schema !== 1 || !isObject(mapSnap.map)) problem(`coverage snapshot ${expectedSnapshots.map}: must be schema 1 with the map object`);
317
+ else {
318
+ const why = changeId ? coverage.validateMap(mapSnap.map, { change: changeId, prd: doc.prd, root: null }) : 'no change id';
319
+ if (why) problem(`coverage snapshot ${expectedSnapshots.map}: ${why}`);
320
+ else {
321
+ mapNormalized = coverage.normalize(mapSnap.map);
322
+ if (coverage.digestOf(mapNormalized) !== cov.map || mapSnap.digest !== cov.map) problem(`coverage.map ${String(cov.map).slice(0, 12)} does not equal the map snapshot digest`);
323
+ else if (mapSnap.path !== coverage.file(changeId)) problem(`coverage snapshot ${expectedSnapshots.map}: path must be ${coverage.file(changeId)}`);
324
+ else mapDoc = mapSnap.map;
325
+ }
326
+ }
327
+ }
328
+ // Adequacy and delivery shape.
329
+ const adequacy = doc.adequacy;
330
+ if (!isObject(adequacy) || !ADEQUACY.includes(adequacy.verdict) || !nonempty(adequacy.note) || Object.keys(adequacy).some(k => !['verdict', 'note'].includes(k))) problem('adequacy must be { verdict: "adequate" | "inadequate", note } with a nonempty note (the reviewer\'s judgment)');
331
+ else if (adequacy.verdict === 'inadequate') problem(`adequacy verdict is inadequate ("${adequacy.note}") — readiness is blocked until the reviewer judges the checks adequate on a new evaluation`);
332
+ const delivery = doc.delivery;
333
+ if (!isObject(delivery) || typeof delivery.original !== 'boolean' || typeof delivery.agreed !== 'boolean' || Object.keys(delivery).some(k => !['original', 'agreed'].includes(k))) problem('delivery must be { original: boolean, agreed: boolean }');
334
+ if (!inv || !mapDoc) return;
335
+ // Declared checks: every one appears, with its kind, required flag and definition digest; nothing undeclared.
336
+ for (const [id, c] of Object.entries(mapDoc.checks)) {
337
+ const row = checks.get(id);
338
+ if (!row) { problem(`declared check ${id} (${c.required ? 'required' : 'optional'} ${c.kind}) is missing from checks — an unused failing required check cannot be omitted`); continue; }
339
+ if (row.kind !== c.kind) problem(`check ${id}: kind ${row.kind} disagrees with the declaration (${c.kind})`);
340
+ if (row.required !== c.required) problem(`check ${id}: required ${row.required} disagrees with the declaration (${c.required})`);
341
+ const declared = coverage.definitionDigest(c);
342
+ if (row.declared !== declared) problem(`check ${id}: declared ${String(row.declared).slice(0, 12)} is not the declaration's digest ${declared.slice(0, 12)}`);
343
+ if (c.kind === 'command' && isObject(row.attempt) && row.attempt.check_digest !== declared) problem(`check ${id}: the attempt ran ${String(row.attempt.check_digest).slice(0, 12)}, not the declared command and timeout (${declared.slice(0, 12)}) — a substituted command is not evidence for the declaration`);
344
+ }
345
+ for (const id of checks.keys()) if (!mapDoc.checks[id]) problem(`check ${id} is not declared in the coverage map snapshot`);
346
+ // Scenario rows: exactly the inventory's scenarios plus removed tombstones, once each, with the map's links.
347
+ const tombstones = Object.keys(mapDoc.scope).filter(id => mapDoc.scope[id].disposition === 'removed' && !inv.scenarios[id]);
348
+ const expectedRows = new Set([...Object.keys(inv.scenarios), ...tombstones]);
349
+ const rows = new Map();
350
+ if (!Array.isArray(doc.scenarios)) { problem('scenarios must be an array with one row per scenario of the inventory snapshot'); return; }
351
+ const passed = id => { const c = checks.get(id); return Boolean(c) && c.result === 'passed'; };
352
+ for (const [sid, srow] of Object.entries(mapDoc.scenarios || {}))
353
+ for (const c of (srow && Array.isArray(srow.checks) ? srow.checks : []))
354
+ if (!mapDoc.checks[c]) problem(`the coverage snapshot links ${sid} to ${c}, which it does not declare`);
355
+ const derive = (id, row) => {
356
+ const scope = mapDoc.scope[id];
357
+ if (scope) return scope.disposition;
358
+ const linked = mapDoc.scenarios[id] ? mapDoc.scenarios[id].checks : [];
359
+ // A link to a check the map snapshot does not declare is not a satisfied
360
+ // obligation: it is an obligation nothing can have verified.
361
+ return linked.length && linked.every(c => mapDoc.checks[c] && (!mapDoc.checks[c].required || passed(c))) ? 'delivered' : 'blocked';
362
+ };
363
+ doc.scenarios.forEach((row, index) => {
364
+ const label = `scenarios[${index}]`;
365
+ if (!isObject(row)) { problem(`${label} must be an object`); return; }
366
+ for (const k of Object.keys(row)) if (!SCENARIO_KEYS.includes(k)) problem(`${label}: unknown key "${k}"`);
367
+ for (const k of SCENARIO_KEYS) if (!(k in row)) problem(`${label}: missing key "${k}"`);
368
+ const id = typeof row.id === 'string' ? row.id : null;
369
+ if (!id) { problem(`${label}.id must be a scenario ID`); return; }
370
+ if (rows.has(id)) { problem(`duplicate scenario row ${id}`); return; }
371
+ rows.set(id, row);
372
+ if (!expectedRows.has(id)) { problem(`scenario ${id} is not a scenario of the inventory snapshot (an invented row)`); return; }
373
+ const live = inv.scenarios[id];
374
+ if (live && row.requirement !== live.requirement) problem(`scenario ${id}: requirement ${row.requirement} disagrees with the inventory (${live.requirement})`);
375
+ if (!DISPOSITIONS_3.includes(row.disposition)) problem(`scenario ${id}: disposition must be one of ${DISPOSITIONS_3.join(', ')}`);
376
+ const expected = derive(id, row);
377
+ if (row.disposition !== expected) problem(`scenario ${id}: disposition ${row.disposition} is not what the map and the outcomes give (${expected})`);
378
+ const mapRow = mapDoc.scenarios[id];
379
+ const same = (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
380
+ if (mapRow) {
381
+ if (!Array.isArray(row.tickets) || !same(row.tickets, mapRow.tickets)) problem(`scenario ${id}: tickets disagree with the map (${mapRow.tickets.join(', ')})`);
382
+ if (!Array.isArray(row.checks) || !same(row.checks, mapRow.checks)) problem(`scenario ${id}: checks disagree with the map (${mapRow.checks.join(', ')})`);
383
+ if (row.decision !== null || row.authorization !== null) problem(`scenario ${id}: an in-scope row carries null decision and authorization`);
384
+ } else {
385
+ if (!Array.isArray(row.tickets) || row.tickets.length || !Array.isArray(row.checks) || row.checks.length) problem(`scenario ${id}: a dispositioned row carries no tickets or checks`);
386
+ if (typeof row.decision !== 'string' || !/^D-[0-9]{2,6}$/.test(row.decision) || row.decision !== mapDoc.scope[id].decision) problem(`scenario ${id}: decision must be the map's ${mapDoc.scope[id].decision}`);
387
+ if (typeof row.authorization !== 'string' || !/^A-[0-9]{2,6}$/.test(row.authorization)) problem(`scenario ${id}: a ${row.disposition} row names the user authorization A-NN that covers its decision`);
388
+ }
389
+ if (row.note !== null && !shortText(row.note)) problem(`scenario ${id}: note must be null or a short string`);
390
+ if (row.disposition === 'blocked') problem(`scenario ${id} is blocked — readiness is blocked until every required check it links passes on a new candidate or the scenario is dispositioned with authorization`);
391
+ });
392
+ for (const id of expectedRows) if (!rows.has(id)) problem(`scenario ${id} of the inventory snapshot has no row (an omitted obligation)`);
393
+ // Requirement rows: exactly the inventory's requirements; rolled up from their scenarios.
394
+ for (const id of Object.keys(inv.requirements)) {
395
+ const req = reqRows.get(id);
396
+ if (!req) { problem(`requirement ${id} of the inventory snapshot has no row`); continue; }
397
+ const expectedScenarios = inv.requirements[id].scenarios;
398
+ if (!Array.isArray(req.scenarios) || JSON.stringify([...req.scenarios].sort()) !== JSON.stringify([...expectedScenarios].sort())) problem(`requirement ${id}: scenarios disagree with the inventory (${expectedScenarios.join(', ')})`);
399
+ const states = expectedScenarios.map(sid => (rows.get(sid) || {}).disposition);
400
+ const roll = states.every(x => x === 'delivered') ? 'delivered' : states.every(x => x === 'removed') ? 'removed' : states.every(x => ['delivered', 'deferred', 'removed'].includes(x)) ? 'deferred' : 'blocked';
401
+ if (req.disposition !== roll) problem(`requirement ${id}: disposition ${req.disposition} is not the roll-up of its scenarios (${roll})`);
402
+ }
403
+ for (const id of reqRows.keys()) if (!inv.requirements[id]) problem(`requirement ${id} is not a requirement of the inventory snapshot (an invented row)`);
404
+ const original = [...expectedRows].every(id => inv.scenarios[id] && (rows.get(id) || {}).disposition === 'delivered');
405
+ const agreed = [...expectedRows].every(id => ['delivered', 'deferred', 'removed'].includes((rows.get(id) || {}).disposition));
406
+ if (isObject(delivery) && (delivery.original !== original || delivery.agreed !== agreed)) problem(`delivery { original: ${original}, agreed: ${agreed} } is what the rows give, not { original: ${delivery.original}, agreed: ${delivery.agreed} }`);
407
+ // Independent reconciliation with the committed candidate, when the repository is available.
408
+ if (typeof doc.candidate !== 'string' || !HEX40.test(doc.candidate) || !changeId) return;
409
+ const show = rel => tryGit(root, ['show', `${doc.candidate}:${rel}`]);
410
+ // Only a commit that does not resolve is "not available": a commit that is present
411
+ // but missing a blob is a problem with the evidence, and the map and the change
412
+ // record must still be reconciled against it.
413
+ if (tryGit(root, ['rev-parse', '--verify', `${doc.candidate}^{commit}`]).error) {
414
+ opts.limitations.push(`the candidate ${doc.candidate.slice(0, 7)} is not available in this repository; the snapshots were validated against themselves only`);
415
+ return;
416
+ }
417
+ const prdShown = show(doc.prd);
418
+ const parsed = prdShown.error ? null : requirements.parseInventory(prdShown.out, { prd: doc.prd });
419
+ if (prdShown.error) problem(`the candidate carries no ${doc.prd}`);
420
+ else if (!parsed.ok) problem(`the candidate's PRD does not parse strictly (${parsed.problems[0]})`);
421
+ else if (parsed.inventory.digest !== cov.inventory) {
422
+ const d = requirements.difference(inv, requirements.snapshotOf(parsed.inventory));
423
+ const extra = d.scenarios.added.length ? `the candidate's PRD defines ${d.scenarios.added.join(', ')}, which the manifest omits` : d.scenarios.removed.length ? `the manifest lists ${d.scenarios.removed.join(', ')}, which the candidate's PRD does not define` : d.scenarios.changed.length ? `${d.scenarios.changed.map(c => c.id).join(', ')} differ from the candidate's PRD` : 'the inventory differs';
424
+ problem(`coverage.inventory does not equal the inventory of the candidate's PRD: ${extra}`);
425
+ }
426
+ const mapShown = show(coverage.file(changeId));
427
+ if (mapShown.error) problem(`the candidate carries no ${coverage.file(changeId)}`);
428
+ else {
429
+ const v = coverage.validateText(mapShown.out, { change: changeId, prd: doc.prd });
430
+ if (v.problem) problem(`the candidate's coverage map cannot be read (${v.problem})`);
431
+ else if (v.digest !== cov.map) problem(`coverage.map does not equal the digest of the candidate's ${coverage.file(changeId)} (a substituted map)`);
432
+ }
433
+ const recShown = show(`.prd/changes/${changeId}.json`);
434
+ if (recShown.error) problem(`the candidate carries no change record .prd/changes/${changeId}.json`);
435
+ else {
436
+ let rec = null;
437
+ try { rec = JSON.parse(recShown.out); } catch { rec = null; }
438
+ if (!rec || rec.schema !== 3) problem(`the candidate's change record is not a strict (schema 3) record`);
439
+ else {
440
+ const entry = (rec.agreements || []).find(g => g.digest === cov.agreement);
441
+ if (!entry) problem(`coverage.agreement ${cov.agreement.slice(0, 12)} is not an agreement of the candidate's change record`);
442
+ else if (entry.inventory !== cov.inventory || entry.coverage !== cov.map) problem(`agreement ${entry.id} binds inventory ${String(entry.inventory).slice(0, 12)} and map ${String(entry.coverage).slice(0, 12)}, not the manifest's`);
443
+ const auth = (rec.authorizations || []).find(a => a.id === cov.authorization);
444
+ if (!auth || auth.digest !== cov.agreement) problem(`coverage.authorization ${cov.authorization} does not bind agreement ${cov.agreement.slice(0, 12)} in the candidate's change record`);
445
+ // A non-delivered row is only as good as the decision and the user authorization
446
+ // the candidate's own record carries — the same rule the local report applies.
447
+ const dispositions = require('./dispositions.cjs');
448
+ for (const [id, row] of rows) {
449
+ if (row.disposition !== 'deferred' && row.disposition !== 'removed') continue;
450
+ const decision = (rec.decisions || []).find(d => d.id === row.decision);
451
+ if (!decision) { problem(`scenario ${id}: decision ${row.decision} is not a decision of the candidate's change record`); continue; }
452
+ if (decision.status !== 'resolved') { problem(`scenario ${id}: decision ${row.decision} is ${decision.status} in the candidate's change record, not resolved`); continue; }
453
+ if (!dispositions.namesId(decision, id)) { problem(`scenario ${id}: decision ${row.decision} does not name ${id}`); continue; }
454
+ const named = (rec.authorizations || []).find(a => a.id === row.authorization);
455
+ if (!named) { problem(`scenario ${id}: authorization ${row.authorization} is not an authorization of the candidate's change record`); continue; }
456
+ const applicable = dispositions.applicableUserAuthorization(rec, named, row.decision);
457
+ if (!applicable.authorization) problem(`scenario ${id}: authorization ${row.authorization} does not carry a user decision for ${row.decision} (${applicable.reason})`);
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ // Schema 2: every check declares its provenance; a passed or failed command
464
+ // result exists only as a runtime attempt whose log digest the manifest carries.
465
+ function validateProvenance(check, name, doc, problem) {
466
+ if (!PROVENANCE.includes(check.provenance)) { problem(`check ${name}: provenance must be runtime or authored`); return; }
467
+ const artifactDigest = p => { const entry = Array.isArray(doc.artifacts) ? doc.artifacts.find(a => isObject(a) && a.path === p) : null; return entry ? entry.sha256 : null; };
468
+ if (check.provenance === 'authored') {
469
+ if ('attempt' in check) problem(`check ${name}: an authored check carries no attempt`);
470
+ if (check.kind === 'command' && ['passed', 'failed'].includes(check.result)) problem(`check ${name}: a ${check.result} command check must have runtime provenance (run it through pincer-runtime.cjs check)`);
471
+ return;
472
+ }
473
+ if (check.kind !== 'command') problem(`check ${name}: runtime provenance applies to command checks only`);
474
+ const a = check.attempt;
475
+ if (!isObject(a)) { problem(`check ${name}: runtime provenance requires an attempt object`); return; }
476
+ for (const key of Object.keys(a)) if (!ATTEMPT_KEYS.includes(key)) problem(`check ${name}: attempt.${key} is not allowed`);
477
+ for (const key of ATTEMPT_KEYS) if (!(key in a)) problem(`check ${name}: attempt.${key} is missing`);
478
+ if (!nonempty(a.id)) problem(`check ${name}: attempt.id must be a nonempty string`);
479
+ if (!Number.isInteger(a.sequence) || a.sequence < 1) problem(`check ${name}: attempt.sequence must be a positive integer`);
480
+ if (!OUTCOMES.includes(a.outcome)) problem(`check ${name}: attempt.outcome must be one of ${OUTCOMES.join(', ')}`);
481
+ else if (check.result !== resultFor(a.outcome)) problem(`check ${name}: result ${check.result} disagrees with attempt outcome ${a.outcome} (expected ${resultFor(a.outcome)})`);
482
+ if (a.exit_code !== null && !Number.isInteger(a.exit_code)) problem(`check ${name}: attempt.exit_code must be an integer or null`);
483
+ for (const key of ['started', 'finished']) if (typeof a[key] !== 'string' || !ISO_UTC.test(a[key])) problem(`check ${name}: attempt.${key} must be an ISO-8601 UTC timestamp`);
484
+ for (const key of ['source_before', 'source_after']) if (a[key] !== null && (typeof a[key] !== 'string' || !SHA256.test(a[key]))) problem(`check ${name}: attempt.${key} must be a 64-hex digest or null`);
485
+ if (typeof a.check_digest !== 'string' || !SHA256.test(a.check_digest)) problem(`check ${name}: attempt.check_digest must be a 64-hex digest`);
486
+ if (!isObject(a.runner) || !nonempty(a.runner.shell) || !Array.isArray(a.runner.args) || !nonempty(a.runner.version)) problem(`check ${name}: attempt.runner must be {shell, args, version}`);
487
+ if (!shortText(a.cwd)) problem(`check ${name}: attempt.cwd must be a short string`);
488
+ if (typeof a.truncated !== 'boolean') problem(`check ${name}: attempt.truncated must be true or false`);
489
+ if (typeof a.log_sha256 !== 'string' || !SHA256.test(a.log_sha256)) problem(`check ${name}: attempt.log_sha256 must be a 64-hex digest`);
490
+ else if (Array.isArray(check.artifacts)) {
491
+ const logs = check.artifacts.filter(p => typeof p === 'string' && /\.log$/.test(p));
492
+ if (logs.length !== 1) problem(`check ${name}: a runtime check references exactly one .log artifact`);
493
+ else if (artifactDigest(logs[0]) !== a.log_sha256) problem(`check ${name}: log artifact ${logs[0]} digest does not equal attempt.log_sha256`);
494
+ }
495
+ }
496
+
497
+ // --- Export (schema 2) ---------------------------------------------------------
498
+ // Build the candidate evidence set from a draft of authored fields and the
499
+ // runtime's attempts for the candidate. Never invents a review transcript and
500
+ // never converts a review judgment into a command result.
501
+ // `strict` (schema 3): { inventory, map, mapDigest, graph, scope: [{ id, disposition,
502
+ // decision, authorization }], agreement, authorization } — the draft then carries
503
+ // no requirements (dispositions are derived), every declared check appears once,
504
+ // command entries are populated from the declaration, and the inventory and map
505
+ // snapshots are written as listed artifacts.
506
+ function exportEvidence(root, { candidate, base, prd, draft, binding, attemptsFor, environment, now, atomicWrite, strict = null }) {
507
+ const version = prd.match(PRD_REF)[1];
508
+ const dirRel = `.prd/evidence/prd-v${version}/${candidate}`;
509
+ const dirAbs = path.join(root, dirRel);
510
+ const problems = [];
511
+ if (!isObject(draft)) return { problems: ['draft must be a JSON object'] };
512
+ const allowed = strict ? ['environment', 'coverage_review', 'adequacy', 'checks', 'visual_review'] : ['environment', 'coverage_review', 'requirements', 'checks', 'visual_review'];
513
+ for (const key of Object.keys(draft)) if (!allowed.includes(key)) problems.push(`draft: unknown key "${key}" (allowed: ${allowed.join(', ')})${strict && key === 'requirements' ? ' — dispositions are derived from the map and the outcomes in a strict change' : ''}`);
514
+ if (!Array.isArray(draft.checks)) problems.push('draft.checks must be an array');
515
+ if (strict && (!isObject(draft.adequacy) || !ADEQUACY.includes(draft.adequacy.verdict) || !nonempty(draft.adequacy.note))) problems.push('draft.adequacy must be { verdict: "adequate" | "inadequate", note } — the reviewer\'s judgment that the checks establish their scenarios');
516
+ if (problems.length) return { problems };
517
+ if (strict) {
518
+ // Every declared check exactly once; nothing undeclared; command entries come from the declaration.
519
+ const ids = draft.checks.filter(isObject).map(s => s.id);
520
+ for (const [id, c] of Object.entries(strict.map.checks)) if (!ids.includes(id)) problems.push(`draft check ${id} (${c.required ? 'required' : 'optional'} ${c.kind}) is missing: every declared check appears in the draft — an unused failing required check cannot be omitted`);
521
+ for (const id of ids) if (!strict.map.checks[id]) problems.push(`draft check ${id} is not declared in the coverage map`);
522
+ if (new Set(ids).size !== ids.length) problems.push('draft checks name a declared check twice');
523
+ if (problems.length) return { problems };
524
+ draft = { ...draft, checks: draft.checks.map(s => {
525
+ const c = strict.map.checks[s.id];
526
+ if (c.kind === 'command') {
527
+ if ('kind' in s && s.kind !== 'command') problems.push(`draft check ${s.id}: kind ${s.kind} disagrees with the declaration (command)`);
528
+ if ('required' in s && s.required !== c.required) problems.push(`draft check ${s.id}: required ${s.required} disagrees with the declaration (${c.required})`);
529
+ if ('command' in s && s.command !== c.command) problems.push(`draft check ${s.id}: the command text disagrees with the declaration`);
530
+ if ('result' in s) problems.push(`draft check ${s.id}: a command result cannot be authored; it is populated from the runtime attempt`);
531
+ return { id: s.id, kind: 'command', required: c.required, ...(s.note ? { note: s.note } : {}), ...(Array.isArray(s.artifacts) ? { artifacts: s.artifacts } : {}) };
532
+ }
533
+ if ('required' in s && s.required !== c.required) problems.push(`draft check ${s.id}: required ${s.required} disagrees with the declaration (${c.required})`);
534
+ if ('kind' in s && s.kind !== c.kind) problems.push(`draft check ${s.id}: kind ${s.kind} disagrees with the declaration (${c.kind})`);
535
+ return { ...s, kind: c.kind, required: c.required };
536
+ }) };
537
+ const reviews = require('./checks.cjs').reviewProblems(strict.map, Object.fromEntries(draft.checks.map(s => [s.id, s])), { dirRel, root });
538
+ for (const p of reviews) problems.push(`${p.code}: ${p.detail}`);
539
+ if (problems.length) return { problems };
540
+ }
541
+ const env = isObject(draft.environment) ? draft.environment : {};
542
+ const checks = [];
543
+ const artifactPaths = new Set();
544
+ for (const stub of draft.checks) {
545
+ if (!isObject(stub) || typeof stub.id !== 'string' || !CHECK_ID.test(stub.id)) { problems.push(`draft.checks entries must be objects with a check ID such as C-01 (got ${JSON.stringify(isObject(stub) ? stub.id : stub)})`); continue; }
546
+ // Authored artifact paths are validated before anything is written: no
547
+ // traversal, no absolute paths, and inside this candidate's evidence directory.
548
+ for (const p of Array.isArray(stub.artifacts) ? stub.artifacts : []) {
549
+ if (typeof p !== 'string') { problems.push(`draft check ${stub.id}: artifact references must be strings`); continue; }
550
+ const why = unsafePath(p);
551
+ if (why) problems.push(`draft check ${stub.id}: artifact ${p}: ${why}`);
552
+ else if (!p.startsWith(`${dirRel}/`)) problems.push(`draft check ${stub.id}: artifact ${p}: outside the evidence directory ${dirRel}/`);
553
+ }
554
+ const isStub = stub.kind === 'command' && !('result' in stub);
555
+ if (!isStub) {
556
+ if (stub.kind === 'command' && ['passed', 'failed'].includes(stub.result)) { problems.push(`draft check ${stub.id}: a ${stub.result} command result cannot be authored; omit result to populate it from the runtime attempt`); continue; }
557
+ const authored = { ...stub, provenance: 'authored' };
558
+ delete authored.attempt;
559
+ for (const p of authored.artifacts || []) artifactPaths.add(p);
560
+ checks.push(authored);
561
+ continue;
562
+ }
563
+ const { attempt, pointed } = attemptsFor(stub.id);
564
+ if (!attempt) { problems.push(`draft check ${stub.id}: no runtime attempt for candidate ${candidate}; run: node scripts/pincer-runtime.cjs check ${stub.id} --candidate ${candidate} -- <command>`); continue; }
565
+ // The record must be complete, written for this check, and its captured
566
+ // logs must still match the digests it recorded; anything else is refused.
567
+ const changesMode = binding.mode === 'changes';
568
+ const invalid = validateAttempt(attempt, contextKey({ kind: 'candidate', change: binding.change, candidate, check: stub.id, mode: binding.mode }), pointed || null);
569
+ if (invalid) { problems.push(`draft check ${stub.id}: attempt ${typeof attempt.id === 'string' ? attempt.id : '?'} ${invalid}; run the check again`); continue; }
570
+ const wanted = changesMode ? (binding.strict ? 3 : 2) : 1;
571
+ if (changesMode && attempt.schema !== wanted) { problems.push(`draft check ${stub.id}: attempt ${attempt.id} was recorded under schema ${attempt.schema} (${attempt.schema < wanted ? (wanted === 3 ? 'before this change adopted strict coverage' : 'before this project used change records') : 'for a strict change; this change has not adopted strict coverage'}) and is history; run the check again`); continue; }
572
+ if (changesMode && attempt.context.change !== binding.change) { problems.push(`draft check ${stub.id}: attempt ${attempt.id} belongs to change ${attempt.context.change}, not ${binding.change}; run the check again`); continue; }
573
+ if (attempt.outcome === 'running') { problems.push(`draft check ${stub.id}: attempt ${attempt.id} is still running`); continue; }
574
+ const logRel = `${dirRel}/checks/${stub.id}.log`;
575
+ const pieces = [`$ ${(attempt.check && attempt.check.display) || ''}`.replace(/\n$/, ''), ''];
576
+ let missing = false, altered = null;
577
+ for (const stream of ['stdout', 'stderr']) {
578
+ const info = attempt.artifacts[stream];
579
+ const file = path.join(root, info.path);
580
+ let text = '';
581
+ if (fs.existsSync(file)) {
582
+ const data = fs.readFileSync(file);
583
+ if (info.sha256 === null) { altered = altered || `captured ${stream} log ${info.path} has no recorded digest (the attempt was finalized by recover)`; }
584
+ else if (crypto.createHash('sha256').update(data).digest('hex') !== info.sha256) { altered = altered || `captured ${stream} log ${info.path} does not match the digest the attempt recorded`; }
585
+ text = data.toString('utf8');
586
+ } else { missing = true; text = '[pincer: captured log missing from local state]'; }
587
+ pieces.push(`--- ${stream}${info.truncated ? ' (truncated by the runtime)' : ''}${info.redactions ? ` (${info.redactions} redaction(s))` : ''} ---`);
588
+ pieces.push(text.replace(/\n$/, ''));
589
+ }
590
+ if (missing) { problems.push(`draft check ${stub.id}: attempt ${attempt.id} has no captured log; run the check again`); continue; }
591
+ if (altered) { problems.push(`draft check ${stub.id}: attempt ${attempt.id}: ${altered}; run the check again`); continue; }
592
+ pieces.push(`--- outcome ${attempt.outcome}${attempt.exit_code !== null && attempt.exit_code !== undefined ? ` (exit ${attempt.exit_code})` : ''} ---`);
593
+ const content = `${pieces.join('\n')}\n`;
594
+ atomicWrite(path.join(dirAbs, 'checks', `${stub.id}.log`), content);
595
+ artifactPaths.add(logRel);
596
+ checks.push({
597
+ id: stub.id, kind: 'command', required: Boolean(stub.required), result: resultFor(attempt.outcome),
598
+ command: (attempt.check && attempt.check.display || '').replace(/\n$/, ''), timestamp: attempt.finished || attempt.started,
599
+ artifacts: [logRel, ...(stub.artifacts || [])], provenance: 'runtime',
600
+ attempt: {
601
+ id: attempt.id, sequence: attempt.sequence, outcome: attempt.outcome, exit_code: attempt.exit_code ?? null,
602
+ started: attempt.started, finished: attempt.finished, source_before: attempt.source ? attempt.source.before : null, source_after: attempt.source ? attempt.source.after : null,
603
+ check_digest: attempt.check.digest, runner: attempt.runner, cwd: attempt.cwd || '.', log_sha256: crypto.createHash('sha256').update(content).digest('hex'), truncated: Boolean((attempt.artifacts.stdout && attempt.artifacts.stdout.truncated) || (attempt.artifacts.stderr && attempt.artifacts.stderr.truncated)),
604
+ },
605
+ ...(stub.note ? { note: stub.note } : {}), ...(attempt.outcome === 'error' ? { note: `${stub.note ? `${stub.note}; ` : ''}attempt error: ${attempt.error}` } : {}),
606
+ });
607
+ }
608
+ if (problems.length) return { problems };
609
+ // Strict: the declaration digest per check, the inventory and map snapshots as listed artifacts.
610
+ let strictParts = null;
611
+ if (strict) {
612
+ for (const c of checks) c.declared = coverage.definitionDigest(strict.map.checks[c.id]);
613
+ const invSnap = requirements.snapshotOf(strict.inventory);
614
+ const inventoryRel = `${dirRel}/coverage/inventory.json`, mapRel = `${dirRel}/coverage/map.json`;
615
+ atomicWrite(path.join(root, inventoryRel), `${JSON.stringify({ schema: 1, prd, digest: invSnap.digest, projection: invSnap.projection, requirements: invSnap.requirements, scenarios: invSnap.scenarios }, null, 2)}\n`);
616
+ atomicWrite(path.join(root, mapRel), `${JSON.stringify({ schema: 1, path: coverage.file(binding.change), digest: strict.mapDigest, map: strict.map }, null, 2)}\n`);
617
+ artifactPaths.add(inventoryRel); artifactPaths.add(mapRel);
618
+ strictParts = { inventoryRel, mapRel, invSnap };
619
+ }
620
+ const artifacts = [];
621
+ for (const p of [...artifactPaths].sort()) {
622
+ const abs = path.join(root, p);
623
+ if (!fs.existsSync(abs)) { problems.push(`artifact ${p}: missing (authored artifacts must be saved before export)`); continue; }
624
+ artifacts.push({ path: p, sha256: digestFile(abs) });
625
+ }
626
+ if (problems.length) return { problems };
627
+ const manifest = {
628
+ schema: strict ? 3 : 2, prd, base, candidate, created: now,
629
+ environment: { os: environment.os, node: environment.node, tools: env.tools || [], limitations: env.limitations || [] },
630
+ coverage_review: draft.coverage_review, requirements: draft.requirements, checks, visual_review: draft.visual_review, artifacts,
631
+ change: { id: binding.change, prd_revision: binding.prd_revision, base: binding.base },
632
+ };
633
+ if (strict) {
634
+ // Rows are derived: a scenario is delivered when every required check it links passed;
635
+ // a dispositioned one names its decision and the user authorization covering it.
636
+ const passed = new Set(checks.filter(c => c.result === 'passed').map(c => c.id));
637
+ const inv = strictParts.invSnap;
638
+ const scopeAuth = Object.fromEntries((strict.scope || []).map(s => [s.id, s]));
639
+ const scenarioRows = [];
640
+ const ids = requirements.sortIds([...new Set([...Object.keys(inv.scenarios), ...Object.keys(strict.map.scope).filter(id => strict.map.scope[id].disposition === 'removed')])]);
641
+ for (const id of ids) {
642
+ const scope = strict.map.scope[id];
643
+ if (scope) {
644
+ const g = strict.graph.scope[id] || {};
645
+ scenarioRows.push({ id, requirement: inv.scenarios[id] ? inv.scenarios[id].requirement : g.requirement || null, disposition: scope.disposition, tickets: [], checks: [], decision: scope.decision, authorization: scopeAuth[id] ? scopeAuth[id].authorization : null, note: scope.note });
646
+ continue;
647
+ }
648
+ const row = strict.map.scenarios[id];
649
+ const delivered = row.checks.every(c => !strict.map.checks[c].required || passed.has(c));
650
+ scenarioRows.push({ id, requirement: inv.scenarios[id].requirement, disposition: delivered ? 'delivered' : 'blocked', tickets: requirements.sortIds(row.tickets), checks: requirements.sortIds(row.checks), decision: null, authorization: null, note: null });
651
+ }
652
+ const rowOf = Object.fromEntries(scenarioRows.map(r => [r.id, r]));
653
+ const requirementRows = requirements.sortIds(Object.keys(inv.requirements)).map(id => {
654
+ const scen = inv.requirements[id].scenarios;
655
+ const states = scen.map(sid => rowOf[sid].disposition);
656
+ const disposition = states.every(x => x === 'delivered') ? 'delivered' : states.every(x => x === 'removed') ? 'removed' : states.every(x => ['delivered', 'deferred', 'removed'].includes(x)) ? 'deferred' : 'blocked';
657
+ const tickets = requirements.sortIds([...new Set(scen.flatMap(sid => rowOf[sid].tickets))]);
658
+ const checkIds = requirements.sortIds([...new Set(scen.flatMap(sid => rowOf[sid].checks))]);
659
+ const decisions = [...new Set(scen.map(sid => rowOf[sid].decision).filter(Boolean))];
660
+ return { id, disposition, tickets, checks: checkIds, scenarios: [...scen], decision: decisions.length === 1 && disposition !== 'delivered' && disposition !== 'blocked' ? decisions[0] : null, authorization: decisions.length === 1 && disposition !== 'delivered' && disposition !== 'blocked' ? (scen.map(sid => rowOf[sid].authorization).find(Boolean) || null) : null, note: null };
661
+ });
662
+ manifest.requirements = requirementRows;
663
+ manifest.coverage = { agreement: strict.agreement, authorization: strict.authorization, inventory: inv.digest, map: strict.mapDigest, snapshots: { inventory: strictParts.inventoryRel, map: strictParts.mapRel } };
664
+ manifest.scenarios = scenarioRows;
665
+ manifest.adequacy = { verdict: draft.adequacy.verdict, note: draft.adequacy.note };
666
+ manifest.delivery = { original: scenarioRows.every(r => inv.scenarios[r.id] && r.disposition === 'delivered'), agreed: scenarioRows.every(r => ['delivered', 'deferred', 'removed'].includes(r.disposition)) };
667
+ }
668
+ const manifestRel = `${dirRel}/manifest.json`;
669
+ atomicWrite(path.join(root, manifestRel), `${JSON.stringify(manifest, null, 2)}\n`);
670
+ const validationOpts = { candidate, base, prd };
671
+ const validation = validate(path.join(root, manifestRel), validationOpts, root);
672
+ return { manifest: manifestRel, problems: validation, limitations: validationOpts.limitations || [], schema: manifest.schema, delivery: manifest.delivery || null };
673
+ }
674
+
675
+
676
+ module.exports = { SCHEMA, SCHEMAS, HEX40, PRD_REF, CHECK_ID, validate, exportEvidence, resultFor, digestFile, repoRoot, realpathDeep, unsafePath };