pincer-workflow 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +9 -7
  2. package/bin/pincer.js +58 -5
  3. package/package.json +3 -3
  4. package/template/.agents/skills/pincer-code/SKILL.md +117 -12
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +57 -10
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +48 -8
  7. package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
  8. package/template/.agents/skills/pincer-release/SKILL.md +32 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +25 -2
  10. package/template/.claude/commands/pincer-code.md +117 -12
  11. package/template/.claude/commands/pincer-evaluate.md +57 -10
  12. package/template/.claude/commands/pincer-narrow.md +48 -8
  13. package/template/.claude/commands/pincer-plan.md +12 -4
  14. package/template/.claude/commands/pincer-release.md +32 -3
  15. package/template/.claude/commands/pincer-status.md +25 -2
  16. package/template/.claude/hooks/hook-policy.cjs +24 -3
  17. package/template/.claude/references/prd-template.md +11 -4
  18. package/template/.claude/references/ticket-template.md +4 -0
  19. package/template/.codex/README.md +3 -2
  20. package/template/.github/prompts/pincer-code.prompt.md +117 -12
  21. package/template/.github/prompts/pincer-evaluate.prompt.md +57 -10
  22. package/template/.github/prompts/pincer-narrow.prompt.md +48 -8
  23. package/template/.github/prompts/pincer-plan.prompt.md +12 -4
  24. package/template/.github/prompts/pincer-release.prompt.md +32 -3
  25. package/template/.github/prompts/pincer-status.prompt.md +25 -2
  26. package/template/AGENTS.md +22 -0
  27. package/template/docs/dry-run-checklist.md +70 -6
  28. package/template/docs/release-checklist.md +5 -2
  29. package/template/docs/runtime-contracts.md +1683 -0
  30. package/template/scripts/pincer-evidence.cjs +13 -229
  31. package/template/scripts/pincer-runtime/adopt.cjs +132 -0
  32. package/template/scripts/pincer-runtime/agreement.cjs +240 -0
  33. package/template/scripts/pincer-runtime/authorization.cjs +167 -0
  34. package/template/scripts/pincer-runtime/changes.cjs +517 -0
  35. package/template/scripts/pincer-runtime/checks.cjs +48 -0
  36. package/template/scripts/pincer-runtime/coverage.cjs +361 -0
  37. package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
  38. package/template/scripts/pincer-runtime/evidence.cjs +676 -0
  39. package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
  40. package/template/scripts/pincer-runtime/gates.cjs +73 -0
  41. package/template/scripts/pincer-runtime/identity.cjs +163 -0
  42. package/template/scripts/pincer-runtime/impact.cjs +177 -0
  43. package/template/scripts/pincer-runtime/io.cjs +41 -0
  44. package/template/scripts/pincer-runtime/lifecycle.cjs +311 -0
  45. package/template/scripts/pincer-runtime/locator.cjs +158 -0
  46. package/template/scripts/pincer-runtime/migrate.cjs +204 -0
  47. package/template/scripts/pincer-runtime/parse.cjs +316 -0
  48. package/template/scripts/pincer-runtime/phases.cjs +245 -0
  49. package/template/scripts/pincer-runtime/readiness.cjs +97 -0
  50. package/template/scripts/pincer-runtime/requirements.cjs +255 -0
  51. package/template/scripts/pincer-runtime/resume.cjs +205 -0
  52. package/template/scripts/pincer-runtime/routing.cjs +54 -0
  53. package/template/scripts/pincer-runtime/runner.cjs +242 -0
  54. package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
  55. package/template/scripts/pincer-runtime/source.cjs +129 -0
  56. package/template/scripts/pincer-runtime/state.cjs +314 -0
  57. package/template/scripts/pincer-runtime/status.cjs +514 -0
  58. package/template/scripts/pincer-runtime/transaction.cjs +200 -0
  59. package/template/scripts/pincer-runtime/transitions.cjs +134 -0
  60. package/template/scripts/pincer-runtime.cjs +661 -0
  61. package/template/scripts/pincer-status.sh +11 -162
  62. package/template/scripts/pincer-ticket.sh +19 -139
  63. package/template/scripts/pincer-ticket-lib.sh +0 -321
