pincer-workflow 0.5.0 → 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 (58) hide show
  1. package/README.md +6 -6
  2. package/bin/pincer.js +17 -1
  3. package/package.json +3 -3
  4. package/template/.agents/skills/pincer-code/SKILL.md +85 -14
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +42 -13
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +43 -12
  7. package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
  8. package/template/.agents/skills/pincer-release/SKILL.md +18 -0
  9. package/template/.agents/skills/pincer-status/SKILL.md +12 -2
  10. package/template/.claude/commands/pincer-code.md +85 -14
  11. package/template/.claude/commands/pincer-evaluate.md +42 -13
  12. package/template/.claude/commands/pincer-narrow.md +43 -12
  13. package/template/.claude/commands/pincer-plan.md +12 -4
  14. package/template/.claude/commands/pincer-release.md +18 -0
  15. package/template/.claude/commands/pincer-status.md +12 -2
  16. package/template/.claude/hooks/hook-policy.cjs +13 -6
  17. package/template/.claude/references/prd-template.md +11 -4
  18. package/template/.codex/README.md +1 -1
  19. package/template/.github/prompts/pincer-code.prompt.md +85 -14
  20. package/template/.github/prompts/pincer-evaluate.prompt.md +42 -13
  21. package/template/.github/prompts/pincer-narrow.prompt.md +43 -12
  22. package/template/.github/prompts/pincer-plan.prompt.md +12 -4
  23. package/template/.github/prompts/pincer-release.prompt.md +18 -0
  24. package/template/.github/prompts/pincer-status.prompt.md +12 -2
  25. package/template/AGENTS.md +17 -1
  26. package/template/docs/dry-run-checklist.md +30 -3
  27. package/template/docs/release-checklist.md +3 -1
  28. package/template/docs/runtime-contracts.md +1342 -96
  29. package/template/scripts/pincer-evidence.cjs +9 -7
  30. package/template/scripts/pincer-runtime/adopt.cjs +132 -0
  31. package/template/scripts/pincer-runtime/agreement.cjs +240 -0
  32. package/template/scripts/pincer-runtime/authorization.cjs +167 -0
  33. package/template/scripts/pincer-runtime/changes.cjs +517 -0
  34. package/template/scripts/pincer-runtime/checks.cjs +48 -0
  35. package/template/scripts/pincer-runtime/coverage.cjs +361 -0
  36. package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
  37. package/template/scripts/pincer-runtime/evidence.cjs +303 -18
  38. package/template/scripts/pincer-runtime/gates.cjs +73 -0
  39. package/template/scripts/pincer-runtime/identity.cjs +21 -4
  40. package/template/scripts/pincer-runtime/impact.cjs +177 -0
  41. package/template/scripts/pincer-runtime/io.cjs +41 -0
  42. package/template/scripts/pincer-runtime/lifecycle.cjs +34 -12
  43. package/template/scripts/pincer-runtime/locator.cjs +158 -0
  44. package/template/scripts/pincer-runtime/migrate.cjs +140 -63
  45. package/template/scripts/pincer-runtime/parse.cjs +20 -1
  46. package/template/scripts/pincer-runtime/phases.cjs +245 -0
  47. package/template/scripts/pincer-runtime/readiness.cjs +9 -1
  48. package/template/scripts/pincer-runtime/requirements.cjs +255 -0
  49. package/template/scripts/pincer-runtime/resume.cjs +205 -0
  50. package/template/scripts/pincer-runtime/routing.cjs +54 -0
  51. package/template/scripts/pincer-runtime/runner.cjs +24 -6
  52. package/template/scripts/pincer-runtime/state.cjs +29 -7
  53. package/template/scripts/pincer-runtime/status.cjs +178 -22
  54. package/template/scripts/pincer-runtime/transaction.cjs +200 -0
  55. package/template/scripts/pincer-runtime/transitions.cjs +134 -0
  56. package/template/scripts/pincer-runtime.cjs +387 -76
  57. package/template/scripts/pincer-status.sh +1 -1
  58. package/template/scripts/pincer-ticket.sh +1 -1
