pincer-workflow 0.5.0 → 0.7.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 (59) hide show
  1. package/README.md +106 -19
  2. package/bin/pincer.js +17 -1
  3. package/package.json +3 -3
  4. package/template/.agents/skills/pincer-code/SKILL.md +88 -14
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +42 -13
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +53 -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 +29 -5
  10. package/template/.claude/commands/pincer-code.md +88 -14
  11. package/template/.claude/commands/pincer-evaluate.md +42 -13
  12. package/template/.claude/commands/pincer-narrow.md +53 -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 +29 -5
  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 +88 -14
  20. package/template/.github/prompts/pincer-evaluate.prompt.md +42 -13
  21. package/template/.github/prompts/pincer-narrow.prompt.md +53 -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 +29 -5
  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 +1428 -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 +15 -2
  48. package/template/scripts/pincer-runtime/requirements.cjs +255 -0
  49. package/template/scripts/pincer-runtime/resume.cjs +273 -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/scaffold.cjs +254 -0
  53. package/template/scripts/pincer-runtime/state.cjs +29 -7
  54. package/template/scripts/pincer-runtime/status.cjs +178 -22
  55. package/template/scripts/pincer-runtime/transaction.cjs +200 -0
  56. package/template/scripts/pincer-runtime/transitions.cjs +134 -0
  57. package/template/scripts/pincer-runtime.cjs +412 -76
  58. package/template/scripts/pincer-status.sh +1 -1
  59. package/template/scripts/pincer-ticket.sh +1 -1
@@ -6,12 +6,19 @@
6
6
  // node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md [--change <id>] [--authorization <text>] [--replace] [--rebind]
7
7
  // node scripts/pincer-runtime.cjs snapshot [--json] [--store]
8
8
  // node scripts/pincer-runtime.cjs recover
9
- // node scripts/pincer-runtime.cjs status [--json]
9
+ // node scripts/pincer-runtime.cjs status [--json] [--change <id>]
10
10
  // node scripts/pincer-runtime.cjs ready [T-NN]
11
11
  // node scripts/pincer-runtime.cjs start|verify|done T-NN · bind T-NN .prd/prd-vN.md
12
12
  // node scripts/pincer-runtime.cjs migrate --preview|--apply --prd .prd/prd-vN.md [--change <id>] [--authorization <text>]
13
13
  // node scripts/pincer-runtime.cjs check C-NN --candidate <sha> [--timeout <seconds>] -- <command...>
14
14
  // node scripts/pincer-runtime.cjs evidence export --candidate <sha> --base <sha> --prd .prd/prd-vN.md --draft <file>
15
+ // node scripts/pincer-runtime.cjs change list [--json] · change show <id> [--json] · change select <id> · change revise <id>
16
+ // node scripts/pincer-runtime.cjs change authorize <id> --agreement <digest> (--reference <text> --excerpt <text> | --delegated --basis A-NN --explanation <text>) [--decision D-NN]...
17
+ // node scripts/pincer-runtime.cjs change decide <id> --summary <text> [--id D-NN] | --resolve D-NN --reference <text> --excerpt <text>
18
+ // node scripts/pincer-runtime.cjs change activate|pause|resume|complete|reopen|cancel|supersede <id> [--reason <text>] [--note <text>] [--decision D-NN] [--with <id>]
19
+ // node scripts/pincer-runtime.cjs resume [--change <id>] [--brief] [--json]
20
+ // node scripts/pincer-runtime.cjs coverage [--change <id>] [--json] · coverage scaffold --change <id> [--json] · coverage adopt --preview|--apply --change <id> [--agreement <digest>]
21
+ // node scripts/pincer-runtime.cjs impact [--change <id>] [--from G-NN|A-NN] [--json]
15
22
  //
16
23
  // Exit codes: 0 ok · 1 failed/not ready/refused · 2 usage · 3 state busy ·
17
24
  // 4 invalid input or state · 124 timed out · 130 interrupted.
@@ -19,6 +26,7 @@ const fs = require('node:fs');
19
26
  const path = require('node:path');
20
27
  const { execFileSync } = require('node:child_process');
21
28
 
29
+ const io = require('./pincer-runtime/io.cjs');
22
30
  const parse = require('./pincer-runtime/parse.cjs');
23
31
  const identity = require('./pincer-runtime/identity.cjs');
24
32
  const source = require('./pincer-runtime/source.cjs');
@@ -29,6 +37,16 @@ const sanitize = require('./pincer-runtime/sanitize.cjs');
29
37
  const lifecycle = require('./pincer-runtime/lifecycle.cjs');
30
38
  const migrate = require('./pincer-runtime/migrate.cjs');
31
39
  const evidence = require('./pincer-runtime/evidence.cjs');
40
+ const changes = require('./pincer-runtime/changes.cjs');
41
+ const agreement = require('./pincer-runtime/agreement.cjs');
42
+ const authorization = require('./pincer-runtime/authorization.cjs');
43
+ const transitions = require('./pincer-runtime/transitions.cjs');
44
+ const gates = require('./pincer-runtime/gates.cjs');
45
+ const locator = require('./pincer-runtime/locator.cjs');
46
+ const resume = require('./pincer-runtime/resume.cjs');
47
+ const adopt = require('./pincer-runtime/adopt.cjs');
48
+ const impact = require('./pincer-runtime/impact.cjs');
49
+ const checks = require('./pincer-runtime/checks.cjs');
32
50
  const { atomicWrite, nowIso, tryGit } = require('./pincer-runtime/fsutil.cjs');
33
51
  const EXIT = { OK: 0, FAILED: 1, USAGE: 2, BUSY: 3, INVALID: 4, TIMED_OUT: 124, INTERRUPTED: 130 };
34
52
 
@@ -42,21 +60,44 @@ function repoRoot() {
42
60
  }
43
61
 
44
62
  function usage(message) {
45
- if (message) process.stderr.write(`pincer: ${message}\n`);
46
- process.stderr.write('usage: pincer-runtime.cjs validate <file>... [--digests]\n' +
63
+ if (message) io.err(`pincer: ${message}\n`);
64
+ io.err('usage: pincer-runtime.cjs validate <file>... [--digests]\n' +
47
65
  ' pincer-runtime.cjs register --prd .prd/prd-vN.md [--change <id>] [--authorization <text>] [--replace] [--rebind]\n' +
48
66
  ' pincer-runtime.cjs snapshot [--json] [--store]\n' +
49
67
  ' pincer-runtime.cjs recover\n' +
50
- ' pincer-runtime.cjs status [--json]\n' +
68
+ ' pincer-runtime.cjs status [--json] [--change <id>]\n' +
51
69
  ' pincer-runtime.cjs ready [T-NN]\n' +
52
70
  ' pincer-runtime.cjs start|verify|done T-NN\n' +
53
71
  ' pincer-runtime.cjs bind T-NN .prd/prd-vN.md\n' +
54
72
  ' pincer-runtime.cjs migrate --preview|--apply --prd .prd/prd-vN.md [--change <id>] [--authorization <text>]\n' +
55
73
  ' pincer-runtime.cjs check C-NN --candidate <sha> [--timeout <seconds>] -- <command...>\n' +
56
- ' pincer-runtime.cjs evidence export --candidate <sha> --base <sha> --prd .prd/prd-vN.md --draft <file>\n');
74
+ ' pincer-runtime.cjs evidence export --candidate <sha> --base <sha> --prd .prd/prd-vN.md --draft <file>\n' +
75
+ ' pincer-runtime.cjs change list [--json] · change show <id> [--json] · change select <id> · change revise <id>\n' +
76
+ ' pincer-runtime.cjs change authorize <id> --agreement <digest> (--reference <text> --excerpt <text> [--constraints <text>] | --delegated --basis A-NN --explanation <text>) [--decision D-NN]...\n' +
77
+ ' pincer-runtime.cjs change decide <id> --summary <text> [--id D-NN] | --resolve D-NN --reference <text> --excerpt <text>\n' +
78
+ ' pincer-runtime.cjs change activate|resume|complete <id> · change pause <id> --reason <text> [--note <text>] · change reopen <id> --reason <text>\n' +
79
+ ' pincer-runtime.cjs change cancel <id> --decision D-NN --reason <text> · change supersede <id> --with <id> --decision D-NN\n' +
80
+ ' pincer-runtime.cjs resume [--change <id>] [--brief] [--json] (the read-only report; `change resume` is the lifecycle operation)\n' +
81
+ ' pincer-runtime.cjs coverage [--change <id>] [--json] · coverage scaffold --change <id> [--json] · coverage adopt --preview|--apply --change <id> [--agreement <digest>]\n' +
82
+ ' pincer-runtime.cjs impact [--change <id>] [--from G-NN|A-NN] [--json]\n');
57
83
  process.exit(EXIT.USAGE);
58
84
  }