@@ -0,0 +1,517 @@
1
+ 'use strict';
2
+ // PINCER runtime — change records (docs/runtime-contracts.md, "Change records").
3
+ // `.prd/changes/<id>.json` with schema 2 is the portable identity and history of
4
+ // one change; several coexist, each owning exactly one PRD. This module reads
5
+ // and validates the whole directory (mode detection, per-record validation,
6
+ // projection replay, cross-record ownership and supersession rules), registers
7
+ // new records through the transaction API, and renders list/show. Nothing here
8
+ // grants approval or executes anything.
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const parse = require('./parse.cjs');
12
+ const transaction = require('./transaction.cjs');
13
+ const { readJson, nowIso, tryGit } = require('./fsutil.cjs');
14
+
15
+ const SCHEMA = 2;
16
+ const RUNTIME = 2;
17
+ // Strict coverage (PRD v6): schema 3 records carry the retained capability.
18
+ const SCHEMA_STRICT = 3;
19
+ const RUNTIME_STRICT = 3;
20
+ const RECORD_SCHEMAS = [SCHEMA, SCHEMA_STRICT];
21
+ const CHANGE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
22
+ const SHA256 = /^[0-9a-f]{64}$/;
23
+ const SUB_ID = /^[GAD]-[0-9]{2,6}$/;
24
+ const STATES = ['planned', 'active', 'paused', 'completed', 'cancelled', 'superseded'];
25
+ const TERMINAL = ['cancelled', 'superseded'];
26
+ // kind -> [allowed from states, to state]; null from = record creation.
27
+ const LIFECYCLE_KINDS = {
28
+ register: [[null], 'planned'], migrate: [[null], 'planned'], activate: [['planned'], 'active'], pause: [['active'], 'paused'],
29
+ resume: [['paused'], 'active'], complete: [['active'], 'completed'], reopen: [['completed'], 'active'],
30
+ cancel: [['planned', 'active', 'paused'], 'cancelled'], supersede: [['planned', 'active', 'paused', 'completed'], 'superseded'],
31
+ };
32
+ const OTHER_KINDS = ['agreement', 'authorize', 'decide', 'resolve', 'adopt'];
33
+ const EVENT_KINDS = [...Object.keys(LIFECYCLE_KINDS), 'agreement', 'authorize', 'decide', 'resolve'];
34
+ const EVENT_KINDS_STRICT = [...EVENT_KINDS, 'adopt'];
35
+ const RECORD_KEYS = ['schema', 'runtime', 'change', 'prd', 'base', 'registered', 'sequence', 'lifecycle', 'agreements', 'authorizations', 'decisions', 'events', 'evaluations', 'legacy'];
36
+ const RECORD_KEYS_STRICT = [...RECORD_KEYS, 'coverage'];
37
+ const COVERAGE_KEYS = ['map', 'adopted', 'agreement'];
38
+ const LIFECYCLE_KEYS = ['state', 'since', 'reason', 'note', 'superseded_by'];
39
+ const AGREEMENT_KEYS = ['id', 'digest', 'prd_revision', 'breakdown', 'tickets', 'decisions', 'snapshot', 'recorded'];
40
+ const AGREEMENT_KEYS_STRICT = ['id', 'digest', 'prd_revision', 'breakdown', 'inventory', 'coverage', 'tickets', 'decisions', 'snapshot', 'recorded'];
41
+ const AUTHORIZATION_KEYS = ['id', 'agreement', 'digest', 'disposition', 'reference', 'excerpt', 'constraints', 'basis', 'explanation', 'decisions', 'recorded'];
42
+ const DECISION_KEYS = ['id', 'status', 'summary', 'reference', 'excerpt', 'raised', 'resolved'];
43
+ const EVENT_KEYS = ['sequence', 'kind', 'from', 'to', 'at', 'reason', 'agreement', 'authorization', 'decision', 'replacement', 'note'];
44
+ const LEGACY_KEYS = ['receipts', 'authorization_text', 'migrated_from', 'migrated'];
45
+ const MAX_TEXT = transaction.MAX_TEXT;
46
+
47
+ const CHANGES_DIR = '.prd/changes';
48
+ const COVERAGE_DIR = '.prd/coverage';
49
+ const isStrict = record => Boolean(record) && record.schema === SCHEMA_STRICT;
50
+ const recordFile = id => `${CHANGES_DIR}/${id}.json`;
51
+ const snapshotFile = (id, agreementId) => `${CHANGES_DIR}/${id}/agreements/${agreementId}.json`;
52
+ const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
53
+ const str = v => typeof v === 'string' && v.length > 0;
54
+ const textOrNull = v => v === null || (typeof v === 'string' && v.length <= MAX_TEXT);
55
+ const nonemptyText = v => typeof v === 'string' && v.trim() !== '' && v.length <= MAX_TEXT;
56
+ const timestamp = v => typeof v === 'string' && parse.TIMESTAMP.test(v);
57
+ const sequentialId = (prefix, i) => `${prefix}-${String(i + 1).padStart(2, '0')}`;
58
+
59
+ // --- Projection replay ------------------------------------------------------------
60
+ // The lifecycle projection is what the events say it is. Returns { state,
61
+ // superseded_by, since, reason, note } or { error }.
62
+ function replay(events) {
63
+ let state = null, superseded_by = null, since = null, reason = null, note = null;
64
+ for (const [i, e] of events.entries()) {
65
+ if (!isObject(e)) return { error: `event ${i + 1} is not an object` };
66
+ if (e.sequence !== i + 1) return { error: `event ${i + 1} carries sequence ${JSON.stringify(e.sequence)}` };
67
+ const rule = e.kind === 'migrate' && state !== null ? null : LIFECYCLE_KINDS[e.kind];
68
+ if (rule) {
69
+ if (!rule[0].includes(state)) return { error: `event ${e.sequence} (${e.kind}) is not permitted from ${state === null ? 'no record' : state}` };
70
+ if (e.from !== state || e.to !== rule[1]) return { error: `event ${e.sequence} (${e.kind}) records ${JSON.stringify(e.from)} → ${JSON.stringify(e.to)}, expected ${JSON.stringify(state)} → ${rule[1]}` };
71
+ state = rule[1]; since = e.at; reason = e.reason ?? null; note = e.note ?? null;
72
+ superseded_by = e.kind === 'supersede' ? e.replacement : null;
73
+ if (e.kind === 'supersede' && !(typeof e.replacement === 'string' && CHANGE_ID.test(e.replacement))) return { error: `event ${e.sequence} (supersede) names no replacement change` };
74
+ if ((e.kind === 'cancel' || e.kind === 'supersede') && !(typeof e.decision === 'string' && SUB_ID.test(e.decision) && e.decision.startsWith('D-'))) return { error: `event ${e.sequence} (${e.kind}) names no decision` };
75
+ } else if (OTHER_KINDS.includes(e.kind) || e.kind === 'migrate') {
76
+ if (state === null) return { error: `event ${e.sequence} (${e.kind}) precedes the record's creation` };
77
+ if (e.from !== state || e.to !== state) return { error: `event ${e.sequence} (${e.kind}) changes the state` };
78
+ } else return { error: `event ${e.sequence} has unknown kind ${JSON.stringify(e.kind)}` };
79
+ }
80
+ if (state === null) return { error: 'no events' };
81
+ return { state, superseded_by, since, reason, note };
82
+ }
83
+
84
+ // --- Per-record validation ---------------------------------------------------------
85
+ // Returns null or { code: MALFORMED | UNSUPPORTED_SCHEMA | HISTORY_INVALID, problem }.
86
+ function validateRecord(doc, file) {
87
+ const where = file || (isObject(doc) && str(doc.change) ? recordFile(doc.change) : `${CHANGES_DIR}/?`);
88
+ const malformed = p => ({ code: 'MALFORMED', problem: `${where}: ${p}` });
89
+ const history = p => ({ code: 'HISTORY_INVALID', problem: `${where}: ${p}` });
90
+ if (!isObject(doc)) return malformed('record must be a JSON object');
91
+ if (!RECORD_SCHEMAS.includes(doc.schema)) return { code: 'UNSUPPORTED_SCHEMA', problem: `${where}: unsupported change record schema ${JSON.stringify(doc.schema)} (this runtime reads schema 2 and 3 records and schema 1 bindings)` };
92
+ const strict = doc.schema === SCHEMA_STRICT;
93
+ const recordKeys = strict ? RECORD_KEYS_STRICT : RECORD_KEYS;
94
+ const agreementKeys = strict ? AGREEMENT_KEYS_STRICT : AGREEMENT_KEYS;
95
+ const eventKinds = strict ? EVENT_KINDS_STRICT : EVENT_KINDS;
96
+ const capability = 'the strict coverage capability cannot be removed by editing the record; restore the backed-up schema 2 record instead';
97
+ for (const key of Object.keys(doc)) if (!recordKeys.includes(key)) return malformed(`unknown key "${key}"${key === 'coverage' ? ' (a schema 2 record carries no strict coverage capability; adoption writes a schema 3 record)' : ''}`);
98
+ for (const key of recordKeys) if (!(key in doc)) return malformed(`missing key "${key}"${key === 'coverage' ? ` (${capability})` : ''}`);
99
+ if (strict && doc.coverage === null) return malformed(capability);
100
+ if (doc.runtime !== (strict ? RUNTIME_STRICT : RUNTIME)) return { code: 'UNSUPPORTED_SCHEMA', problem: `${where}: unsupported runtime contract ${JSON.stringify(doc.runtime)}` };
101
+ if (!str(doc.change) || !CHANGE_ID.test(doc.change)) return malformed('change must match [a-z0-9][a-z0-9-]{0,63}');
102
+ if (file && path.basename(file, '.json') !== doc.change) return malformed(`filename does not match change "${doc.change}"`);
103
+ if (!str(doc.prd) || !parse.PRD_REF.test(doc.prd)) return malformed('prd must be of the form .prd/prd-vN.md');
104
+ if (!str(doc.base) || !parse.HEX40.test(doc.base)) return malformed('base must be a full 40-hex commit ID');
105
+ if (!timestamp(doc.registered)) return malformed('registered must be an ISO UTC timestamp');
106
+ if (!Number.isInteger(doc.sequence) || doc.sequence < 1) return malformed('sequence must be a positive integer');
107
+ for (const [key, keys] of [['lifecycle', LIFECYCLE_KEYS], ['legacy', LEGACY_KEYS]]) {
108
+ if (!isObject(doc[key])) return malformed(`${key} must be an object`);
109
+ for (const k of Object.keys(doc[key])) if (!keys.includes(k)) return malformed(`${key}.${k} is not allowed`);
110
+ for (const k of keys) if (!(k in doc[key])) return malformed(`${key}.${k} is missing`);
111
+ }
112
+ for (const key of ['agreements', 'authorizations', 'decisions', 'events', 'evaluations']) if (!Array.isArray(doc[key])) return malformed(`${key} must be an array`);
113
+ if (doc.evaluations.length) return malformed('evaluations must be empty in schema 2 (evaluation references live in the evaluation locator)');
114
+ const lc = doc.lifecycle;
115
+ if (!STATES.includes(lc.state)) return malformed(`lifecycle.state must be one of ${STATES.join(', ')}`);
116
+ if (!timestamp(lc.since)) return malformed('lifecycle.since must be an ISO UTC timestamp');
117
+ if (!textOrNull(lc.reason) || !textOrNull(lc.note)) return malformed('lifecycle.reason and lifecycle.note must be null or short strings');
118
+ if (!(lc.superseded_by === null || (str(lc.superseded_by) && CHANGE_ID.test(lc.superseded_by)))) return malformed('lifecycle.superseded_by must be null or a change ID');
119
+ if ((lc.state === 'superseded') !== (lc.superseded_by !== null)) return malformed('lifecycle.superseded_by is set exactly when the state is superseded');
120
+ // Decisions first: agreements and authorizations refer to them.
121
+ const decisions = new Map();
122
+ for (const [i, d] of doc.decisions.entries()) {
123
+ if (!isObject(d)) return malformed(`decisions[${i}] must be an object`);
124
+ for (const k of Object.keys(d)) if (!DECISION_KEYS.includes(k)) return malformed(`decisions[${i}].${k} is not allowed`);
125
+ for (const k of DECISION_KEYS) if (!(k in d)) return malformed(`decisions[${i}].${k} is missing`);
126
+ if (d.id !== sequentialId('D', i)) return malformed(`decisions[${i}].id must be ${sequentialId('D', i)}`);
127
+ if (!['open', 'resolved'].includes(d.status)) return malformed(`${d.id}: status must be open or resolved`);
128
+ if (!nonemptyText(d.summary)) return malformed(`${d.id}: summary must be a nonempty short string`);
129
+ if (!timestamp(d.raised)) return malformed(`${d.id}: raised must be an ISO UTC timestamp`);
130
+ if (d.status === 'resolved') {
131
+ if (!nonemptyText(d.reference) || !nonemptyText(d.excerpt)) return malformed(`${d.id}: a resolved decision needs reference and excerpt`);
132
+ if (!timestamp(d.resolved)) return malformed(`${d.id}: resolved must be an ISO UTC timestamp`);
133
+ } else if (d.reference !== null || d.excerpt !== null || d.resolved !== null) return malformed(`${d.id}: an open decision carries null reference, excerpt and resolved`);
134
+ decisions.set(d.id, d);
135
+ }
136
+ const decisionRefs = (list, label) => {
137
+ if (!Array.isArray(list)) return `${label}: decisions must be an array`;
138
+ for (const id of list) { const d = decisions.get(id); if (!d) return `${label}: references unknown decision ${JSON.stringify(id)}`; if (d.status !== 'resolved') return `${label}: references open decision ${id}`; }
139
+ return null;
140
+ };
141
+ const agreements = new Map();
142
+ for (const [i, g] of doc.agreements.entries()) {
143
+ if (!isObject(g)) return malformed(`agreements[${i}] must be an object`);
144
+ for (const k of Object.keys(g)) if (!agreementKeys.includes(k)) return malformed(`agreements[${i}].${k} is not allowed`);
145
+ for (const k of agreementKeys) if (!(k in g)) return malformed(`agreements[${i}].${k} is missing`);
146
+ if (strict) for (const k of ['inventory', 'coverage']) if (!(g[k] === null || (str(g[k]) && SHA256.test(g[k])))) return malformed(`${g.id}: ${k} must be null or a 64-hex digest`);
147
+ if (g.id !== sequentialId('G', i)) return malformed(`agreements[${i}].id must be ${sequentialId('G', i)}`);
148
+ for (const k of ['digest', 'prd_revision', 'breakdown']) if (!str(g[k]) || !SHA256.test(g[k])) return malformed(`${g.id}: ${k} must be a 64-hex digest`);
149
+ if (!Array.isArray(g.tickets) || !g.tickets.every(t => parse.canonicalId(t))) return malformed(`${g.id}: tickets must be canonical ticket IDs`);
150
+ const refs = decisionRefs(g.decisions, g.id); if (refs) return malformed(refs);
151
+ if (g.snapshot !== snapshotFile(doc.change, g.id)) return malformed(`${g.id}: snapshot must be ${snapshotFile(doc.change, g.id)}`);
152
+ if (!timestamp(g.recorded)) return malformed(`${g.id}: recorded must be an ISO UTC timestamp`);
153
+ agreements.set(g.id, g);
154
+ }
155
+ const authorizations = new Map();
156
+ for (const [i, a] of doc.authorizations.entries()) {
157
+ if (!isObject(a)) return malformed(`authorizations[${i}] must be an object`);
158
+ for (const k of Object.keys(a)) if (!AUTHORIZATION_KEYS.includes(k)) return malformed(`authorizations[${i}].${k} is not allowed`);
159
+ for (const k of AUTHORIZATION_KEYS) if (!(k in a)) return malformed(`authorizations[${i}].${k} is missing`);
160
+ if (a.id !== sequentialId('A', i)) return malformed(`authorizations[${i}].id must be ${sequentialId('A', i)}`);
161
+ const g = agreements.get(a.agreement);
162
+ if (!g) return history(`${a.id}: references unknown agreement ${JSON.stringify(a.agreement)}`);
163
+ if (a.digest !== g.digest) return history(`${a.id}: digest does not equal ${g.id}'s`);
164
+ if (!['user', 'delegated'].includes(a.disposition)) return malformed(`${a.id}: disposition must be user or delegated`);
165
+ if (!textOrNull(a.constraints)) return malformed(`${a.id}: constraints must be null or a short string`);
166
+ if (a.disposition === 'user') {
167
+ if (!nonemptyText(a.reference) || !nonemptyText(a.excerpt)) return malformed(`${a.id}: a user authorization needs reference and excerpt`);
168
+ if (a.basis !== null || a.explanation !== null) return malformed(`${a.id}: a user authorization carries null basis and explanation`);
169
+ } else {
170
+ if (a.reference !== null || a.excerpt !== null) return malformed(`${a.id}: a delegated authorization carries null reference and excerpt`);
171
+ if (!nonemptyText(a.explanation)) return malformed(`${a.id}: a delegated authorization needs an explanation`);
172
+ if (!authorizations.has(a.basis)) return history(`${a.id}: basis ${JSON.stringify(a.basis)} is not an earlier authorization of this change`);
173
+ }
174
+ const refs = decisionRefs(a.decisions, a.id); if (refs) return malformed(refs);
175
+ if (!timestamp(a.recorded)) return malformed(`${a.id}: recorded must be an ISO UTC timestamp`);
176
+ authorizations.set(a.id, a);
177
+ }
178
+ for (const [i, e] of doc.events.entries()) {
179
+ if (!isObject(e)) return malformed(`events[${i}] must be an object`);
180
+ for (const k of Object.keys(e)) if (!EVENT_KEYS.includes(k)) return malformed(`events[${i}].${k} is not allowed`);
181
+ for (const k of EVENT_KEYS) if (!(k in e)) return malformed(`events[${i}].${k} is missing`);
182
+ if (!eventKinds.includes(e.kind)) return malformed(`events[${i}].kind must be one of ${eventKinds.join(', ')}${e.kind === 'adopt' ? ' (a schema 2 record carries no adopt event; adoption writes a schema 3 record)' : ''}`);
183
+ if (!timestamp(e.at)) return malformed(`events[${i}].at must be an ISO UTC timestamp`);
184
+ if (!textOrNull(e.reason) || !textOrNull(e.note)) return malformed(`events[${i}]: reason and note must be null or short strings`);
185
+ if (!(e.agreement === null || agreements.has(e.agreement))) return history(`events[${i}]: references unknown agreement ${JSON.stringify(e.agreement)}`);
186
+ if (!(e.authorization === null || authorizations.has(e.authorization))) return history(`events[${i}]: references unknown authorization ${JSON.stringify(e.authorization)}`);
187
+ if (!(e.decision === null || decisions.has(e.decision))) return history(`events[${i}]: references unknown decision ${JSON.stringify(e.decision)}`);
188
+ if (!(e.replacement === null || (str(e.replacement) && CHANGE_ID.test(e.replacement)))) return malformed(`events[${i}].replacement must be null or a change ID`);
189
+ }
190
+ if (doc.sequence !== doc.events.length) return history(`sequence is ${doc.sequence} but the history has ${doc.events.length} event(s)`);
191
+ const projected = replay(doc.events);
192
+ if (projected.error) return history(projected.error);
193
+ if (projected.state !== lc.state) return history(`lifecycle.state is ${lc.state} but the history ends at ${projected.state}`);
194
+ if ((projected.superseded_by || null) !== lc.superseded_by) return history(`lifecycle.superseded_by disagrees with the supersede event`);
195
+ if (lc.superseded_by === doc.change) return history('a change cannot supersede itself');
196
+ if (strict) {
197
+ const cv = doc.coverage;
198
+ if (!isObject(cv)) return malformed('coverage must be an object { map, adopted, agreement }');
199
+ for (const k of Object.keys(cv)) if (!COVERAGE_KEYS.includes(k)) return malformed(`coverage.${k} is not allowed`);
200
+ for (const k of COVERAGE_KEYS) if (!(k in cv)) return malformed(`coverage.${k} is missing`);
201
+ if (cv.map !== `${COVERAGE_DIR}/${doc.change}.json`) return malformed(`coverage.map must be ${COVERAGE_DIR}/${doc.change}.json`);
202
+ if (!timestamp(cv.adopted)) return malformed('coverage.adopted must be an ISO UTC timestamp');
203
+ const g = agreements.get(cv.agreement);
204
+ if (!g) return history(`coverage.agreement ${JSON.stringify(cv.agreement)} is not an agreement of this record`);
205
+ if (g.inventory === null || g.coverage === null) return history(`coverage.agreement ${g.id} carries no inventory and coverage digests (it predates adoption)`);
206
+ const adopts = doc.events.filter(e => e.kind === 'adopt');
207
+ if (adopts.length !== 1) return history(`a strict record carries exactly one adopt event (found ${adopts.length})`);
208
+ if (adopts[0].agreement !== cv.agreement) return history(`the adopt event names agreement ${JSON.stringify(adopts[0].agreement)}, coverage.agreement is ${cv.agreement}`);
209
+ }
210
+ const lg = doc.legacy;
211
+ if (!isObject(lg.receipts)) return malformed('legacy.receipts must be an object');
212
+ for (const [id, r] of Object.entries(lg.receipts)) {
213
+ if (!parse.canonicalId(id) || !isObject(r) || !['verified', 'last_check'].every(k => k in r && (r[k] === null || typeof r[k] === 'string'))) return malformed(`legacy.receipts[${id}] must be { verified, last_check }`);
214
+ }
215
+ if (!textOrNull(lg.authorization_text)) return malformed('legacy.authorization_text must be null or a short string');
216
+ if (!(lg.migrated_from === null || ['legacy', 'binding'].includes(lg.migrated_from))) return malformed('legacy.migrated_from must be null, legacy or binding');
217
+ if (!(lg.migrated === null || timestamp(lg.migrated))) return malformed('legacy.migrated must be null or an ISO UTC timestamp');
218
+ if ((lg.migrated_from === null) !== (lg.migrated === null)) return malformed('legacy.migrated_from and legacy.migrated are set together');
219
+ return null;
220
+ }
221
+
222
+ // --- Directory scan and mode ---------------------------------------------------------
223
+ function listFiles(root) {
224
+ const dir = path.join(root, CHANGES_DIR);
225
+ if (!fs.existsSync(dir)) return [];
226
+ return fs.readdirSync(dir).filter(name => name.endsWith('.json')).sort().map(name => `${CHANGES_DIR}/${name}`);
227
+ }
228
+ // Classify every file: schema 1 bindings, schema 2 records, and files this
229
+ // runtime cannot read. Mode: legacy (nothing), migrated (schema 1 only; the
230
+ // v0.5.0 rules decide the rest), changes (schema 2 only, readable or not),
231
+ // invalid (mixed schemas, or nothing readable).
232
+ function scan(root) {
233
+ const entries = [];
234
+ for (const file of listFiles(root)) {
235
+ const read = readJson(path.join(root, file));
236
+ if (read.error) { entries.push({ file, code: 'MALFORMED', problem: `${file}: ${read.error}` }); continue; }
237
+ const doc = read.data;
238
+ if (!isObject(doc)) { entries.push({ file, code: 'MALFORMED', problem: `${file}: record must be a JSON object` }); continue; }
239
+ if (doc.schema === 1) entries.push({ file, schema: 1, doc });
240
+ else if (RECORD_SCHEMAS.includes(doc.schema)) entries.push({ file, schema: doc.schema, record: true, id: path.basename(file, '.json'), doc });
241
+ else entries.push({ file, code: 'UNSUPPORTED_SCHEMA', problem: `${file}: unsupported schema ${JSON.stringify(doc.schema)} (this runtime reads schema 1 bindings and schema 2 and 3 change records)` });
242
+ }
243
+ const one = entries.filter(e => e.schema === 1), two = entries.filter(e => e.record), bad = entries.filter(e => e.code);
244
+ const problems = [];
245
+ let mode;
246
+ if (!entries.length) mode = 'legacy';
247
+ else if (one.length && two.length) { mode = 'invalid'; problems.push({ code: 'INPUT_INVALID', detail: `${CHANGES_DIR}/ mixes a schema 1 binding (${one.map(e => path.basename(e.file)).join(', ')}) with schema 2 change records (${two.map(e => path.basename(e.file)).join(', ')}); migrate the binding or remove the records by hand` }); }
248
+ else if (two.length) mode = 'changes';
249
+ else if (one.length) mode = 'migrated';
250
+ else mode = 'invalid';
251
+ for (const e of bad) if (mode !== 'migrated') problems.push({ code: e.code, detail: e.problem });
252
+ return { mode, entries, problems };
253
+ }
254
+
255
+ // Load and validate every schema 2 record and the cross-record rules. Returns
256
+ // { mode, records: Map<id, { record, file }>, problems: [{ code, detail }], pending }.
257
+ function loadRecords(root) {
258
+ const s = scan(root);
259
+ const out = { mode: s.mode, records: new Map(), problems: [...s.problems], pending: transaction.pending(root) };
260
+ if (s.mode !== 'changes') return out;
261
+ if (out.pending.committed.length) out.problems.push({ code: 'STATE_INCOMPLETE', detail: `a committed transaction (${out.pending.committed[0].command || out.pending.committed[0].id}) was not fully applied; run: node scripts/pincer-runtime.cjs recover` });
262
+ const owners = new Map();
263
+ for (const e of s.entries.filter(x => x.record)) {
264
+ const invalid = validateRecord(e.doc, e.file);
265
+ if (invalid) { out.problems.push({ code: invalid.code, detail: invalid.problem }); continue; }
266
+ // Every recorded agreement must be reviewable: its snapshot exists and its
267
+ // digest recomputes from the snapshot (docs/runtime-contracts.md).
268
+ const agreement = require('./agreement.cjs');
269
+ const badSnapshot = e.doc.agreements.map(g => agreement.readSnapshot(root, e.doc, g)).find(r => r.code);
270
+ if (badSnapshot) { out.problems.push({ code: badSnapshot.code, detail: badSnapshot.problem }); continue; }
271
+ out.records.set(e.id, { record: e.doc, file: e.file });
272
+ const prior = owners.get(e.doc.prd);
273
+ if (prior) out.problems.push({ code: 'INPUT_INVALID', detail: `duplicate PRD ownership: ${e.doc.prd} is owned by change "${prior}" and change "${e.id}"; remove one record by hand` });
274
+ else owners.set(e.doc.prd, e.id);
275
+ }
276
+ for (const [id, { record }] of out.records) {
277
+ const target = record.lifecycle.superseded_by;
278
+ if (target === null) continue;
279
+ if (!out.records.has(target)) { out.problems.push({ code: 'HISTORY_INVALID', detail: `${recordFile(id)}: superseded by "${target}", which is not a retained change record` }); continue; }
280
+ const seen = new Set([id]);
281
+ let cursor = target;
282
+ while (cursor && out.records.has(cursor)) {
283
+ if (seen.has(cursor)) { out.problems.push({ code: 'HISTORY_INVALID', detail: `${recordFile(id)}: supersession returns to "${cursor}" (a cycle)` }); break; }
284
+ seen.add(cursor);
285
+ cursor = out.records.get(cursor).record.lifecycle.superseded_by;
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+ // One record by id (after the directory validated). { record, file, loaded } or { code, problem }.
291
+ function loadRecord(root, id) {
292
+ const loaded = loadRecords(root);
293
+ if (loaded.mode === 'legacy') return { code: 'CHANGE_REQUIRED', problem: `no change record under ${CHANGES_DIR}/ — register with: node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md`, loaded };
294
+ if (loaded.mode === 'migrated') return { code: 'MIGRATION_REQUIRED', problem: `${CHANGES_DIR}/ holds a v0.5.0 binding; migrate it first: node scripts/pincer-runtime.cjs migrate --preview --prd ${loaded.records.size ? '' : '<prd>'}`, loaded };
295
+ if (loaded.problems.length) return { code: loaded.problems[0].code, problem: loaded.problems[0].detail, loaded };
296
+ if (typeof id !== 'string' || !CHANGE_ID.test(id)) return { code: 'INPUT_INVALID', problem: `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}`, loaded };
297
+ const entry = loaded.records.get(id);
298
+ if (!entry) return { code: 'INPUT_INVALID', problem: `no change record ${recordFile(id)} (retained: ${[...loaded.records.keys()].join(', ') || 'none'})`, loaded };
299
+ return { ...entry, loaded };
300
+ }
301
+ const ownerOf = (loaded, prd) => [...loaded.records.entries()].find(([, e]) => e.record.prd === prd) || null;
302
+
303
+ function head(root) {
304
+ const result = tryGit(root, ['rev-parse', '--verify', 'HEAD^{commit}']);
305
+ if (result.error || !parse.HEX40.test(result.out.trim())) return null;
306
+ return result.out.trim();
307
+ }
308
+ const IGNORE_LINE = '.pincer/';
309
+ function gitignoreHas(text) {
310
+ return text.split('\n').map(l => l.trim()).some(l => l === IGNORE_LINE || l === '/.pincer/' || l === '.pincer');
311
+ }
312
+ function gitignoreWith(existing) {
313
+ const lead = existing && !existing.endsWith('\n') ? '\n' : '';
314
+ return `${existing}${lead}${existing ? '\n' : ''}# pincer runtime state (added by the runtime)\n${IGNORE_LINE}\n`;
315
+ }
316
+
317
+ function newRecord({ change, prd, base, now, kind = 'register', legacy = null }) {
318
+ return {
319
+ schema: SCHEMA, runtime: RUNTIME, change, prd, base, registered: now, sequence: 1,
320
+ lifecycle: { state: 'planned', since: now, reason: null, note: null, superseded_by: null },
321
+ agreements: [], authorizations: [], decisions: [],
322
+ events: [{ sequence: 1, kind, from: null, to: 'planned', at: now, reason: null, agreement: null, authorization: null, decision: null, replacement: null, note: null }],
323
+ evaluations: [],
324
+ legacy: legacy || { receipts: {}, authorization_text: null, migrated_from: null, migrated: null },
325
+ };
326
+ }
327
+
328
+ // --- register (legacy or changes mode) ---------------------------------------------
329
+ // Writes a planned schema 2 record and ignores .pincer/, through one transaction.
330
+ // Returns { record, file, action: 'registered' | 'unchanged' } or { code, problem }.
331
+ function register(root, { prd, change } = {}) {
332
+ const prdResult = parse.validatePrd(root, prd);
333
+ if (!prdResult.ok) return { code: 'INPUT_INVALID', problem: `${prdResult.file || prd}: ${prdResult.problems[0]}` };
334
+ const id = change || `prd-v${prd.match(parse.PRD_REF)[1]}`;
335
+ if (!CHANGE_ID.test(id)) return { code: 'INPUT_INVALID', problem: `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}` };
336
+ const base = head(root);
337
+ if (!base) return { code: 'UNSUPPORTED_INPUT', problem: 'registration needs a git repository with at least one commit (base = HEAD)' };
338
+ try {
339
+ const out = transaction.run(root, { command: `register ${id}` }, ctx => {
340
+ const loaded = loadRecords(root);
341
+ if (loaded.mode === 'migrated') ctx.refuse('MIGRATION_REQUIRED', `${CHANGES_DIR}/ holds a v0.5.0 binding; one binding per worktree in migrated mode — migrate to change records first: node scripts/pincer-runtime.cjs migrate --preview --prd <its prd>, then register ${prd}`);
342
+ if (loaded.problems.length) ctx.refuse(loaded.problems[0].code, loaded.problems[0].detail);
343
+ const existing = loaded.records.get(id);
344
+ if (existing) {
345
+ if (existing.record.prd === prd) return { record: existing.record, file: existing.file, action: 'unchanged' };
346
+ ctx.refuse('INPUT_INVALID', `${existing.file} already names change "${id}" for ${existing.record.prd}; choose another --change ID for ${prd}`);
347
+ }
348
+ const owner = ownerOf(loaded, prd);
349
+ if (owner) ctx.refuse('INPUT_INVALID', `${prd} is already owned by change "${owner[0]}" (${owner[1].file}); work on it with: node scripts/pincer-runtime.cjs change select ${owner[0]}`);
350
+ const record = newRecord({ change: id, prd, base, now: ctx.now });
351
+ const file = recordFile(id);
352
+ ctx.write(file, record);
353
+ const ignore = ctx.text('.gitignore') || '';
354
+ if (!gitignoreHas(ignore)) ctx.write('.gitignore', gitignoreWith(ignore));
355
+ return { record, file, action: 'registered' };
356
+ });
357
+ return out.result;
358
+ } catch (error) {
359
+ if (error.refusal) return { code: error.code, problem: error.message };
360
+ if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
361
+ throw error;
362
+ }
363
+ }
364
+
365
+ // --- list / show ----------------------------------------------------------------------
366
+ function summarize(id, { record, file }, extra = {}) {
367
+ return { id, file, prd: record.prd, strict: isStrict(record), state: record.lifecycle.state, since: record.lifecycle.since, reason: record.lifecycle.reason, note: record.lifecycle.note, superseded_by: record.lifecycle.superseded_by, sequence: record.sequence, base: record.base, registered: record.registered, ...extra };
368
+ }
369
+ function list(root) {
370
+ const loaded = loadRecords(root);
371
+ const changes = [...loaded.records.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, e]) => summarize(id, e));
372
+ return { mode: loaded.mode, changes, problems: loaded.problems };
373
+ }
374
+ const short = s => (typeof s === 'string' ? s.slice(0, 12) : '—');
375
+ function renderList(result, { selection = null } = {}) {
376
+ const lines = [];
377
+ if (result.mode === 'legacy') return 'Changes none (legacy project; register with: node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md)\n';
378
+ if (result.mode === 'migrated') return 'Changes v0.5.0 binding (migrated mode); migrate to change records with: node scripts/pincer-runtime.cjs migrate --preview --prd <prd>\n';
379
+ for (const p of result.problems) lines.push(`WARN ${p.code}: ${p.detail}`);
380
+ lines.push(`Changes ${result.changes.length} retained${selection && selection.change ? ` · selected ${selection.change}` : ' · no selection'}`);
381
+ for (const c of result.changes) {
382
+ const mark = selection && selection.change === c.id ? '*' : ' ';
383
+ let detail = `${c.prd} · since ${c.since} · sequence ${c.sequence}${c.authorization ? ` · authorization ${c.authorization}` : ''}`;
384
+ if (c.state === 'superseded') detail += ` · by ${c.superseded_by}`;
385
+ if (c.reason) detail += ` · ${c.reason}`;
386
+ lines.push(`${mark} ${c.id.padEnd(16)} ${c.state.padEnd(10)} ${detail}`);
387
+ }
388
+ if (!result.changes.length) lines.push(' (no readable change record)');
389
+ return `${lines.join('\n')}\n`;
390
+ }
391
+ function renderShow(id, { record, file }, extra = {}) {
392
+ const r = record, lines = [];
393
+ lines.push(`Change ${id} · ${r.prd} · base ${r.base.slice(0, 7)} · registered ${r.registered} · sequence ${r.sequence} (${file})`);
394
+ const lc = r.lifecycle;
395
+ lines.push(`Lifecycle ${lc.state} since ${lc.since}${lc.superseded_by ? ` · superseded by ${lc.superseded_by}` : ''}${lc.reason ? ` · reason: ${lc.reason}` : ''}${lc.note ? ` · note (authored): ${lc.note}` : ''}`);
396
+ lines.push(`Agreements ${r.agreements.length ? r.agreements.map(g => `${g.id} ${short(g.digest)} (prd ${short(g.prd_revision)}, ${g.tickets.length} ticket(s), ${g.decisions.length} decision(s)) recorded ${g.recorded}`).join('; ') : 'none recorded'}`);
397
+ if (extra.agreement) {
398
+ const a = extra.agreement;
399
+ if (a.code) lines.push(`Agreement now: cannot be computed — ${a.code}: ${a.problem}`);
400
+ else lines.push(`Agreement now ${short(a.digest)}${a.entry ? ` = ${a.entry.id}` : a.latest ? ` ≠ latest recorded ${a.latest.id} ${short(a.latest.digest)} (${a.rendered}); record it with: node scripts/pincer-runtime.cjs change revise ${id}` : ' (not recorded; record it with: node scripts/pincer-runtime.cjs change revise ' + id + ')'}`);
401
+ }
402
+ lines.push(`Authorizations ${r.authorizations.length ? r.authorizations.map(a => `${a.id} ${a.disposition} for ${a.agreement} (${short(a.digest)}) recorded ${a.recorded}${a.disposition === 'user' ? ` — "${a.excerpt}" (${a.reference})` : ` — basis ${a.basis}: ${a.explanation}`}`).join('; ') : 'none'}`);
403
+ if (extra.verdict) lines.push(`Authorization ${extra.verdict.verdict}${extra.verdict.verdict === 'current' ? ` — ${extra.verdict.detail}` : `: ${extra.verdict.detail}`}`);
404
+ lines.push(`Decisions ${r.decisions.length ? r.decisions.map(d => `${d.id} ${d.status}: ${d.summary}${d.status === 'resolved' ? ` — "${d.excerpt}" (${d.reference})` : ''}`).join('; ') : 'none'}`);
405
+ lines.push(`Evaluations ${extra.locatorProblem ? `unreadable: ${extra.locatorProblem}` : extra.evaluations && extra.evaluations.length ? extra.evaluations.map(e => `${e.candidate.slice(0, 7)} ${e.manifest} recorded ${e.recorded}`).join('; ') : `none recorded (.prd/evidence/changes/${id}.json)`}`);
406
+ lines.push(`Coverage ${isStrict(r) ? `strict since ${r.coverage.adopted} · map ${r.coverage.map} · adoption agreement ${r.coverage.agreement}` : 'unverified (strict coverage not adopted; preview with: node scripts/pincer-runtime.cjs coverage adopt --preview --change ' + id + ')'}`);
407
+ lines.push(`Legacy ${r.legacy.migrated_from ? `migrated from ${r.legacy.migrated_from} at ${r.legacy.migrated}; ${Object.keys(r.legacy.receipts).length} receipt(s) as history${r.legacy.authorization_text ? `; v0.5.0 authorization text (unvalidated): "${r.legacy.authorization_text}"` : ''}` : 'none'}`);
408
+ lines.push('Events');
409
+ for (const e of r.events) lines.push(` ${String(e.sequence).padStart(3)} ${e.at} ${e.kind.padEnd(10)} ${e.from === null ? '—' : e.from} → ${e.to}${e.agreement ? ` ${e.agreement}` : ''}${e.authorization ? ` ${e.authorization}` : ''}${e.decision ? ` ${e.decision}` : ''}${e.replacement ? ` → ${e.replacement}` : ''}${e.reason ? ` · ${e.reason}` : ''}${e.note ? ` · note: ${e.note}` : ''}`);
410
+ return `${lines.join('\n')}\n`;
411
+ }
412
+
413
+ module.exports = {
414
+ SCHEMA, RUNTIME, SCHEMA_STRICT, RUNTIME_STRICT, RECORD_SCHEMAS, CHANGE_ID, STATES, TERMINAL, LIFECYCLE_KINDS, EVENT_KINDS, EVENT_KINDS_STRICT, RECORD_KEYS, RECORD_KEYS_STRICT, AGREEMENT_KEYS, AGREEMENT_KEYS_STRICT, COVERAGE_KEYS, CHANGES_DIR, COVERAGE_DIR, IGNORE_LINE, isStrict,
415
+ recordFile, snapshotFile, replay, validateRecord, scan, loadRecords, loadRecord, ownerOf, newRecord, register, list, summarize, renderList, renderShow, head, gitignoreHas, gitignoreWith,
416
+ };
417
+
418
+ // --- Selection (docs/runtime-contracts.md, "Selection") ---------------------------
419
+ // The selected change of this worktree: .pincer/runtime/selection.json, written
420
+ // only by `change select` and migration. No file → SELECTION_REQUIRED even when a
421
+ // single record exists; a file naming a missing or unreadable record →
422
+ // SELECTION_INVALID; never a fallback to another record or the highest PRD.
423
+ const state = require('./state.cjs');
424
+ const SELECTION_FILE = `${state.RUNTIME_DIR}/selection.json`;
425
+ const SELECTION_KEYS = ['schema', 'change', 'selected'];
426
+ function readSelection(root) {
427
+ const file = path.join(root, SELECTION_FILE);
428
+ const read = readJson(file);
429
+ if (read.error === 'missing') return { code: 'SELECTION_REQUIRED', problem: 'no change is selected in this worktree; select one with: node scripts/pincer-runtime.cjs change select <id>' };
430
+ if (read.error) return { code: 'MALFORMED', problem: `${SELECTION_FILE}: ${read.error}; select again with: node scripts/pincer-runtime.cjs change select <id>` };
431
+ const doc = read.data;
432
+ if (!isObject(doc) || doc.schema !== 1 || Object.keys(doc).some(k => !SELECTION_KEYS.includes(k)) || !SELECTION_KEYS.every(k => k in doc) || !str(doc.change) || !CHANGE_ID.test(doc.change) || !timestamp(doc.selected)) {
433
+ return { code: 'MALFORMED', problem: `${SELECTION_FILE}: not a schema 1 selection { schema, change, selected }; select again with: node scripts/pincer-runtime.cjs change select <id>` };
434
+ }
435
+ return { change: doc.change, selected: doc.selected, file: SELECTION_FILE };
436
+ }
437
+ // Resolve the record commands act on: `--change <id>` inspects without touching
438
+ // the selection; otherwise the selection. Returns { record, file, id, loaded,
439
+ // selection, explicit } or { code, problem, loaded, selection }.
440
+ function resolveSelected(root, { change = null } = {}) {
441
+ const loaded = loadRecords(root);
442
+ const selection = readSelection(root);
443
+ const base = { loaded, selection: selection.code ? null : selection, selectionProblem: selection.code ? selection : null };
444
+ if (loaded.mode === 'legacy') return { ...base, code: 'CHANGE_REQUIRED', problem: `no change record under ${CHANGES_DIR}/ — register with: node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md` };
445
+ if (loaded.mode === 'migrated') return { ...base, code: 'MIGRATION_REQUIRED', problem: `${CHANGES_DIR}/ holds a v0.5.0 binding; migrate it first: node scripts/pincer-runtime.cjs migrate --preview --prd <prd>` };
446
+ if (loaded.problems.length && !(change || !selection.code)) return { ...base, code: loaded.problems[0].code, problem: loaded.problems[0].detail };
447
+ const id = change || (selection.code ? null : selection.change);
448
+ if (!id) return { ...base, code: selection.code, problem: `${selection.problem} (retained: ${[...loaded.records.keys()].join(', ') || 'none'})` };
449
+ if (!CHANGE_ID.test(id)) return { ...base, code: 'INPUT_INVALID', problem: `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}` };
450
+ const entry = loaded.records.get(id);
451
+ if (!entry) {
452
+ const own = loaded.problems.find(p => p.detail.startsWith(`${recordFile(id)}:`) || p.detail.startsWith(`${CHANGES_DIR}/${id}/`));
453
+ const why = own ? `is unreadable (${own.code}: ${own.detail})` : `does not exist (retained: ${[...loaded.records.keys()].join(', ') || 'none'})`;
454
+ if (change) return { ...base, code: own ? own.code : 'INPUT_INVALID', problem: `change record ${recordFile(id)} ${why}` };
455
+ return { ...base, code: 'SELECTION_INVALID', problem: `the selected change "${id}" ${why}; select another with: node scripts/pincer-runtime.cjs change select <id>, or repair the record` };
456
+ }
457
+ if (loaded.problems.length) return { ...base, code: loaded.problems[0].code, problem: loaded.problems[0].detail };
458
+ return { ...base, ...entry, id, explicit: Boolean(change) };
459
+ }
460
+ // change select <id>: the local pointer only. Never touches HEAD, the index,
461
+ // tracked or untracked files; refuses an unknown or unreadable record.
462
+ function select(root, id) {
463
+ if (typeof id !== 'string' || !CHANGE_ID.test(id)) return { code: 'INPUT_INVALID', problem: `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}` };
464
+ try {
465
+ const out = transaction.run(root, { command: `change select ${id}` }, ctx => {
466
+ const loaded = loadRecords(root);
467
+ if (loaded.mode === 'legacy') ctx.refuse('CHANGE_REQUIRED', `no change record under ${CHANGES_DIR}/ — register with: node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md`);
468
+ if (loaded.mode === 'migrated') ctx.refuse('MIGRATION_REQUIRED', `${CHANGES_DIR}/ holds a v0.5.0 binding; migrate it first: node scripts/pincer-runtime.cjs migrate --preview --prd <prd>`);
469
+ if (loaded.problems.length) ctx.refuse(loaded.problems[0].code, loaded.problems[0].detail);
470
+ const entry = loaded.records.get(id);
471
+ if (!entry) ctx.refuse('INPUT_INVALID', `no change record ${recordFile(id)} (retained: ${[...loaded.records.keys()].join(', ') || 'none'})`);
472
+ const current = readSelection(root);
473
+ if (!current.code && current.change === id) return { action: 'unchanged', record: entry.record, file: entry.file, selected: current.selected };
474
+ const selected = ctx.now;
475
+ ctx.write(SELECTION_FILE, { schema: 1, change: id, selected });
476
+ return { action: 'selected', record: entry.record, file: entry.file, selected, previous: current.code ? null : current.change };
477
+ });
478
+ return out.result;
479
+ } catch (error) {
480
+ if (error.refusal) return { code: error.code, problem: error.message };
481
+ if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
482
+ throw error;
483
+ }
484
+ }
485
+
486
+ // --- Repository view ---------------------------------------------------------------------
487
+ // Compatibility of a record with the working tree: HEAD exists, the PRD validates,
488
+ // the recorded base is an ancestor of HEAD. The branch name is printed as a hint
489
+ // only. Returns { head, branch, base_is_ancestor, dirty, prdResult, problems }.
490
+ function view(root, record) {
491
+ const problems = [];
492
+ const headSha = head(root);
493
+ const branchRead = tryGit(root, ['symbolic-ref', '--short', '-q', 'HEAD']);
494
+ const branch = branchRead.error ? null : branchRead.out.trim() || null;
495
+ const dirtyRead = tryGit(root, ['status', '--porcelain', '--untracked-files=all']);
496
+ const dirty = dirtyRead.error ? [] : dirtyRead.out.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^"(.*)"$/, '$1'));
497
+ const prdResult = parse.validatePrd(root, record.prd);
498
+ let ancestor = false;
499
+ if (!headSha) problems.push({ code: 'BASE_MISMATCH', detail: 'no commit at HEAD (not a git repository with commits)' });
500
+ else {
501
+ ancestor = !tryGit(root, ['merge-base', '--is-ancestor', record.base, 'HEAD']).error;
502
+ if (!ancestor) problems.push({ code: 'BASE_MISMATCH', detail: `the recorded base ${record.base.slice(0, 7)} of change "${record.change}" is not an ancestor of HEAD ${headSha.slice(0, 7)}${branch ? ` (branch ${branch})` : ' (detached HEAD)'}${dirty.length ? `; dirty: ${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ` (+${dirty.length - 5})` : ''}` : ''} — check out the branch that carries the change; the branch name is a hint, not proof` });
503
+ }
504
+ if (!prdResult.ok) problems.push({ code: 'BASE_MISMATCH', detail: `${record.prd}: ${prdResult.problems[0]} — the change's PRD is missing or invalid in this working tree` });
505
+ return { head: headSha, branch, base_is_ancestor: ancestor, dirty, prdResult, problems };
506
+ }
507
+ // The change a ticket belongs to, through its PRD association. Returns
508
+ // { id, prd } or { problem } (no owner, or an unresolved association).
509
+ function ticketOwner(root, loaded, file, fields) {
510
+ const assoc = require('./status.cjs').ticketPrd(root, file, fields);
511
+ if (assoc.problem) return { problem: assoc.problem };
512
+ const owner = ownerOf(loaded, assoc.prd);
513
+ if (!owner) return { prd: assoc.prd, problem: `${file} belongs to ${assoc.prd}, which no change record owns — register it with: node scripts/pincer-runtime.cjs register --prd ${assoc.prd}` };
514
+ return { id: owner[0], prd: assoc.prd, prdResult: assoc.prdResult };
515
+ }
516
+
517
+ Object.assign(module.exports, { SELECTION_FILE, readSelection, resolveSelected, select, view, ticketOwner });
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ // PINCER runtime — declared candidate checks (docs/runtime-contracts.md, "Strict
3
+ // coverage" → "Declared candidate checks"). In a strict change `check C-NN` runs the
4
+ // map's declaration (command, timeout, cwd) and nothing else: a supplied command or
5
+ // timeout, an undeclared ID and a review obligation are CHECK_UNDECLARED before
6
+ // anything is prepared, and the declaration is recomputed under the attempt lock.
7
+ // Review obligations are recorded in the evaluation draft with a candidate-bound
8
+ // artifact and an explicit result; a required one that is missing, unverified,
9
+ // failed or artifact-less is REVIEW_MISSING for export and release. Nothing here
10
+ // launches, writes or judges adequacy.
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const coverage = require('./coverage.cjs');
14
+ const evidence = require('./evidence.cjs');
15
+
16
+ // The declaration of a check for a strict binding. Returns { check, digest, commands,
17
+ // timeout, cwd, file } or { code, problem }.
18
+ function declaration(root, binding, checkId) {
19
+ const m = coverage.readMap(root, { change: binding.change, prd: binding.prd });
20
+ if (!m.ok) return { code: m.code, problem: m.problems[0] };
21
+ const c = m.map.checks[checkId];
22
+ if (!c) return { code: 'CHECK_UNDECLARED', problem: `${checkId} is not declared in ${m.file} (declared: ${Object.keys(m.map.checks).sort().join(', ') || 'none'}); declare it there and authorize the agreement` };
23
+ if (c.kind !== 'command') return { code: 'CHECK_UNDECLARED', problem: `${checkId} is a ${c.kind} obligation ("${c.obligation}"); it is recorded in the evaluation draft with its candidate-bound artifact and result, never run as a command` };
24
+ return { check: c, digest: coverage.definitionDigest(c), commands: c.command.split('\n'), timeout: c.timeout, cwd: c.cwd, file: m.file };
25
+ }
26
+
27
+ // Problems of the review obligations for a candidate: `drafts` maps check IDs to the
28
+ // draft entries ({ result, artifacts, ... }); `dirRel` is the candidate's evidence
29
+ // directory. Returns [{ code: 'REVIEW_MISSING', detail, ids }] for required
30
+ // obligations that are not passed with an existing candidate-bound artifact, plus
31
+ // { code: 'COVERAGE_INCOMPLETE' } for declared obligations absent from the draft.
32
+ function reviewProblems(map, drafts, { dirRel, root }) {
33
+ const problems = [];
34
+ for (const [id, c] of Object.entries(map.checks)) {
35
+ if (c.kind === 'command') continue;
36
+ const entry = drafts[id];
37
+ const label = `${id} (${c.required ? 'required' : 'optional'} ${c.kind} obligation: ${c.obligation})`;
38
+ if (!entry) { problems.push({ code: c.required ? 'REVIEW_MISSING' : 'COVERAGE_INCOMPLETE', detail: `${label} is not recorded in the evaluation draft`, ids: [id] }); continue; }
39
+ const artifacts = Array.isArray(entry.artifacts) ? entry.artifacts.filter(p => typeof p === 'string') : [];
40
+ const bound = artifacts.filter(p => !evidence.unsafePath(p) && p.startsWith(`${dirRel}/`) && (!root || fs.existsSync(path.join(root, p))));
41
+ if (!c.required) continue;
42
+ if (entry.result !== 'passed') { problems.push({ code: 'REVIEW_MISSING', detail: `${label} is ${entry.result || 'without a result'}; a required review must pass on this candidate`, ids: [id] }); continue; }
43
+ if (!bound.length) { problems.push({ code: 'REVIEW_MISSING', detail: `${label} has no candidate-bound artifact under ${dirRel}/ (${artifacts.length ? `listed: ${artifacts.join(', ')}` : 'none listed'})`, ids: [id] }); continue; }
44
+ }
45
+ return problems;
46
+ }
47
+
48
+ module.exports = { declaration, reviewProblems };