@@ -13,6 +13,9 @@ const path = require('node:path');
13
13
  const crypto = require('node:crypto');
14
14
  const { execFileSync } = require('node:child_process');
15
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');
16
19
 
17
20
  const SCHEMA = 1;
18
21
  const HEX40 = /^[0-9a-f]{40}$/;
@@ -36,9 +39,19 @@ const KINDS = ['command', 'visual', 'review'];
36
39
  const RESULTS = ['passed', 'failed', 'unverified'];
37
40
  // Schema 2 (docs/runtime-contracts.md, "Evidence schema 2"): a change binding,
38
41
  // provenance per check, and attempt provenance on runtime command checks.
39
- const SCHEMAS = [1, 2];
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];
40
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'];
41
53
  const CHECK_KEYS_2 = [...CHECK_KEYS, 'provenance', 'attempt'];
54
+ const CHECK_KEYS_3 = [...CHECK_KEYS_2, 'declared'];
42
55
  const PROVENANCE = ['runtime', 'authored'];
43
56
  const ATTEMPT_KEYS = ['id', 'sequence', 'outcome', 'exit_code', 'started', 'finished', 'source_before', 'source_after', 'check_digest', 'runner', 'cwd', 'log_sha256', 'truncated'];
44
57
  const OUTCOMES = ['passed', 'failed', 'timed_out', 'interrupted', 'error'];
@@ -95,10 +108,14 @@ function validate(manifestArg, opts, rootArg) {
95
108
  let doc;
96
109
  try { doc = JSON.parse(raw); } catch (error) { return [`malformed JSON (${error.message})`]; }
97
110
  if (!isObject(doc)) return ['malformed: the manifest must be a JSON object'];
98
- if (!SCHEMAS.includes(doc.schema)) return [`unknown evidence schema ${JSON.stringify(doc.schema)} — this runtime validates schemas ${SCHEMAS.join(' and ')}`];
99
- const schema2 = doc.schema === 2;
100
- const topKeys = schema2 ? TOP_KEYS_2 : TOP_KEYS;
101
- const checkKeys = schema2 ? CHECK_KEYS_2 : CHECK_KEYS;
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 = [];
102
119
 
103
120
  const problems = [];
104
121
  const problem = message => problems.push(message);
@@ -189,6 +206,8 @@ function validate(manifestArg, opts, rootArg) {
189
206
  for (const key of Object.keys(check)) if (!checkKeys.includes(key)) problem(`check ${name}: unknown key "${key}"`);
190
207
  if (!KINDS.includes(check.kind)) problem(`check ${name}: kind must be one of ${KINDS.join(', ')}`);
191
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`);
192
211
  if (typeof check.required !== 'boolean') problem(`check ${name}: required must be true or false`);
193
212
  if (!RESULTS.includes(check.result)) problem(`check ${name}: result must be one of ${RESULTS.join(', ')}`);
194
213
  if (typeof check.timestamp !== 'string' || !ISO_UTC.test(check.timestamp)) problem(`check ${name}: timestamp must be an ISO-8601 UTC timestamp`);
@@ -215,7 +234,8 @@ function validate(manifestArg, opts, rootArg) {
215
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`);
216
235
  }
217
236
  });