59
85
 
86
+ // The change context of a candidate command: the v0.5.0 binding in migrated
87
+ // mode, or the selected change's guarded context in changes mode (selected,
88
+ // owning the PRD, completed, compatible view, authorization current).
89
+ function candidateBinding(root, command, prd) {
90
+ const bind = identity.loadBinding(root, prd ? { prd } : {});
91
+ if (bind.code === 'CHANGES_MODE') {
92
+ try { return gates.guard(root, { command, prd }).binding; } catch (error) {
93
+ if (error && error.refusal) fail('pincer', `${error.code}: ${error.message}`, exitForCode(error.code));
94
+ throw error;
95
+ }
96
+ }
97
+ if (bind.code) fail('pincer', `${bind.code}: ${bind.problem}`, EXIT.INVALID);
98
+ return bind.binding;
99
+ }
100
+
60
101
  // The clean-view precondition for candidate checks and exports: HEAD is the
61
102
  // candidate and nothing is dirty outside NOTES.md and the candidate's evidence
62
103
  // directory. Never stashes, resets or commits.
@@ -64,7 +105,12 @@ function requireCandidateView(root, candidate, prd) {
64
105
  const head = identity.head(root);
65
106
  if (!head) fail('pincer', 'not a git repository with commits', EXIT.INVALID);
66
107
  const version = prd.match(parse.PRD_REF)[1];
67
- const allowed = p => p === 'NOTES.md' || p.startsWith(`.prd/evidence/prd-v${version}/${candidate}/`);
108
+ // Only this PRD's evidence directory for the candidate (being assembled by
109
+ // this evaluation) and the candidate's validated followers (NOTES.md, valid
110
+ // locators, listed artifacts of validated manifests for the same candidate —
111
+ // two changes may share a candidate) may differ. Never another whole directory.
112
+ const followers = locator.followers(root, candidate);
113
+ const allowed = p => p.startsWith(`.prd/evidence/prd-v${version}/${candidate}/`) || followers.has(p);
68
114
  if (head !== candidate) {
69
115
  // A descendant that only adds NOTES.md and the candidate's evidence (the
70
116
  // evaluate commit) is still a clean view of the candidate's source.
@@ -86,24 +132,48 @@ async function cmdCheck(root, args) {
86
132
  const [checkId, ...command] = o.positional;
87
133
  if (!checkId || !evidence.CHECK_ID.test(checkId)) usage('check requires a check ID such as C-01');
88
134
  if (!o.candidate || !parse.HEX40.test(o.candidate)) usage('check requires --candidate <full 40-hex commit ID>');
89
- if (!command.length) usage('check requires the command after --');
90
- const timeout = o.timeout === undefined ? parse.DEFAULT_TIMEOUT : Number(o.timeout);
91
- if (!Number.isInteger(timeout) || timeout <= 0) usage('--timeout must be a positive integer number of seconds');
92
- const bind = identity.loadBinding(root);
93
- if (bind.code) fail('pincer', `${bind.code}: ${bind.problem}`, EXIT.INVALID);
94
- const b = bind.binding;
135
+ const b = candidateBinding(root, 'check', null);
136
+ // Strict coverage (docs/runtime-contracts.md, "Declared candidate checks"): the map's
137
+ // declaration is the only source of the command, timeout and cwd; a supplied
138
+ // command or timeout cannot become evidence for a declared check by reusing its ID.
139
+ let commands, timeout, cwd = null, declared = null;
140
+ if (b.strict) {
141
+ if (o.timeout !== undefined || command.length) fail('pincer', `CHECK_UNDECLARED: ${checkId} runs its declaration in a strict change; --timeout and a command after -- are not accepted (edit .prd/coverage/${b.change}.json and authorize the agreement instead)`, EXIT.INVALID);
142
+ declared = checks.declaration(root, b, checkId);
143
+ if (declared.code) fail('pincer', `${declared.code}: ${declared.problem}`, exitForCode(declared.code));
144
+ commands = declared.commands; timeout = declared.timeout; cwd = declared.cwd;
145
+ } else {
146
+ if (!command.length) usage('check requires the command after --');
147
+ timeout = o.timeout === undefined ? parse.DEFAULT_TIMEOUT : Number(o.timeout);
148
+ if (!Number.isInteger(timeout) || timeout <= 0) usage('--timeout must be a positive integer number of seconds');
149
+ commands = [command.join(' ')];
150
+ }
95
151
  requireCandidateView(root, o.candidate, b.prd);
96
- const line = command.join(' ');
97
- const secretLine = sanitize.inlineSecretLine([line]);
152
+ const line = commands.join('\n');
153
+ const secretLine = sanitize.inlineSecretLine(commands);
98
154
  if (secretLine) fail('pincer', 'the check command assigns a secret-like literal; reference it from the environment instead', EXIT.INVALID);
99
- process.stdout.write(`── ${checkId} candidate ${o.candidate.slice(0, 7)} ──\n $ ${sanitize.sanitizeText(line).text}\n`);
100
- const context = { kind: 'candidate', change: b.change, prd: b.prd, prd_revision: b.prd_revision, base: b.base, candidate: o.candidate, check: checkId };
101
- const result = await runner.runAttempt({ root, context, commands: [line], timeoutSeconds: timeout, command: `check ${checkId}` });
102
- if (result.code) fail('pincer', `${result.code}: ${result.problem}`, problemExit(result.code));
155
+ const announce = () => io.out(`── ${checkId} candidate ${o.candidate.slice(0, 7)}${declared ? ` (declared in ${declared.file}${cwd ? `, cwd ${cwd}` : ''})` : ''} ──\n${commands.map(c => ` $ ${sanitize.sanitizeText(c).text}`).join('\n')}\n`);
156
+ const contextFor = c => ({ kind: 'candidate', change: c.change, prd: c.prd, prd_revision: c.prd_revision, base: c.base, candidate: o.candidate, check: checkId, ...(c.mode === 'changes' ? { mode: 'changes', agreement: c.agreement } : {}), ...(c.strict ? { inventory: c.inventory, coverage: c.coverage } : {}) });
157
+ // In changes mode the guard runs again under the runner's lock (docs/runtime-contracts.md,
158
+ // "Command gates"); a lifecycle or agreement change committed since the pre-launch
159
+ // evaluation refuses the attempt before anything is recorded.
160
+ // In a strict change the declaration is recomputed under the lock too: a changed
161
+ // declaration whose agreement was re-authorized meanwhile refuses (CHECK_UNDECLARED).
162
+ const revalidate = b.mode === 'changes' ? () => {
163
+ const g = gates.guard(root, { command: 'check', prd: b.prd });
164
+ if (g.binding.strict) {
165
+ const again = checks.declaration(root, g.binding, checkId);
166
+ if (again.code) require('./pincer-runtime/transaction.cjs').refuse(again.code, again.problem);
167
+ if (!declared || again.digest !== declared.digest) require('./pincer-runtime/transaction.cjs').refuse('CHECK_UNDECLARED', `the declaration of ${checkId} changed since the command was prepared (${declared ? declared.digest.slice(0, 12) : 'none'} → ${again.digest.slice(0, 12)}); run the check again`);
168
+ } else if (declared) require('./pincer-runtime/transaction.cjs').refuse('CHECK_UNDECLARED', `change ${b.change} is no longer strict; run the check again`);
169
+ return contextFor(g.binding);
170
+ } : null;
171
+ const result = await runner.runAttempt({ root, context: contextFor(b), commands, timeoutSeconds: timeout, command: `check ${checkId}`, revalidate, announce, cwd });
172
+ if (result.code) fail('pincer', `${result.code}: ${result.problem}`, result.refused ? exitForCode(result.code) : problemExit(result.code));
103
173
  const a = result.attempt;
104
174
  const logs = `${state.RUNTIME_DIR}/attempts/${a.id}/`;
105
- if (a.outcome === 'passed') process.stdout.write(`✓ ${checkId} passed — attempt ${a.id} (source ${a.source.after.slice(0, 12)}, logs ${logs})\n`);
106
- else process.stderr.write(`✗ ${checkId} ${a.outcome}${a.exit_code !== null ? ` (exit ${a.exit_code})` : ''}${a.error ? `: ${a.error}` : ''} — attempt ${a.id} (logs ${logs})\n`);
175
+ if (a.outcome === 'passed') io.out(`✓ ${checkId} passed — attempt ${a.id} (source ${a.source.after.slice(0, 12)}, logs ${logs})\n`);
176
+ else io.err(`✗ ${checkId} ${a.outcome}${a.exit_code !== null ? ` (exit ${a.exit_code})` : ''}${a.error ? `: ${a.error}` : ''} — attempt ${a.id} (logs ${logs})\n`);
107
177
  process.exit(runner.exitFor(a));
108
178
  }
109
179
 
@@ -115,8 +185,8 @@ function cmdEvidence(root, args) {
115
185
  for (const key of ['candidate', 'base']) if (!o[key] || !parse.HEX40.test(o[key])) usage(`evidence export requires --${key} <full 40-hex commit ID>`);
116
186
  if (!o.prd || !parse.PRD_REF.test(o.prd)) usage('evidence export requires --prd .prd/prd-vN.md');
117
187
  if (!o.draft) usage('evidence export requires --draft <file>');
118
- const bind = identity.loadBinding(root, { prd: o.prd });
119
- if (bind.code) fail('pincer', `${bind.code}: ${bind.problem}`, EXIT.INVALID);
188
+ const b = candidateBinding(root, 'export', o.prd);
189
+ const bind = { binding: b };
120
190
  requireCandidateView(root, o.candidate, o.prd);
121
191
  let draft;
122
192
  try { draft = JSON.parse(fs.readFileSync(path.resolve(root, o.draft), 'utf8')); } catch (error) { fail('pincer', `cannot read draft ${o.draft}: ${error.message}`, EXIT.INVALID); }
@@ -124,19 +194,43 @@ function cmdEvidence(root, args) {
124
194
  if (indexRead.error) fail('pincer', indexRead.error, EXIT.INVALID);
125
195
  const attemptsFor = checkId => {
126
196
  if (!indexRead.index) return { attempt: null, pointed: null };
127
- const key = state.contextKey({ kind: 'candidate', candidate: o.candidate, check: checkId });
197
+ const key = state.contextKey({ kind: 'candidate', change: b.change, candidate: o.candidate, check: checkId, mode: b.mode });
128
198
  return { attempt: state.latestAttempt(root, key, indexRead.index), pointed: indexRead.index.current[key] || null };
129
199
  };
130
200
  const os = require('node:os');
201
+ // Strict coverage: export needs structural coverage and derives every row from the
202
+ // map, the inventory and the outcomes (docs/runtime-contracts.md, "Evidence schema 3").
203
+ let strict = null;
204
+ if (b.strict) {
205
+ const phases = require('./pincer-runtime/phases.cjs');
206
+ const coverageModule = require('./pincer-runtime/coverage.cjs');
207
+ const resolved = changes.resolveSelected(root, {});
208
+ if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
209
+ const report = phases.compute(root, resolved.record);
210
+ const blocker = phases.firstBlocker(report, 'structure');
211
+ if (blocker) fail('pincer', `${blocker.code}: evidence export refused: ${blocker.detail} — export needs structural coverage`, exitForCode(blocker.code));
212
+ const cov = coverageModule.load(root, resolved.record, { priorInventory: gid => agreement.inventoryOf(root, resolved.record, resolved.record.agreements.find(g => g.id === gid) || null) });
213
+ const v = authorization.verdict(root, resolved.record);
214
+ strict = { inventory: cov.inventory, map: cov.map.map, mapDigest: cov.map.digest, graph: cov.graph, scope: report.scope, agreement: b.agreement, authorization: v.authorized ? v.authorized.id : null };
215
+ }
131
216
  const result = evidence.exportEvidence(root, {
132
217
  candidate: o.candidate, base: o.base, prd: o.prd, draft, binding: bind.binding, attemptsFor,
133
- environment: { os: `${os.platform()} ${os.release()}`, node: process.version }, now: nowIso(), atomicWrite,
218
+ environment: { os: `${os.platform()} ${os.release()}`, node: process.version }, now: nowIso(), atomicWrite, strict,
134
219
  });
135
220
  if (result.problems.length) {
136
- for (const p of result.problems) process.stderr.write(`evidence: ${result.manifest || o.draft}: ${p}\n`);
221
+ for (const p of result.problems) io.err(`evidence: ${result.manifest || o.draft}: ${p}\n`);
137
222
  process.exit(EXIT.FAILED);
138
223
  }
139
- process.stdout.write(`exported ${result.manifest} (schema 2) — validate: node scripts/pincer-evidence.cjs validate ${result.manifest} --candidate ${o.candidate} --prd ${o.prd}\n`);
224
+ io.out(`exported ${result.manifest} (schema ${result.schema})${result.delivery ? ` — delivery: original ${result.delivery.original}, agreed ${result.delivery.agreed}` : ''} — validate: node scripts/pincer-evidence.cjs validate ${result.manifest} --candidate ${o.candidate} --prd ${o.prd}\n`);
225
+ for (const l of result.limitations || []) io.err(`pincer: limitation: ${l}\n`);
226
+ if (b.mode === 'changes') {
227
+ // The per-change evaluation locator is the identity of this evaluation;
228
+ // root NOTES.md stays the human summary (docs/runtime-contracts.md).
229
+ const entry = { candidate: o.candidate, base: o.base, prd: o.prd, prd_revision: b.prd_revision, agreement: b.agreement, manifest: result.manifest, recorded: nowIso() };
230
+ const appended = locator.append(root, b.change, entry);
231
+ if (appended.code) fail('pincer', `${appended.code}: the manifest was written but the evaluation locator could not be updated: ${appended.problem}`, exitForCode(appended.code));
232
+ io.out(`${appended.action === 'recorded' ? 'recorded' : 'already recorded'} evaluation of change ${b.change} in ${locator.file(b.change)} (candidate ${o.candidate.slice(0, 7)}); commit it with the evidence\n`);
233
+ }
140
234
  process.exit(EXIT.OK);
141
235
  }
142
236
 
@@ -148,21 +242,22 @@ function cmdMigrate(root, args) {
148
242
  const options = { prd: o.prd, change: o.change, authorization: o.authorization ?? null };
149
243
  if (o.preview) {
150
244
  const p = migrate.plan(root, options);
151
- process.stdout.write(migrate.renderPlan(p));
245
+ io.out(migrate.renderPlan(p));
152
246
  process.exit(p.conflicts.length ? EXIT.FAILED : EXIT.OK);
153
247
  }
154
248
  const result = migrate.apply(root, options);
155
- if (result.plan.conflicts.length) { process.stdout.write(migrate.renderPlan(result.plan)); process.exit(EXIT.FAILED); }
156
- if (result.already) { process.stdout.write(`already migrated: ${o.prd} is bound as change ${result.plan.change}; nothing changed\n`); process.exit(EXIT.OK); }
157
- if (result.error) { process.stderr.write(`pincer: migration stopped before the binding was written: ${result.error}\n`); process.exit(EXIT.INVALID); }
158
- const b = result.binding;
159
- process.stdout.write(`migrated ${o.prd} → change ${b.change} (revision ${b.prd_revision.slice(0, 12)}, base ${b.base.slice(0, 7)}, ${Object.keys(result.plan.tickets.length ? b.legacy_receipts : {}).length || result.plan.tickets.length} ticket(s) rewritten)\n`);
160
- if (result.backupDir) process.stdout.write(`backups: ${result.backupDir} (${result.backups.length} file(s)); rollback per docs/runtime-contracts.md\n`);
161
- if (!o.authorization) process.stderr.write('pincer: note: no --authorization recorded; migration does not prove human approval\n');
249
+ if (result.plan.conflicts.length) { io.out(migrate.renderPlan(result.plan)); process.exit(EXIT.FAILED); }
250
+ if (result.already) { io.out(`already migrated: ${o.prd} is change ${result.plan.change} (schema 2 record); nothing changed\n`); process.exit(EXIT.OK); }
251
+ if (result.error) { io.err(`pincer: ${result.code}: migration refused; nothing was written: ${result.error}\n`); process.exit(exitForCode(result.code)); }
252
+ const r = result.record, p = result.plan;
253
+ io.out(`migrated ${o.prd} → change ${r.change} (schema 2 record, ${r.lifecycle.state}${p.source === 'binding' ? ', converted from the v0.5.0 binding' : p.source === 'record' ? ', receipts imported' : ''}; base ${r.base.slice(0, 7)}; ${p.tickets.length} ticket(s) rewritten; ${Object.keys(r.legacy.receipts).length} legacy receipt(s) as history${p.index ? `; ${p.index.stale.length} candidate pointer(s) dropped` : ''})\n`);
254
+ if (result.backupDir) io.out(`backups: ${result.backupDir} (${result.backups.length} file(s)); rollback per docs/runtime-contracts.md\n`);
255
+ if (p.selection) io.out(`selected change ${r.change} in this worktree (${changes.SELECTION_FILE})\n`);
256
+ io.err(`pincer: note: the change is planned with no authorization${r.legacy.authorization_text ? ' (the v0.5.0 authorization text is history only)' : ''}; existing attempts and evaluations are history until verified again — next: node scripts/pincer-runtime.cjs change authorize ${r.change} --agreement <digest> --reference <text> --excerpt <text>, then change activate ${r.change}\n`);
162
257
  process.exit(EXIT.OK);
163
258
  }
164
259
 
165
- const fail = (prefix, message, code = EXIT.INVALID) => { process.stderr.write(`${prefix}: ${message}\n`); process.exit(code); };
260
+ const fail = (prefix, message, code = EXIT.INVALID) => { io.err(`${prefix}: ${message}\n`); process.exit(code); };
166
261
 
167
262
  // start | verify | done | bind — both modes; refusals carry their prefix and exit code.
168
263
  async function cmdLifecycle(root, command, args) {
@@ -175,7 +270,7 @@ async function cmdLifecycle(root, command, args) {
175
270
  : command === 'start' ? lifecycle.start(root, o.positional[0])
176
271
  : command === 'verify' ? await lifecycle.verify(root, o.positional[0])
177
272
  : await lifecycle.done(root, o.positional[0]);
178
- if (result.out) process.stdout.write(result.out);
273
+ if (result.out) io.out(result.out);
179
274
  process.exit(result.exit ?? EXIT.OK);
180
275
  } catch (error) {
181
276
  if (error instanceof lifecycle.Refusal) fail(error.prefix, error.message, error.exit);
@@ -184,32 +279,38 @@ async function cmdLifecycle(root, command, args) {
184
279
  }
185
280
 
186
281
  function cmdStatus(root, args) {
187
- const o = parseOptions(args, { switches: ['--json'] });
282
+ const o = parseOptions(args, { switches: ['--json'], valued: ['--change'] });
188
283
  if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
189
284
  const budget = process.env.PINCER_BUILD_BUDGET_MIN || '';
190
- const result = status.render(root, { budget });
191
- if (o.json) process.stdout.write(`${JSON.stringify(result.json, null, 2)}\n`);
192
- else process.stdout.write(result.text);
285
+ const result = status.render(root, { budget, change: o.change || null });
286
+ if (o.json) io.out(`${JSON.stringify(result.json, null, 2)}\n`);
287
+ else io.out(result.text);
193
288
  process.exit(result.exit);
194
289
  }
195
290
 
196
291
  // Read-only readiness gate: a ticket, or the candidate when no ticket is named.
197
292
  function cmdReady(root, args) {
198
- const o = parseOptions(args, {});
293
+ const o = parseOptions(args, { valued: ['--change'] });
199
294
  if (o.positional.length > 1) usage('ready takes at most one ticket ID');
200
- const result = status.render(root, {});
201
- if (result.exit !== 0) { process.stderr.write(result.text); process.exit(result.exit); }
295
+ const result = status.render(root, { change: o.change || null });
296
+ if (result.exit !== 0) { io.err(result.text); process.exit(result.exit); }
202
297
  const j = result.json;
298
+ // Changes mode: the selection, lifecycle, view and authorization gates block
299
+ // read-only readiness too (the same codes the execution guard would refuse with).
300
+ const changeBlockers = j.mode === 'changes' ? j.reasons.filter(r => gates.ORDER.includes(r.code)) : [];
301
+ if (j.mode === 'changes' && j.change && j.change.lifecycle.state !== 'active' && o.positional.length === 1) changeBlockers.push({ code: 'LIFECYCLE_BLOCKED', detail: `change ${j.change.id} is ${j.change.lifecycle.state}; ticket execution runs on an active change` });
203
302
  if (o.positional.length === 1) {
204
303
  const id = parse.normalizeId(o.positional[0]);
205
304
  const ticket = id && j.tickets.find(t => t.id === id);
206
- if (!ticket) { process.stderr.write(`pincer: no ticket ${o.positional[0]} is associated with the selected PRD\n`); process.exit(EXIT.INVALID); }
207
- if (ticket.readiness.ready) { process.stdout.write(`ready ${id}\n`); process.exit(EXIT.OK); }
208
- for (const r of ticket.readiness.reasons) process.stdout.write(`not ready ${id}: ${r.code} ${r.detail}\n`);
209
- process.stdout.write(`next: ${ticket.readiness.next}\n`);
305
+ if (!ticket) { io.err(`pincer: no ticket ${o.positional[0]} is associated with the selected PRD${j.mode === 'changes' && !j.change ? ` (${j.selection.problem ? j.selection.problem.detail : 'no change selected'})` : ''}\n`); process.exit(EXIT.INVALID); }
306
+ if (ticket.readiness.ready && !changeBlockers.length) { io.out(`ready ${id}\n`); process.exit(EXIT.OK); }
307
+ for (const r of changeBlockers) io.out(`not ready ${id}: ${r.code} ${r.detail}\n`);
308
+ for (const r of ticket.readiness.reasons) io.out(`not ready ${id}: ${r.code} ${r.detail}\n`);
309
+ io.out(`next: ${changeBlockers.length ? `${changeBlockers[0].code}: ${changeBlockers[0].detail}` : ticket.readiness.next}\n`);
210
310
  process.exit(EXIT.FAILED);
211
311
  }
212
- const blockers = [];
312
+ const blockers = [...changeBlockers];
313
+ if (j.mode === 'changes' && j.change && j.change.lifecycle.state !== 'completed') blockers.push({ code: 'LIFECYCLE_BLOCKED', detail: `change ${j.change.id} is ${j.change.lifecycle.state}, not completed; release audits completed changes only` });
213
314
  const localUnavailable = j.candidate && j.candidate.local_attempts === 'unavailable';
214
315
  for (const t of j.tickets) {
215
316
  if (t.status !== 'done') blockers.push({ code: 'EVIDENCE_MISSING', detail: `${t.id} is ${t.status}, not done` });
@@ -222,17 +323,24 @@ function cmdReady(root, args) {
222
323
  if (!j.prd) blockers.push({ code: 'INPUT_INVALID', detail: 'no PRD' });
223
324
  else if (j.prd.status !== 'built') blockers.push({ code: 'CANDIDATE_STALE', detail: `PRD status is '${j.prd.status}', expected 'built'` });
224
325
  if (j.candidate) blockers.push(...j.candidate.reasons);
225
- if (!blockers.length) { process.stdout.write(`ready candidate ${j.candidate.candidate}\n`); process.exit(EXIT.OK); }
226
- for (const b of blockers) process.stdout.write(`not ready: ${b.code} ${b.detail}\n`);
227
- process.stdout.write(`next: ${j.next}\n`);
326
+ if (j.mode === 'changes' && !j.change) blockers.push({ code: 'SELECTION_REQUIRED', detail: 'no change is selected' });
327
+ // Strict coverage (docs/runtime-contracts.md, "Phase-specific coverage"): release needs
328
+ // structural coverage and every in-scope scenario delivered by the reconciled evidence.
329
+ if (j.mode === 'changes' && j.change && result.gathered && result.gathered.record && changes.isStrict(result.gathered.record)) {
330
+ const report = require('./pincer-runtime/phases.cjs').compute(root, result.gathered.record, { gathered: result.gathered });
331
+ for (const p of [...report.structure.problems, ...report.candidate.problems]) if (!blockers.some(b => b.code === p.code && b.detail === p.detail) && !(p.code === 'CANDIDATE_STALE' && blockers.some(b => b.code === 'CANDIDATE_STALE'))) blockers.push({ code: p.code, detail: p.detail });
332
+ }
333
+ if (!blockers.length) { io.out(`ready candidate ${j.candidate.candidate}\n`); process.exit(EXIT.OK); }
334
+ for (const b of blockers) io.out(`not ready: ${b.code} ${b.detail}\n`);
335
+ io.out(`next: ${j.next}\n`);
228
336
  process.exit(EXIT.FAILED);
229
337
  }
230
338
 
231
339
  // Run a state operation, mapping the contracted failures to exit codes.
232
340
  function guarded(fn) {
233
341
  try { return fn(); } catch (error) {
234
- if (error && error.code === 'STATE_BUSY') { process.stderr.write(`pincer: STATE_BUSY: ${error.message}\n`); process.exit(EXIT.BUSY); }
235
- if (error && error.code === 'INVALID') { process.stderr.write(`pincer: ${error.message}\n`); process.exit(EXIT.INVALID); }
342
+ if (error && error.code === 'STATE_BUSY') { io.err(`pincer: STATE_BUSY: ${error.message}\n`); process.exit(EXIT.BUSY); }
343
+ if (error && error.code === 'INVALID') { io.err(`pincer: ${error.message}\n`); process.exit(EXIT.INVALID); }
236
344
  throw error;
237
345
  }
238
346
  }
@@ -240,24 +348,33 @@ function guarded(fn) {
240
348
  function cmdRecover(root, args) {
241
349
  const o = parseOptions(args, {});
242
350
  if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
243
- if (!state.exists(root)) { process.stdout.write('nothing to recover: no local runtime state\n'); process.exit(EXIT.OK); }
351
+ if (!state.exists(root)) { io.out('nothing to recover: no local runtime state\n'); process.exit(EXIT.OK); }
244
352
  const report = guarded(() => state.recover(root));
245
- for (const id of report.finalized) process.stdout.write(`finalized ${id} as interrupted (owner no longer running)\n`);
246
- for (const { id, owner } of report.live) process.stdout.write(`still running ${id} (pid ${owner.pid} is alive)\n`);
247
- for (const { id, owner } of report.foreign) process.stdout.write(`still running ${id} (owned by ${owner.host}; not reclaimed from another host)\n`);
248
- for (const id of report.missing) process.stdout.write(`dropped ${id} from running: record missing\n`);
249
- for (const file of report.journal) process.stdout.write(`removed stray journal file ${file}\n`);
250
- if (!Object.values(report).some(list => list.length)) process.stdout.write('nothing to recover\n');
353
+ for (const t of report.transactions.completed) io.out(`completed transaction ${t.id} (${t.command}): ${t.targets.join(', ')}\n`);
354
+ for (const t of report.transactions.discarded) io.out(`discarded uncommitted staging ${t.id}\n`);
355
+ for (const t of report.transactions.unreadable) io.out(`left transaction ${t.id} in place: ${t.problem} (inspect ${state.RUNTIME_DIR}/journal/${t.id}/manifest.json by hand)\n`);
356
+ for (const id of report.finalized) io.out(`finalized ${id} as interrupted (owner no longer running)\n`);
357
+ for (const { id, owner } of report.live) io.out(`still running ${id} (pid ${owner.pid} is alive)\n`);
358
+ for (const { id, owner } of report.foreign) io.out(`still running ${id} (owned by ${owner.host}; not reclaimed from another host)\n`);
359
+ for (const id of report.missing) io.out(`dropped ${id} from running: record missing\n`);
360
+ for (const file of report.journal) io.out(`removed stray journal file ${file}\n`);
361
+ const { transactions, ...lists } = report;
362
+ if (!Object.values(lists).some(list => list.length) && !Object.values(transactions).some(list => list.length)) io.out('nothing to recover\n');
251
363
  process.exit(EXIT.OK);
252
364
  }
253
365
 
254
366
  // `--flag value` and `--switch` options; positional arguments keep their order.
255
- function parseOptions(args, { valued = [], switches = [] } = {}) {
367
+ function parseOptions(args, { valued = [], switches = [], repeated = [] } = {}) {
256
368
  const options = { positional: [] };
369
+ for (const arg of repeated) options[arg.slice(2)] = [];
257
370
  for (let i = 0; i < args.length; i++) {
258
371
  const arg = args[i];
259
372
  if (arg === '--') { options.positional.push(...args.slice(i + 1)); break; }
260
- if (valued.includes(arg)) {
373
+ if (repeated.includes(arg)) {
374
+ const value = args[++i];
375
+ if (value === undefined) usage(`${arg} requires a value`);
376
+ options[arg.slice(2)].push(value);
377
+ } else if (valued.includes(arg)) {
261
378
  const value = args[++i];
262
379
  if (value === undefined) usage(`${arg} requires a value`);
263
380
  options[arg.slice(2)] = value;
@@ -268,30 +385,245 @@ function parseOptions(args, { valued = [], switches = [] } = {}) {
268
385
  return options;
269
386
  }
270
387
  const problemExit = code => (code === 'STATE_BUSY' ? EXIT.BUSY : EXIT.INVALID);
388
+ // Contracted exit codes for the change commands: 3 busy, 4 invalid input or
389
+ // unreadable state, 1 for every refusal (docs/runtime-contracts.md, "Exit codes").
390
+ const INVALID_CODES = ['INPUT_INVALID', 'INVENTORY_INVALID', 'COVERAGE_INVALID', 'CHECK_UNDECLARED', 'MALFORMED', 'UNSUPPORTED_SCHEMA', 'HISTORY_INVALID', 'STATE_INCOMPLETE', 'UNSUPPORTED_INPUT', 'CHANGES_MODE', 'AMBIGUOUS', 'INVALID'];
391
+ const exitForCode = code => (code === 'STATE_BUSY' ? EXIT.BUSY : INVALID_CODES.includes(code) ? EXIT.INVALID : EXIT.FAILED);
271
392
 
272
393
  function cmdRegister(root, args) {
273
394
  const o = parseOptions(args, { valued: ['--prd', '--change', '--authorization'], switches: ['--replace', '--rebind'] });
274
395
  if (!o.prd) usage('register requires --prd .prd/prd-vN.md');
275
396
  if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
276
- const result = identity.register(root, { prd: o.prd, change: o.change, authorization: o.authorization ?? null, replace: Boolean(o.replace), rebind: Boolean(o.rebind) });
277
- if (result.code) { process.stderr.write(`pincer: ${result.problem}\n`); process.exit(problemExit(result.code)); }
278
- for (const note of result.notes) process.stderr.write(`pincer: note: ${note}\n`);
279
- const b = result.binding;
280
- process.stdout.write(`${result.action} change ${b.change} → ${b.prd} revision ${b.prd_revision.slice(0, 12)} base ${b.base.slice(0, 7)} (${result.file})\n`);
397
+ const mode = changes.scan(root).mode;
398
+ if (mode === 'migrated') {
399
+ // v0.5.0 binding: the released semantics, except that a second PRD or
400
+ // --replace now needs the migration to change records.
401
+ const result = identity.register(root, { prd: o.prd, change: o.change, authorization: o.authorization ?? null, replace: Boolean(o.replace), rebind: Boolean(o.rebind) });
402
+ if (result.code) { io.err(`pincer: ${result.code === 'MIGRATION_REQUIRED' ? 'MIGRATION_REQUIRED: ' : ''}${result.problem}\n`); process.exit(result.code === 'MIGRATION_REQUIRED' ? EXIT.FAILED : problemExit(result.code)); }
403
+ for (const note of result.notes) io.err(`pincer: note: ${note}\n`);
404
+ const b = result.binding;
405
+ io.out(`${result.action} change ${b.change} → ${b.prd} revision ${b.prd_revision.slice(0, 12)} base ${b.base.slice(0, 7)} (${result.file})\n`);
406
+ process.exit(EXIT.OK);
407
+ }
408
+ // Legacy or changes mode: a schema 2 record. The v0.5.0 flags are refused
409
+ // with the command that replaces them; nothing is inferred from them.
410
+ if (o.replace) fail('pincer', 'LIFECYCLE_BLOCKED: --replace is not supported for change records (they are retained); work on another change with `change select <id>`, retire one with `change supersede <id> --with <replacement> --decision D-NN` or `change cancel <id> --decision D-NN --reason <text>`', EXIT.FAILED);
411
+ if (o.rebind) fail('pincer', 'AGREEMENT_CHANGED: --rebind is not supported for change records; record the revised agreement with `change revise <id>` and its disposition with `change authorize`', EXIT.FAILED);
412
+ if (o.authorization !== undefined) fail('pincer', 'AUTHORIZATION_REQUIRED: --authorization is not recorded on change records (free text cannot become approval); record the user\'s instruction with `change authorize <id> --agreement <digest> --reference <text> --excerpt <text>` after registration', EXIT.FAILED);
413
+ const result = changes.register(root, { prd: o.prd, change: o.change });
414
+ if (result.code) { io.err(`pincer: ${result.code}: ${result.problem}\n`); process.exit(exitForCode(result.code)); }
415
+ const r = result.record;
416
+ io.out(`${result.action} change ${r.change} → ${r.prd} base ${r.base.slice(0, 7)} · ${r.lifecycle.state} (${result.file})\n`);
417
+ if (result.action === 'registered') io.err('pincer: note: registration grants no authorization; record the user\'s instruction with `change authorize` and select the change with `change select`\n');
281
418
  process.exit(EXIT.OK);
282
419
  }
283
420
 
421
+ // coverage adopt --preview|--apply --change <id> [--agreement <digest>] — the explicit
422
+ // entry into strict coverage (docs/runtime-contracts.md, "Adoption and rollback").
423
+ function cmdCoverage(root, args) {
424
+ const [sub, ...rest] = args;
425
+ // coverage scaffold --change <id> [--json] — the read-only coverage draft
426
+ // (docs/runtime-contracts.md, "Coverage draft"). Writes nothing and launches nothing;
427
+ // the draft it prints is not a map and `coverage adopt` does not accept it.
428
+ if (sub === 'scaffold') {
429
+ const o = parseOptions(rest, { switches: ['--json'], valued: ['--change'] });
430
+ if (o.positional.length) usage(`unexpected argument ${o.positional[0]} (coverage scaffold takes --change <id> [--json])`);
431
+ if (!o.change) usage('coverage scaffold requires --change <id>');
432
+ const scaffold = require('./pincer-runtime/scaffold.cjs');
433
+ const resolved = changes.resolveSelected(root, { change: o.change });
434
+ if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
435
+ const result = scaffold.build(root, resolved.record);
436
+ if (!result.ok) fail('pincer', `${result.code}: ${result.problems[0]}`, exitForCode(result.code));
437
+ if (o.json) io.out(`${JSON.stringify(result.draft, null, 2)}\n`);
438
+ else io.out(scaffold.render(result.draft));
439
+ process.exit(EXIT.OK);
440
+ }
441
+ if (sub !== 'adopt') {
442
+ // The read-only coverage report (docs/runtime-contracts.md, "Coverage and impact commands").
443
+ const o = parseOptions(args, { switches: ['--json'], valued: ['--change'] });
444
+ if (o.positional.length) usage(`unexpected argument ${o.positional[0]} (coverage takes [--change <id>] [--json], or the adopt subcommand)`);
445
+ const phases = require('./pincer-runtime/phases.cjs');
446
+ const resolved = changes.resolveSelected(root, { change: o.change || null });
447
+ if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
448
+ const st = status.render(root, { change: o.change || null });
449
+ if (st.exit === 4) { io.err(st.text); process.exit(EXIT.INVALID); }
450
+ // A report asked for with --change may describe a change this worktree has not
451
+ // selected; no execution command it prints could run as written.
452
+ const selected = !resolved.selection || resolved.selection.change === resolved.record.change;
453
+ const result = phases.report(root, resolved.record, { gathered: st.gathered, generated: st.json.generated, selected });
454
+ if (o.json) io.out(`${JSON.stringify(result, null, 2)}\n`);
455
+ else io.out(phases.render(result));
456
+ process.exit(EXIT.OK);
457
+ }
458
+ const o = parseOptions(rest, { valued: ['--change', '--agreement'], switches: ['--preview', '--apply'] });
459
+ if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
460
+ if (!o.change) usage('coverage adopt requires --change <id>');
461
+ if (Boolean(o.preview) === Boolean(o.apply)) usage('coverage adopt requires exactly one of --preview or --apply');
462
+ if (o.agreement !== undefined && !/^[0-9a-f]{64}$/.test(o.agreement)) usage('--agreement must be the 64-hex agreement digest the preview printed');
463
+ if (o.preview) {
464
+ const p = adopt.plan(root, { change: o.change });
465
+ io.out(adopt.renderPlan(p));
466
+ process.exit(p.conflicts.length ? EXIT.FAILED : EXIT.OK);
467
+ }
468
+ const result = adopt.apply(root, { change: o.change, agreement: o.agreement ?? null });
469
+ if (result.plan.conflicts.length) { io.out(adopt.renderPlan(result.plan)); process.exit(EXIT.FAILED); }
470
+ if (result.already) { io.out(`already adopted: change ${o.change} is strict since ${result.plan.record.coverage.adopted} (agreement ${result.plan.record.coverage.agreement}); nothing changed\n`); process.exit(EXIT.OK); }
471
+ if (result.error) { io.err(`pincer: ${result.code}: adoption refused; nothing was written: ${result.error}\n`); process.exit(exitForCode(result.code)); }
472
+ const r = result.record, p = result.plan;
473
+ io.out(`adopted strict coverage for change ${r.change} (schema 3 record; agreement ${p.agreement.id} ${p.agreement.digest.slice(0, 12)}; inventory ${p.inventory.requirements} requirement(s), ${p.inventory.scenarios} scenario(s); map ${p.map.digest.slice(0, 12)}; ${p.historical} earlier attempt(s) are history)\n`);
474
+ io.out(`backup: ${result.backup} (rollback per docs/runtime-contracts.md, "Adoption and rollback")\n`);
475
+ io.err(`pincer: note: adoption grants no authorization; record the user's instruction covering the strict agreement with: node scripts/pincer-runtime.cjs change authorize ${r.change} --agreement ${p.agreement.digest} --reference <text> --excerpt <text> (or --delegated --basis A-NN --explanation <text>)\n`);
476
+ process.exit(EXIT.OK);
477
+ }
478
+
479
+ // impact [--change <id>] [--from G-NN|A-NN] [--json] — read-only structural differences
480
+ // against a retained agreement (docs/runtime-contracts.md, "Coverage and impact commands").
481
+ function cmdImpact(root, args) {
482
+ const o = parseOptions(args, { switches: ['--json'], valued: ['--change', '--from'] });
483
+ if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
484
+ const resolved = changes.resolveSelected(root, { change: o.change || null });
485
+ if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
486
+ const result = impact.compute(root, resolved.record, { from: o.from || null });
487
+ if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
488
+ if (o.json) io.out(`${JSON.stringify(result, null, 2)}\n`);
489
+ else io.out(impact.render(result));
490
+ process.exit(EXIT.OK);
491
+ }
492
+
493
+ // resume [--change <id>] [--json] — the read-only resume report (never the lifecycle operation).
494
+ function cmdResume(root, args) {
495
+ const o = parseOptions(args, { switches: ['--json', '--brief'], valued: ['--change'] });
496
+ if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
497
+ const result = resume.build(root, { change: o.change || null });
498
+ // --brief is a projection of the same computed report: same verdict, same next
499
+ // action, same blocker categories, fewer bytes (docs/runtime-contracts.md, "Brief
500
+ // resume"). It never recomputes a decision and never changes the exit code.
501
+ if (o.brief) {
502
+ const brief = resume.brief(result.json);
503
+ if (o.json) io.out(`${JSON.stringify(brief, null, 2)}\n`);
504
+ else io.out(resume.renderBrief(brief));
505
+ process.exit(result.exit);
506
+ }
507
+ if (o.json) io.out(`${JSON.stringify(result.json, null, 2)}\n`);
508
+ else io.out(result.text);
509
+ process.exit(result.exit);
510
+ }
511
+
512
+ // The current agreement of a record against its recorded entries (read-only).
513
+ function agreementNow(root, record) {
514
+ const now = agreement.compute(root, record);
515
+ if (now.code) return now;
516
+ const entry = agreement.entryFor(record, now.digest);
517
+ const latest = agreement.latestEntry(record);
518
+ let difference = null, rendered = null;
519
+ if (!entry && latest) {
520
+ const snap = agreement.readSnapshot(root, record, latest);
521
+ if (!snap.code) { difference = agreement.difference(snap.snapshot, now); rendered = agreement.renderDifference(difference); }
522
+ }
523
+ return { digest: now.digest, entry, latest, difference, rendered };
524
+ }
525
+
526
+ // change list | show | select | revise — inspection and local/authored records.
527
+ function cmdChange(root, args) {
528
+ const [sub, ...rest] = args;
529
+ if (sub === 'list') {
530
+ const o = parseOptions(rest, { switches: ['--json'] });
531
+ if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
532
+ const result = changes.list(root);
533
+ const loaded = changes.loadRecords(root);
534
+ for (const c of result.changes) { const e = loaded.records.get(c.id); if (e) { const v = authorization.verdict(root, e.record); c.authorization = v.verdict; c.agreement = v.current; } }
535
+ const sel = changes.readSelection(root);
536
+ const selection = sel.code ? { change: null, problem: { code: sel.code, detail: sel.problem } } : { change: sel.change, problem: null };
537
+ result.changes.forEach(c => { c.selected = Boolean(sel.change) && sel.change === c.id; });
538
+ if (o.json) io.out(`${JSON.stringify({ schema: 1, runtime: changes.RUNTIME, generated: nowIso(), root, mode: result.mode, selection, changes: result.changes, problems: result.problems }, null, 2)}\n`);
539
+ else io.out(changes.renderList(result, { selection: sel.code ? null : sel }));
540
+ process.exit(result.problems.length ? EXIT.INVALID : EXIT.OK);
541
+ }
542
+ if (sub === 'select') {
543
+ const o = parseOptions(rest, {});
544
+ if (o.positional.length !== 1) usage('change select requires exactly one change ID');
545
+ const result = changes.select(root, o.positional[0]);
546
+ if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
547
+ io.out(`${result.action === 'unchanged' ? 'already selected' : 'selected'} change ${o.positional[0]} → ${result.record.prd} · ${result.record.lifecycle.state} (${changes.SELECTION_FILE}, local to this worktree${result.previous ? `; previously ${result.previous}` : ''})\n`);
548
+ if (result.action === 'selected') io.err('pincer: note: selection is metadata only — no checkout, stash, reset or commit was made, and selecting grants no authorization\n');
549
+ process.exit(EXIT.OK);
550
+ }
551
+ if (sub === 'show') {
552
+ const o = parseOptions(rest, { switches: ['--json'] });
553
+ if (o.positional.length !== 1) usage('change show requires exactly one change ID');
554
+ const result = changes.loadRecord(root, o.positional[0]);
555
+ if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
556
+ const sel = changes.readSelection(root);
557
+ const now = agreementNow(root, result.record);
558
+ const v = authorization.verdict(root, result.record);
559
+ const loc = locator.read(root, o.positional[0]);
560
+ const evaluations = loc.code ? [] : loc.locator.evaluations;
561
+ if (o.json) io.out(`${JSON.stringify({ schema: 1, runtime: changes.RUNTIME, generated: nowIso(), root, file: result.file, selected: !sel.code && sel.change === o.positional[0], record: result.record, agreement: now.code ? { current: null, problem: { code: now.code, detail: now.problem } } : { current: now.digest, recorded: now.entry ? now.entry.id : null, latest: now.latest ? now.latest.id : null, difference: now.difference }, authorization: { verdict: v.verdict, detail: v.detail, authorized: v.authorized ? v.authorized.id : null, open_decisions: v.open }, evaluations, locator: loc.code ? { code: loc.code, detail: loc.problem } : null }, null, 2)}\n`);
562
+ else io.out(changes.renderShow(o.positional[0], result, { agreement: now, verdict: v, evaluations: loc.code ? [] : evaluations, locatorProblem: loc.code ? loc.problem : null }));
563
+ process.exit(EXIT.OK);
564
+ }
565
+ if (transitions.OPS.includes(sub)) {
566
+ const o = parseOptions(rest, { valued: ['--reason', '--note', '--decision', '--with'] });
567
+ if (o.positional.length !== 1) usage(`change ${sub} requires exactly one change ID`);
568
+ const id = o.positional[0];
569
+ const result = transitions.transition(root, id, sub, { reason: o.reason, note: o.note, decision: o.decision, with: o.with });
570
+ if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
571
+ if (result.action === 'unchanged') { io.out(`change ${id} is already ${result.to}${result.record.lifecycle.superseded_by ? ` by ${result.record.lifecycle.superseded_by}` : ''}; nothing written\n`); process.exit(EXIT.OK); }
572
+ const e = result.event;
573
+ const past = { activate: 'activated', pause: 'paused', resume: 'resumed', complete: 'completed', reopen: 'reopened', cancel: 'cancelled', supersede: 'superseded' }[sub];
574
+ io.out(`${past} change ${id}: ${result.from} → ${result.to} (event ${e.sequence}${e.authorization ? `, authorization ${e.authorization}` : ''}${e.decision ? `, decision ${e.decision}` : ''}${e.replacement ? `, replaced by ${e.replacement}` : ''}${e.reason ? `; reason: ${e.reason}` : ''})\n`);
575
+ if (sub === 'complete') io.err('pincer: note: completed means implementation complete and ready for evaluation, not evaluated or released; commit the record before choosing the candidate\n');
576
+ if (sub === 'pause' && e.note) io.err('pincer: note: the handoff note is authored text; status and resume show it but never derive readiness from it\n');
577
+ process.exit(EXIT.OK);
578
+ }
579
+ if (sub === 'authorize') {
580
+ const o = parseOptions(rest, { valued: ['--agreement', '--reference', '--excerpt', '--constraints', '--basis', '--explanation'], switches: ['--delegated'], repeated: ['--decision'] });
581
+ if (o.positional.length !== 1) usage('change authorize requires exactly one change ID');
582
+ if (!o.agreement) usage('change authorize requires --agreement <digest>');
583
+ const result = authorization.authorize(root, o.positional[0], { agreement: o.agreement, delegated: Boolean(o.delegated), reference: o.reference, excerpt: o.excerpt, constraints: o.constraints, basis: o.basis, explanation: o.explanation, decisions: o.decision });
584
+ if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
585
+ const a = result.authorization;
586
+ if (result.action === 'unchanged') io.out(`unchanged: ${a.id} (${a.disposition}) already records this authorization of agreement ${a.agreement} ${a.digest.slice(0, 12)} for ${o.positional[0]}; nothing written\n`);
587
+ else io.out(`recorded authorization ${a.id} (${a.disposition}${a.basis ? `, basis ${a.basis}` : ''}) of agreement ${a.agreement} ${a.digest.slice(0, 12)} for ${o.positional[0]}${a.decisions.length ? ` · decisions ${a.decisions.join(', ')}` : ''}\n`);
588
+ if (result.action === 'recorded') io.err(`pincer: note: this records local provenance of ${a.disposition === 'user' ? "the user's instruction" : 'a delegation judgment'}, not authenticated identity; execution still needs the change selected and active\n`);
589
+ process.exit(EXIT.OK);
590
+ }
591
+ if (sub === 'decide') {
592
+ const o = parseOptions(rest, { valued: ['--summary', '--id', '--resolve', '--reference', '--excerpt'] });
593
+ if (o.positional.length !== 1) usage('change decide requires exactly one change ID');
594
+ if (!o.resolve && !o.summary) usage('change decide requires --summary <text> (raise) or --resolve D-NN --reference <text> --excerpt <text>');
595
+ const result = authorization.decide(root, o.positional[0], { summary: o.summary, id: o.id, resolve: o.resolve, reference: o.reference, excerpt: o.excerpt });
596
+ if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
597
+ const d = result.decision;
598
+ if (result.action === 'unchanged') io.out(`unchanged: ${d.id} is already ${d.status} (${d.summary}); nothing written\n`);
599
+ else if (result.action === 'raised') { io.out(`raised decision ${d.id} on ${o.positional[0]}: ${d.summary}\n`); io.err(`pincer: note: execution of ${o.positional[0]} is blocked (DECISION_REQUIRED) until the user's decision is recorded with: node scripts/pincer-runtime.cjs change decide ${o.positional[0]} --resolve ${d.id} --reference <text> --excerpt <text>\n`); }
600
+ else { io.out(`resolved decision ${d.id} on ${o.positional[0]}: "${d.excerpt}" (${d.reference})\n`); io.err(`pincer: note: the agreement is now ${result.agreement ? result.agreement.slice(0, 12) : 'unavailable'} and needs authorization: node scripts/pincer-runtime.cjs change authorize ${o.positional[0]} --agreement ${result.agreement || '<digest>'} --decision ${d.id} …\n`); }
601
+ process.exit(EXIT.OK);
602
+ }
603
+ if (sub === 'revise') {
604
+ const o = parseOptions(rest, {});
605
+ if (o.positional.length !== 1) usage('change revise requires exactly one change ID');
606
+ const result = agreement.revise(root, o.positional[0]);
607
+ if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
608
+ if (result.action === 'unchanged') io.out(`unchanged: the current agreement of ${o.positional[0]} is ${result.agreement.id} ${result.agreement.digest.slice(0, 12)} (recorded ${result.agreement.recorded}); nothing written\n`);
609
+ else io.out(`recorded agreement ${result.agreement.id} ${result.agreement.digest.slice(0, 12)} for ${o.positional[0]} (${result.agreement.tickets.length} ticket(s), snapshot ${result.agreement.snapshot})${result.difference ? ` — differs from the previous agreement: ${agreement.renderDifference(result.difference)}` : ''}\n`);
610
+ if (result.action === 'recorded') io.err('pincer: note: recording an agreement authorizes nothing; record its disposition with `change authorize`\n');
611
+ process.exit(EXIT.OK);
612
+ }
613
+ usage(sub ? `unknown change subcommand ${sub} (change supports: list, show, select, revise, authorize, decide, activate, pause, resume, complete, reopen, cancel, supersede)` : 'change requires a subcommand: list, show, select, revise, authorize, decide, activate, pause, resume, complete, reopen, cancel, supersede');
614
+ }
615
+
284
616
  function cmdSnapshot(root, args) {
285
617
  const o = parseOptions(args, { switches: ['--json', '--store'] });
286
618
  if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
287
619
  const manifest = source.snapshot(root);
288
- for (const problem of manifest.problems) process.stderr.write(`pincer: ${problem.code}: ${problem.detail}\n`);
620
+ for (const problem of manifest.problems) io.err(`pincer: ${problem.code}: ${problem.detail}\n`);
289
621
  if (manifest.problems.length) process.exit(EXIT.INVALID);
290
622
  if (o.store) source.storeManifest(root, manifest);
291
- if (o.json) process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
623
+ if (o.json) io.out(`${JSON.stringify(manifest, null, 2)}\n`);
292
624
  else {
293
- process.stdout.write(`digest ${manifest.digest}\nfiles ${manifest.files.length}\nexcluded ${manifest.excluded.length}\n`);
294
- for (const l of manifest.limitations) process.stderr.write(`pincer: limitation: ${l}\n`);
625
+ io.out(`digest ${manifest.digest}\nfiles ${manifest.files.length}\nexcluded ${manifest.excluded.length}\n`);
626
+ for (const l of manifest.limitations) io.err(`pincer: limitation: ${l}\n`);
295
627
  }
296
628
  process.exit(EXIT.OK);
297
629
  }
@@ -302,7 +634,7 @@ function cmdValidate(root, args) {
302
634
  if (files.some(arg => arg.startsWith('--'))) usage(`unknown option ${files.find(arg => arg.startsWith('--'))}`);
303
635
  if (files.length === 0) usage('validate requires at least one file');
304
636
  let invalid = false;
305
- const report = (prefix, file, problems) => { for (const p of problems) process.stderr.write(`${prefix}: ${file}: ${p}\n`); invalid ||= problems.length > 0; };
637
+ const report = (prefix, file, problems) => { for (const p of problems) io.err(`${prefix}: ${file}: ${p}\n`); invalid ||= problems.length > 0; };
306
638
  for (const file of files) {
307
639
  const relative = path.isAbsolute(file) ? path.relative(root, file) : file;
308
640
  const base = path.basename(relative);
@@ -312,13 +644,13 @@ function cmdValidate(root, args) {
312
644
  const result = parse.validateTicket(relative, text);
313
645
  report('pincer-ticket', relative, result.problems);
314
646
  if (result.ok && digests) {
315
- process.stdout.write(`ticket ${parse.ticketDigest(text)}\n`);
316
- process.stdout.write(`check ${parse.checkDigest(text, result.timeout)}\n`);
647
+ io.out(`ticket ${parse.ticketDigest(text)}\n`);
648
+ io.out(`check ${parse.checkDigest(text, result.timeout)}\n`);
317
649
  }
318
650
  } else if (parse.PRD_REF.test(relative)) {
319
651
  const result = parse.validatePrd(root, relative);
320
652
  report('pincer', relative, result.problems);
321
- if (result.ok && digests) process.stdout.write(`prd ${parse.prdDigest(result.text)}\n`);
653
+ if (result.ok && digests) io.out(`prd ${parse.prdDigest(result.text)}\n`);
322
654
  } else {
323
655
  let text;
324
656
  try { text = fs.readFileSync(path.resolve(root, relative), 'utf8'); } catch { report('pincer', relative, ['no such file']); continue; }
@@ -341,10 +673,14 @@ function main(argv) {
341
673
  if (command === 'migrate') return cmdMigrate(root, rest);
342
674
  if (command === 'check') return cmdCheck(root, rest);
343
675
  if (command === 'evidence') return cmdEvidence(root, rest);
676
+ if (command === 'change') return cmdChange(root, rest);
677
+ if (command === 'resume') return cmdResume(root, rest);
678
+ if (command === 'coverage') return cmdCoverage(root, rest);
679
+ if (command === 'impact') return cmdImpact(root, rest);
344
680
  usage(command ? `unknown command ${command}` : undefined);
345
681
  }
346
682
 
347
683
  Promise.resolve(main(process.argv.slice(2))).catch(error => {
348
- process.stderr.write(`pincer: unexpected error: ${error && error.stack ? error.stack : error}\n`);
684
+ io.err(`pincer: unexpected error: ${error && error.stack ? error.stack : error}\n`);
349
685
  process.exit(EXIT.INVALID);
350
686
  });