218
- for (const [p, referenced] of artifacts) if (!referenced) problem(`artifact ${p}: not referenced by any check`);
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`);
219
239
 
220
240
  // Requirements: every ID dispositioned; deferrals authorized; checks resolve.
221
241
  const requirements = new Set();
@@ -228,16 +248,16 @@ function validate(manifestArg, opts, rootArg) {
228
248
  else if (requirements.has(id)) problem(`duplicate requirement ID ${id}`);
229
249
  else requirements.add(id);
230
250
  const name = id || label;
231
- for (const key of Object.keys(req)) if (!REQ_KEYS.includes(key)) problem(`requirement ${name}: unknown key "${key}"`);
232
- if (!DISPOSITIONS.includes(req.disposition)) problem(`requirement ${name}: disposition must be one of ${DISPOSITIONS.join(', ')}`);
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(', ')}`);
233
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`);
234
254
  if (!Array.isArray(req.checks)) problem(`requirement ${name}: checks must be an array of check IDs`);
235
255
  else for (const c of req.checks) {
236
256
  if (typeof c !== 'string' || !checks.has(c)) problem(`requirement ${name}: references unknown check ${JSON.stringify(c)} (dangling reference)`);
237
257
  }
238
- for (const key of ['note', 'authorized_by']) if (key in req && !shortText(req[key])) problem(`requirement ${name}: ${key} must be a short string`);
239
- if (req.disposition === 'deferred' && !nonempty(req.authorized_by)) problem(`requirement ${name}: deferred requires authorized_by naming the explicit user authorization`);
240
- if (req.disposition === 'delivered' && Array.isArray(req.checks) && req.checks.length === 0) problem(`requirement ${name}: delivered requires at least one check`);
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`);
241
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`);
242
262
  });
243
263
 
@@ -250,11 +270,195 @@ function validate(manifestArg, opts, rootArg) {
250
270
  if ('reason' in visual && !shortText(visual.reason)) problem('visual_review.reason must be a short string');
251
271
  }
252
272
 
273
+ if (schema3) validateStrict(doc, { root, rel, dirRel, checks, requirements: requirementsSeen(doc), opts }, problem);
274
+
253
275
  if (problems.length === 0 && opts.files) {
254
276
  opts.list = [rel, ...doc.artifacts.map(a => a.path)];
255
277
  }
256
278
  return problems;
257
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
+ }
258
462
 
259
463
  // Schema 2: every check declares its provenance; a passed or failed command
260
464
  // result exists only as a runtime attempt whose log digest the manifest carries.
@@ -294,16 +498,46 @@ function validateProvenance(check, name, doc, problem) {
294
498
  // Build the candidate evidence set from a draft of authored fields and the
295
499
  // runtime's attempts for the candidate. Never invents a review transcript and
296
500
  // never converts a review judgment into a command result.
297
- function exportEvidence(root, { candidate, base, prd, draft, binding, attemptsFor, environment, now, atomicWrite }) {
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 }) {
298
507
  const version = prd.match(PRD_REF)[1];
299
508
  const dirRel = `.prd/evidence/prd-v${version}/${candidate}`;
300
509
  const dirAbs = path.join(root, dirRel);
301
510
  const problems = [];
302
511
  if (!isObject(draft)) return { problems: ['draft must be a JSON object'] };
303
- const allowed = ['environment', 'coverage_review', 'requirements', 'checks', 'visual_review'];
304
- for (const key of Object.keys(draft)) if (!allowed.includes(key)) problems.push(`draft: unknown key "${key}" (allowed: ${allowed.join(', ')})`);
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' : ''}`);
305
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');
306
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
+ }
307
541
  const env = isObject(draft.environment) ? draft.environment : {};
308
542
  const checks = [];
309
543
  const artifactPaths = new Set();
@@ -330,8 +564,12 @@ function exportEvidence(root, { candidate, base, prd, draft, binding, attemptsFo
330
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; }
331
565
  // The record must be complete, written for this check, and its captured
332
566
  // logs must still match the digests it recorded; anything else is refused.
333
- const invalid = validateAttempt(attempt, contextKey({ kind: 'candidate', candidate, check: stub.id }), pointed || null);
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);
334
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; }
335
573
  if (attempt.outcome === 'running') { problems.push(`draft check ${stub.id}: attempt ${attempt.id} is still running`); continue; }
336
574
  const logRel = `${dirRel}/checks/${stub.id}.log`;
337
575
  const pieces = [`$ ${(attempt.check && attempt.check.display) || ''}`.replace(/\n$/, ''), ''];
@@ -368,6 +606,17 @@ function exportEvidence(root, { candidate, base, prd, draft, binding, attemptsFo
368
606
  });
369
607
  }
370
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
+ }
371
620
  const artifacts = [];
372
621
  for (const p of [...artifactPaths].sort()) {
373
622
  const abs = path.join(root, p);
@@ -376,15 +625,51 @@ function exportEvidence(root, { candidate, base, prd, draft, binding, attemptsFo
376
625
  }
377
626
  if (problems.length) return { problems };
378
627
  const manifest = {
379
- schema: 2, prd, base, candidate, created: now,
628
+ schema: strict ? 3 : 2, prd, base, candidate, created: now,
380
629
  environment: { os: environment.os, node: environment.node, tools: env.tools || [], limitations: env.limitations || [] },
381
630
  coverage_review: draft.coverage_review, requirements: draft.requirements, checks, visual_review: draft.visual_review, artifacts,
382
631
  change: { id: binding.change, prd_revision: binding.prd_revision, base: binding.base },
383
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
+ }
384
668
  const manifestRel = `${dirRel}/manifest.json`;
385
669
  atomicWrite(path.join(root, manifestRel), `${JSON.stringify(manifest, null, 2)}\n`);
386
- const validation = validate(path.join(root, manifestRel), { candidate, base, prd }, root);
387
- return { manifest: manifestRel, problems: validation };
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 };
388
673
  }
389
674
 
390
675
 
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ // PINCER runtime — command gates (docs/runtime-contracts.md, "Command gates").
3
+ // In changes mode every execution or lifecycle-writing command passes this one
4
+ // guard before a child process is spawned or a ticket file written: the
5
+ // records must be readable, a change must be selected and own the ticket (or
6
+ // PRD), its lifecycle state must permit the command, the repository view must
7
+ // be compatible, and the authorization verdict must be `current`. Refusals carry
8
+ // the contracted code, in the contracted order, and nothing has been written.
9
+ const changes = require('./changes.cjs');
10
+ const agreement = require('./agreement.cjs');
11
+ const authorization = require('./authorization.cjs');
12
+ const { refuse } = require('./transaction.cjs');
13
+
14
+ // Lifecycle states that permit each command.
15
+ const PERMITTED = {
16
+ start: ['active'], done: ['active'], verify: ['active', 'completed'],
17
+ check: ['completed'], export: ['completed'],
18
+ };
19
+ const ORDER = ['INPUT_INVALID', 'INVENTORY_INVALID', 'COVERAGE_INVALID', 'MALFORMED', 'UNSUPPORTED_SCHEMA', 'HISTORY_INVALID', 'STATE_INCOMPLETE', 'SELECTION_REQUIRED', 'SELECTION_INVALID', 'WRONG_CHANGE', 'LIFECYCLE_BLOCKED', 'BASE_MISMATCH', 'DECISION_REQUIRED', 'AUTHORIZATION_REQUIRED', 'AGREEMENT_CHANGED'];
20
+
21
+ // guard(root, { command, ticket: { file, fields } | null, prd: string | null })
22
+ // Returns { id, record, file, selection, view, computed, verdict, binding } where
23
+ // `binding` is the context every attempt, readiness and export consumes:
24
+ // { change, prd, prd_revision, base, agreement, legacy_receipts, mode: 'changes' }.
25
+ // Throws a transaction Refusal on the first failing gate.
26
+ function guard(root, { command, ticket = null, prd = null }) {
27
+ const resolved = changes.resolveSelected(root, {});
28
+ if (resolved.code) {
29
+ const hint = resolved.code === 'SELECTION_REQUIRED' || resolved.code === 'SELECTION_INVALID' ? ` (execution needs the selected change; ${resolved.problem})` : `: ${resolved.problem}`;
30
+ refuse(resolved.code, `${command} refused${hint}`);
31
+ }
32
+ const { id, record, file, selection, loaded } = resolved;
33
+ // Ownership: the ticket's PRD, or the named PRD, must belong to the selected change.
34
+ let owner = null;
35
+ if (ticket) {
36
+ const o = changes.ticketOwner(root, loaded, ticket.file, ticket.fields);
37
+ if (o.problem) refuse(o.id ? 'WRONG_CHANGE' : 'INPUT_INVALID', `${command} refused: ${o.problem}`);
38
+ owner = o;
39
+ } else if (prd) {
40
+ const o = changes.ownerOf(loaded, prd);
41
+ if (!o) refuse('INPUT_INVALID', `${command} refused: ${prd} is owned by no change record; register it first`);
42
+ owner = { id: o[0], prd };
43
+ }
44
+ if (owner && owner.id !== id) refuse('WRONG_CHANGE', `${command} refused: ${ticket ? `${ticket.fields.ticket} (${owner.prd})` : owner.prd} belongs to change ${owner.id}, but ${id} is selected; select it first: node scripts/pincer-runtime.cjs change select ${owner.id}`);
45
+ const st = record.lifecycle.state;
46
+ if (!PERMITTED[command].includes(st)) {
47
+ const next = changes.TERMINAL.includes(st) ? `the change is ${st}${record.lifecycle.superseded_by ? ` by ${record.lifecycle.superseded_by}` : ''}; register a new change and reference this one`
48
+ : st === 'planned' ? `activate it first: node scripts/pincer-runtime.cjs change activate ${id}`
49
+ : st === 'paused' ? `resume it first: node scripts/pincer-runtime.cjs change resume ${id}${record.lifecycle.reason ? ` (paused: ${record.lifecycle.reason})` : ''}`
50
+ : st === 'completed' ? `${command} needs an active change; reopen it first: node scripts/pincer-runtime.cjs change reopen ${id} --reason <text>`
51
+ : `${command} needs a completed change; complete it first: node scripts/pincer-runtime.cjs change complete ${id}`;
52
+ refuse('LIFECYCLE_BLOCKED', `${command} refused: change ${id} is ${st} (${command} runs on ${PERMITTED[command].join(' or ')} changes); ${next}`);
53
+ }
54
+ const view = changes.view(root, record);
55
+ if (view.problems.length) refuse('BASE_MISMATCH', `${command} refused: ${view.problems[0].detail}`);
56
+ const computed = agreement.compute(root, record);
57
+ if (computed.code) refuse(computed.code, `${command} refused: ${computed.problem}`);
58
+ const verdict = authorization.verdict(root, record, computed);
59
+ if (verdict.verdict !== 'current') refuse(verdict.verdict, `${command} refused: ${verdict.detail}`);
60
+ // A strict change's binding carries its inventory and coverage map digests (attempt schema 3).
61
+ const binding = { change: id, prd: record.prd, prd_revision: computed.prd.revision, base: record.base, agreement: computed.digest, legacy_receipts: record.legacy.receipts, mode: 'changes', strict: Boolean(computed.inventory), ...(computed.inventory ? { inventory: computed.inventory.digest, coverage: computed.coverage.digest } : {}) };
62
+ return { id, record, file, selection, view, computed, verdict, binding, loaded };
63
+ }
64
+
65
+ // The read-only counterpart for status/ready: the same checks as reasons, none thrown.
66
+ function reasons(root, { command, ticket = null, prd = null }) {
67
+ try { guard(root, { command, ticket, prd }); return []; } catch (error) {
68
+ if (error && error.refusal) return [{ code: error.code, detail: error.message }];
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ module.exports = { guard, reasons, PERMITTED, ORDER };
@@ -14,6 +14,7 @@ const RUNTIME = 1;
14
14
  const BINDING_KEYS = ['schema', 'change', 'prd', 'prd_revision', 'base', 'registered', 'authorization', 'runtime', 'legacy_receipts'];
15
15
 
16
16
  const bindingsDir = root => path.join(root, '.prd', 'changes');
17
+ const CHANGES_HINT = 'this project keeps change records (schema 2) under .prd/changes/; inspect them with: node scripts/pincer-runtime.cjs change list';
17
18
  function listBindings(root) {
18
19
  const dir = bindingsDir(root);
19
20
  if (!fs.existsSync(dir)) return [];
@@ -41,6 +42,22 @@ function validateBinding(doc) {
41
42
  // caller needs — a binding for another PRD is CHANGE_REQUIRED with `other` set so
42
43
  // callers can treat that PRD as legacy.
43
44
  function loadBinding(root, { prd } = {}) {
45
+ // Mode first (docs/runtime-contracts.md, "Modes"): schema 2 records are the
46
+ // changes mode and never fall back to a binding or to legacy; mixed or
47
+ // unreadable directories are invalid.
48
+ const scan = require('./changes.cjs').scan(root);
49
+ if (scan.mode === 'changes') return { code: 'CHANGES_MODE', problem: `${CHANGES_HINT}`, scan };
50
+ if (scan.mode === 'invalid') return { code: scan.problems[0].code, problem: scan.problems[0].detail, scan };
51
+ // A committed transaction that was not fully applied is unsafe in every mode, not
52
+ // only in changes mode: `recover` finishes it by renaming its staged files into
53
+ // place, over anything written since. Changes mode raises this through
54
+ // changes.scan(); a migrated or legacy project reaches it here, and a migrated
55
+ // project is the one `migrate --apply` crashes in.
56
+ const pending = require('./transaction.cjs').pending(root);
57
+ if (pending.committed.length) {
58
+ const first = pending.committed[0];
59
+ return { code: 'STATE_INCOMPLETE', problem: `a committed transaction (${first.command || first.id}) was not fully applied; run: node scripts/pincer-runtime.cjs recover` };
60
+ }
44
61
  const files = listBindings(root);
45
62
  if (files.length === 0) {
46
63
  const hint = prd ? `register it with: node scripts/pincer-runtime.cjs register --prd ${prd}` : 'run register or migrate';
@@ -117,11 +134,11 @@ function register(root, { prd, change, authorization = null, replace = false, re
117
134
  if (invalid) return { code: 'MALFORMED', problem: `${files[0]}: ${invalid} — repair or remove it before registering` };
118
135
  existing = { file: files[0], binding: read.data };
119
136
  }
137
+ // v0.5.0 replaced the binding here (deleting the other PRD's); several changes
138
+ // are retained only by schema 2 records, so a second PRD needs the migration.
139
+ if (replace) return { code: 'MIGRATION_REQUIRED', problem: `--replace would delete ${existing ? existing.file : 'the binding'}; change records are retained instead — migrate first: node scripts/pincer-runtime.cjs migrate --preview --prd ${existing ? existing.binding.prd : prd}, then register ${prd} and select the change to work on with change select (retire one with change supersede or change cancel)` };
120
140
  if (existing && (existing.binding.prd !== prd || existing.binding.change !== id)) {
121
- if (!replace) return { code: 'AMBIGUOUS', problem: `${existing.file} already binds ${existing.binding.prd} as change "${existing.binding.change}"; one change per worktree — pass --replace to replace it (its attempts stay in local history)` };
122
- fs.unlinkSync(path.join(root, existing.file));
123
- const binding = { schema: 1, change: id, prd, prd_revision: revision, base, registered: nowIso(), authorization, runtime: RUNTIME, legacy_receipts: {} };
124
- return { binding, file: writeBinding(root, binding), action: 'replaced', replaced: existing.file, notes };
141
+ return { code: 'MIGRATION_REQUIRED', problem: `${existing.file} binds ${existing.binding.prd} as change "${existing.binding.change}"; one binding per worktree in migrated mode — migrate to change records first: node scripts/pincer-runtime.cjs migrate --preview --prd ${existing.binding.prd}, then register ${prd}` };
125
142
  }
126
143
  if (existing) {
127
144
  const current = existing.binding;