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,311 @@
1
+ 'use strict';
2
+ // PINCER runtime — ticket lifecycle (docs/runtime-contracts.md, "Modes"). The
3
+ // only writer of ticket lifecycle fields. Legacy mode reproduces the v0.4.1
4
+ // contract word for word (receipts in the ticket, `done` re-runs the check);
5
+ // migrated mode records attempts under .pincer/runtime/ and `done` consumes the
6
+ // current passing attempt without rewriting receipts.
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const { spawn } = require('node:child_process');
10
+ const parse = require('./parse.cjs');
11
+ const identity = require('./identity.cjs');
12
+ const source = require('./source.cjs');
13
+ const state = require('./state.cjs');
14
+ const readiness = require('./readiness.cjs');
15
+ const runner = require('./runner.cjs');
16
+ const { sanitizeText, inlineSecretLine } = require('./sanitize.cjs');
17
+ const { nowIso } = require('./fsutil.cjs');
18
+ const statusModule = require('./status.cjs');
19
+ const gates = require('./gates.cjs');
20
+
21
+ const EXIT = { OK: 0, FAILED: 1, INVALID: 4 };
22
+ class Refusal extends Error {
23
+ constructor(message, { prefix = 'pincer-ticket', exit = EXIT.FAILED, code } = {}) { super(message); this.prefix = prefix; this.exit = exit; this.reasonCode = code; }
24
+ }
25
+ const die = (message, options) => { throw new Refusal(message, options); };
26
+
27
+ // --- Frontmatter writes (the fm_set / fm_unset contract) --------------------
28
+ // Replace a field inside the frontmatter keeping an inline comment, or add it
29
+ // before the closing ---. Values are written as `key: value`.
30
+ function fmSet(text, key, value) {
31
+ const rows = parse.lines(text);
32
+ let done = false, closed = false;
33
+ const out = rows.map((line, i) => {
34
+ if (i === 0 || closed) return line;
35
+ if (!done && line.startsWith(`${key}:`)) {
36
+ done = true;
37
+ const hash = line.indexOf('#');
38
+ return hash === -1 ? `${key}: ${value}` : `${key}: ${value} ${line.slice(hash)}`;
39
+ }
40
+ if (line === '---') { closed = true; return done ? line : `${key}: ${value}\n---`; }
41
+ return line;
42
+ });
43
+ if (!done && !closed) out.push(`${key}: ${value}`);
44
+ return `${out.join('\n')}\n`;
45
+ }
46
+ function fmUnset(text, key) {
47
+ const rows = parse.lines(text);
48
+ let closed = false;
49
+ const out = rows.filter((line, i) => {
50
+ if (i === 0) return true;
51
+ if (closed) return true;
52
+ if (line === '---') { closed = true; return true; }
53
+ return !line.startsWith(`${key}:`);
54
+ });
55
+ return `${out.join('\n')}\n`;
56
+ }
57
+ const readTicket = (root, file) => fs.readFileSync(path.join(root, file), 'utf8');
58
+ const writeTicket = (root, file, text) => fs.writeFileSync(path.join(root, file), text);
59
+
60
+ // --- Resolution --------------------------------------------------------------
61
+ function resolve(root, input) {
62
+ const set = parse.validateTicketSet(root);
63
+ if (!set.ok) die(set.problems.map(p => (set.file ? `${set.file}: ${p}` : p)).join('\n'), { exit: EXIT.INVALID, code: 'INPUT_INVALID' });
64
+ const tf = parse.ticketFile(root, input);
65
+ if (tf.problem) die(tf.problem, { exit: EXIT.INVALID, code: 'INPUT_INVALID' });
66
+ return load(root, tf.file, tf.id);
67
+ }
68
+ function load(root, file, id) {
69
+ const text = readTicket(root, file);
70
+ const v = parse.validateTicket(file, text);
71
+ if (!v.ok) die(v.problems.map(p => `${file}: ${p}`).join('\n'), { exit: EXIT.INVALID, code: 'INPUT_INVALID' });
72
+ return { id: id || v.fields.ticket, file, text, fields: v.fields, timeout: v.timeout };
73
+ }
74
+ // usable_ticket_prd: association plus a ticketed/built PRD.
75
+ function usablePrd(root, t) {
76
+ const assoc = statusModule.ticketPrd(root, t.file, t.fields);
77
+ if (assoc.problem) die(assoc.problem, { prefix: 'pincer' });
78
+ if (!statusModule.usablePrd(assoc.prdResult)) die(`${assoc.prd}: PRD is draft; complete the authorized breakdown before starting (expected ticketed or built)`, { prefix: 'pincer' });
79
+ return assoc;
80
+ }
81
+ // The mode of the ticket's PRD. In changes mode the command passes the shared
82
+ // guard (docs/runtime-contracts.md, "Command gates") before anything is written
83
+ // or launched; the guard's binding carries the change, current agreement and
84
+ // legacy receipts for readiness, attempts and closure.
85
+ const gateExit = code => (code === 'STATE_BUSY' ? 3 : ['INPUT_INVALID', 'INVENTORY_INVALID', 'COVERAGE_INVALID', 'MALFORMED', 'UNSUPPORTED_SCHEMA', 'HISTORY_INVALID', 'STATE_INCOMPLETE'].includes(code) ? EXIT.INVALID : EXIT.FAILED);
86
+ function modeFor(root, prd, { command, ticket } = {}) {
87
+ const bind = identity.loadBinding(root, { prd });
88
+ if (bind.binding && !bind.code) return { mode: 'migrated', binding: bind.binding };
89
+ if (bind.code === 'CHANGE_REQUIRED') return { mode: 'legacy' };
90
+ if (bind.code === 'CHANGES_MODE') {
91
+ try {
92
+ const g = gates.guard(root, { command, ticket: ticket ? { file: ticket.file, fields: ticket.fields } : null, prd });
93
+ return { mode: 'changes', binding: g.binding, guard: g };
94
+ } catch (error) {
95
+ if (error && error.refusal) die(`${error.code}: ${error.message}`, { prefix: 'pincer', exit: gateExit(error.code), code: error.code });
96
+ throw error;
97
+ }
98
+ }
99
+ die(`${bind.code}: ${bind.problem}`, { prefix: 'pincer', exit: EXIT.INVALID, code: bind.code });
100
+ return null;
101
+ }
102
+
103
+ // --- Readiness of a dependency or of the ticket itself ------------------------
104
+ function currentInputs(root, binding) {
105
+ const manifest = source.snapshot(root);
106
+ const indexRead = state.exists(root) ? state.readIndex(root) : { index: null };
107
+ if (indexRead.error) die(indexRead.error, { prefix: 'pincer', exit: EXIT.INVALID, code: 'INPUT_INVALID' });
108
+ return { manifest, index: indexRead.index, current: { prdRevision: binding.prd_revision, sourceDigest: manifest.digest } };
109
+ }
110
+ function migratedReadiness(root, t, binding, inputs) {
111
+ const key = state.contextKey({ kind: 'ticket', change: binding.change, ticket: t.id });
112
+ const attempt = inputs.index ? state.inspectArtifacts(root, state.latestAttempt(root, key, inputs.index)) : null;
113
+ let changedPaths = [];
114
+ if (attempt && attempt.source && inputs.manifest.digest && attempt.source.after !== inputs.manifest.digest) {
115
+ const before = source.readManifest(root, attempt.source.after);
116
+ if (before) changedPaths = source.diffManifests(before, inputs.manifest);
117
+ }
118
+ const legacyReceipt = (binding.legacy_receipts && binding.legacy_receipts[t.id]) || (t.fields.verified || t.fields.last_check ? { verified: t.fields.verified, last_check: t.fields.last_check } : null);
119
+ const r = readiness.migratedTicketReadiness({ text: t.text, fields: t.fields, timeout: t.timeout, attempt, legacyReceipt, current: inputs.current, sourceProblems: inputs.manifest.problems, changedPaths, contextKey: key, pointedId: inputs.index ? inputs.index.current[key] || null : null, mode: binding.mode || 'migrated', strict: Boolean(binding.strict) });
120
+ r.attempt = attempt;
121
+ return r;
122
+ }
123
+ function ticketReady(root, t, mode, binding, inputs) {
124
+ if (mode === 'legacy') { const r = readiness.legacyTicketReadiness(t.text, t.fields); return r.ready ? null : r.legacyMessage; }
125
+ const r = migratedReadiness(root, t, binding, inputs);
126
+ return r.ready ? null : `${r.reasons[0].code}: ${r.reasons[0].detail} — ${r.reasons[0].next}`;
127
+ }
128
+
129
+ // --- Commands ----------------------------------------------------------------
130
+ function bind(root, input, ref) {
131
+ const t = resolve(root, input);
132
+ const v = parse.validatePrd(root, ref);
133
+ if (!v.ok) die(`${v.file ? `${v.file}: ` : ''}${v.problems[0]}`, { prefix: 'pincer' });
134
+ const existing = t.fields.prd || '';
135
+ if (existing && existing !== ref) die(`${t.id} already references ${existing}; refusing to rebind it to ${ref}`);
136
+ if (existing !== ref) writeTicket(root, t.file, fmSet(t.text, 'prd', ref));
137
+ return { out: `${t.id} bound to PRD ${ref}\n` };
138
+ }
139
+
140
+ function start(root, input, { quiet = false } = {}) {
141
+ const t = resolve(root, input);
142
+ const assoc = usablePrd(root, t);
143
+ const { mode, binding } = modeFor(root, assoc.prd, { command: 'start', ticket: t });
144
+ const st = t.fields.status;
145
+ if (st === 'in_progress') {
146
+ if (!t.fields.prd) writeTicket(root, t.file, fmSet(t.text, 'prd', assoc.prd));
147
+ return { out: quiet ? '' : `${t.id} already in progress (started ${t.fields.started || ''})\n`, mode, binding };
148
+ }
149
+ if (st === 'done') die(`${t.id} is already done`);
150
+ const inputs = mode !== 'legacy' ? currentInputs(root, binding) : null;
151
+ for (const dep of parse.dependencies(t.fields)) {
152
+ const df = parse.ticketFile(root, dep);
153
+ if (df.problem) die(df.problem);
154
+ const d = load(root, df.file, dep);
155
+ if (d.fields.status !== 'done') die(`${t.id} depends on ${dep}, which is '${d.fields.status}' — finish ${dep} first (or fix depends_on in ${t.file})`);
156
+ const depAssoc = usablePrd(root, d);
157
+ if (depAssoc.prd !== assoc.prd) die(`${t.id} references ${assoc.prd} but dependency ${dep} references ${depAssoc.prd}`);
158
+ const problem = ticketReady(root, d, mode, binding, inputs);
159
+ if (problem) die(`${t.id} depends on ${dep}: ${problem}`);
160
+ }
161
+ let text = t.text;
162
+ if (!t.fields.prd) text = fmSet(text, 'prd', assoc.prd);
163
+ text = fmSet(text, 'status', 'in_progress');
164
+ if (!t.fields.started) text = fmSet(text, 'started', nowIso());
165
+ writeTicket(root, t.file, text);
166
+ const started = parse.frontmatterField(text, 'started');
167
+ return { out: `▶ ${t.id} started ${started} — ${t.file}\n`, mode, binding };
168
+ }
169
+
170
+ // Legacy execution: the block runs with inherited stdio in its own process group;
171
+ // SIGINT/SIGTERM to the runtime terminate it and are reported as interrupted.
172
+ function executeLegacy(root, block) {
173
+ return new Promise(resolve => {
174
+ const child = spawn(runner.runnerInfo().shell, ['-eo', 'pipefail', '-c', block], { cwd: root, detached: true, stdio: 'inherit', env: process.env });
175
+ let interrupted = null;
176
+ const onSignal = signal => { interrupted = signal; try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* gone */ } } setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, runner.GRACE_MS).unref(); };
177
+ process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal);
178
+ child.on('error', error => { process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); resolve({ code: 127, signal: null, interrupted, error: error.message }); });
179
+ child.on('exit', (code, signal) => { process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); resolve({ code, signal, interrupted }); });
180
+ });
181
+ }
182
+
183
+ async function verify(root, input, { write = process.stdout, error = process.stderr } = {}) {
184
+ const t0 = resolve(root, input);
185
+ const assoc = usablePrd(root, t0);
186
+ const { mode, binding } = modeFor(root, assoc.prd, { command: 'verify', ticket: t0 });
187
+ if (t0.fields.status === 'open') {
188
+ // A verify on an open ticket starts it; in changes mode that needs an active change.
189
+ if (mode === 'changes') modeFor(root, assoc.prd, { command: 'start', ticket: t0 });
190
+ const s = start(root, t0.id); write.write(s.out);
191
+ }
192
+ const t = load(root, t0.file, t0.id);
193
+ if (mode === 'legacy') return verifyLegacy(root, t, write, error);
194
+ return verifyMigrated(root, t, binding, write, error);
195
+ }
196
+
197
+ async function verifyLegacy(root, t, write, error) {
198
+ const id = t.id, file = t.file;
199
+ if (t.fields.status === 'done') write.write(`${id} is done — re-running its check and updating the latest outcome\n`);
200
+ const commands = parse.verificationCommands(t.text);
201
+ const block = parse.blockText(t.text);
202
+ let text = fmUnset(t.text, 'verified');
203
+ const hash = parse.legacyBlockHash(t.text);
204
+ text = fmSet(text, 'last_check', `${nowIso()} running ${hash}`);
205
+ writeTicket(root, file, text);
206
+ if (!commands.some(c => !/^[ \t]*(#.*)?$/.test(c))) die(`no runnable command in the Verification block of ${file}`);
207
+ write.write(`── ${id} verification ──\n`);
208
+ for (const c of commands) write.write(` $ ${c}\n`);
209
+ const result = await executeLegacy(root, block);
210
+ const stamp = outcome => writeTicket(root, file, fmSet(readTicket(root, file), 'last_check', `${nowIso()} ${outcome} ${hash}`));
211
+ if (result.interrupted) { stamp('interrupted'); return { exit: 130 }; }
212
+ const rc = result.code === null ? 1 : result.code;
213
+ if (rc !== 0) {
214
+ stamp('failed');
215
+ error.write(`✗ ${id} verification FAILED (exit ${rc}) — failure recorded in last_check of ${file}; any prior successful receipt was revoked. Fix, then re-run verify.\n`);
216
+ return { exit: rc };
217
+ }
218
+ const after = readTicket(root, file);
219
+ if (parse.legacyBlockHash(after) !== hash) { stamp('failed'); die('Verification block changed during execution — re-run verify'); }
220
+ const v = parse.validateTicket(file, after);
221
+ let usable = v.ok;
222
+ if (usable) { try { usablePrd(root, { file, fields: v.fields }); } catch { usable = false; } }
223
+ if (!usable) { stamp('failed'); die('ticket became invalid during verification — fix it and re-run verify'); }
224
+ let done = fmSet(after, 'last_check', `${nowIso()} passed ${hash}`);
225
+ done = fmSet(done, 'verified', `${nowIso()} ${hash}`);
226
+ writeTicket(root, file, done);
227
+ write.write(`✓ ${id} verified — receipt: ${parse.frontmatterField(done, 'verified')}\n`);
228
+ return { exit: 0 };
229
+ }
230
+
231
+ async function verifyMigrated(root, t, binding, write, error) {
232
+ const id = t.id;
233
+ if (t.fields.status === 'done') write.write(`${id} is done — re-running its check and recording the latest outcome\n`);
234
+ const commands = parse.verificationCommands(t.text);
235
+ const secretLine = inlineSecretLine(commands);
236
+ if (secretLine) die(`${t.file}: Verification block line ${secretLine} assigns a secret-like literal; reference it from the environment instead (the block is recorded as display text)`, { exit: EXIT.INVALID, code: 'INPUT_INVALID' });
237
+ const announce = () => { write.write(`── ${id} verification ──\n`); for (const c of commands) write.write(` $ ${sanitizeText(c).text}\n`); };
238
+ const contextFor = b => ({ kind: 'ticket', change: b.change, prd: b.prd, prd_revision: b.prd_revision, base: b.base, ticket: id, ticket_digest: parse.ticketDigest(t.text), ...(b.agreement ? { agreement: b.agreement } : {}), ...(b.strict ? { inventory: b.inventory, coverage: b.coverage } : {}) });
239
+ // In changes mode the guard runs again under the runner's lock (docs/runtime-contracts.md,
240
+ // "Command gates"): a transition, revision or authorization committed since the
241
+ // pre-launch evaluation refuses the attempt or is the agreement it records.
242
+ const revalidate = binding.mode === 'changes' ? () => contextFor(gates.guard(root, { command: 'verify', ticket: { file: t.file, fields: t.fields } }).binding) : null;
243
+ const result = await runner.runAttempt({ root, context: contextFor(binding), commands, timeoutSeconds: t.timeout, command: `verify ${id}`, revalidate, announce });
244
+ if (result.code) die(`${result.code}: ${result.problem}`, { prefix: 'pincer', exit: result.refused ? gateExit(result.code) : result.code === 'STATE_BUSY' ? 3 : EXIT.INVALID, code: result.code });
245
+ const a = result.attempt;
246
+ const logs = `${state.RUNTIME_DIR}/attempts/${a.id}/`;
247
+ if (a.outcome === 'passed') {
248
+ write.write(`✓ ${id} verified — attempt ${a.id} passed (source ${a.source.after.slice(0, 12)}, logs ${logs})\n`);
249
+ return { exit: 0, attempt: a };
250
+ }
251
+ const why = a.outcome === 'failed' ? `FAILED (exit ${a.exit_code ?? a.signal})` : a.outcome === 'timed_out' ? `TIMED OUT after ${a.check.timeout_seconds} s` : a.outcome === 'interrupted' ? 'INTERRUPTED' : `ERROR: ${a.error}`;
252
+ error.write(`✗ ${id} verification ${why} — recorded as attempt ${a.id} (logs ${logs}); any prior passing attempt is superseded. Fix, then re-run verify.\n`);
253
+ return { exit: runner.exitFor(a), attempt: a };
254
+ }
255
+
256
+ const slugWords = file => path.basename(file, '.md').replace(/^T-[0-9]+-/, '').replace(/-/g, ' ');
257
+ const closeHint = (id, file) => `✓ ${id} done. Inspect staged work, stage only this ticket's paths, review git diff --cached, then commit: ${id}: ${slugWords(file)}\n`;
258
+
259
+ async function done(root, input, io = {}) {
260
+ const write = io.write || process.stdout, error = io.error || process.stderr;
261
+ const t = resolve(root, input);
262
+ const assoc = usablePrd(root, t);
263
+ const { mode, binding } = modeFor(root, assoc.prd, { command: 'done', ticket: t });
264
+ const id = t.id, st = t.fields.status;
265
+ if (st !== 'in_progress' && st !== 'done') die(`${id} is '${st}' — run 'scripts/pincer-ticket.sh verify ${id}' first`);
266
+ if (mode === 'legacy') return doneLegacy(root, t, write, error);
267
+ return doneMigrated(root, t, binding, write);
268
+ }
269
+
270
+ async function doneLegacy(root, t, write, error) {
271
+ const id = t.id, file = t.file, st = t.fields.status;
272
+ const rec = t.fields.verified || '';
273
+ if (!rec) die(`no verification receipt on ${id} — run 'scripts/pincer-ticket.sh verify ${id}' and get a green check first`);
274
+ const cur = parse.legacyBlockHash(t.text);
275
+ const recHash = rec.split(/\s+/).pop();
276
+ if (recHash !== cur) die(`receipt hash ${recHash} does not match the current Verification block (${cur}): the check changed after it passed — run 'scripts/pincer-ticket.sh verify ${id}' again`);
277
+ const u = parse.unticked(t.text);
278
+ if (u.length) die(`unticked acceptance criteria on ${id}:\n${u.join('\n')}\nTick each verified criterion; a criterion that was cut is a scope change to record in the PRD, not a box to skip.`);
279
+ const result = await verifyLegacy(root, t, write, error);
280
+ if (result.exit !== 0) return result;
281
+ const after = load(root, file, id);
282
+ const u2 = parse.unticked(after.text);
283
+ if (u2.length) die(`unticked acceptance criteria on ${id} after verification:\n${u2.join('\n')}`);
284
+ if (st === 'done') { write.write(`${id} already done — current check passed\n`); return { exit: 0 }; }
285
+ let text = fmSet(after.text, 'status', 'done');
286
+ text = fmSet(text, 'finished', nowIso());
287
+ writeTicket(root, file, text);
288
+ write.write(closeHint(id, file));
289
+ return { exit: 0 };
290
+ }
291
+
292
+ // Migrated closure consumes the latest passing attempt against current inputs
293
+ // and checked criteria; it never launches the check and writes the ticket once.
294
+ async function doneMigrated(root, t, binding, write) {
295
+ const id = t.id, file = t.file, st = t.fields.status;
296
+ const inputs = currentInputs(root, binding);
297
+ const r = migratedReadiness(root, t, binding, inputs);
298
+ if (!r.ready) {
299
+ const first = r.reasons[0];
300
+ const lines = r.reasons.map(x => `${x.code}: ${x.detail}`);
301
+ die(`${id} cannot close — ${lines.join('; ')}\nnext: ${first.next}`, { code: first.code });
302
+ }
303
+ if (st === 'done') { write.write(`${id} already done — current attempt ${r.attempt.id} passed\n`); return { exit: 0 }; }
304
+ let text = fmSet(t.text, 'status', 'done');
305
+ text = fmSet(text, 'finished', nowIso());
306
+ writeTicket(root, file, text);
307
+ write.write(closeHint(id, file));
308
+ return { exit: 0 };
309
+ }
310
+
311
+ module.exports = { Refusal, fmSet, fmUnset, bind, start, verify, done, migratedReadiness, currentInputs };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+ // PINCER runtime — evaluation locators (docs/runtime-contracts.md, "Evaluation
3
+ // locator"). In changes mode the evaluations of a change are located by
4
+ // `.prd/evidence/changes/<change-id>.json`, tracked in git, appended only by
5
+ // `evidence export` through a transaction. It lives under the fixed source
6
+ // exclusion, is listed in no manifest (no digest refers to itself), and is one
7
+ // of the paths allowed to follow the candidate. Root NOTES.md stays a human
8
+ // summary of whichever change was evaluated last; the locator is the identity.
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const parse = require('./parse.cjs');
12
+ const evidence = require('./evidence.cjs');
13
+ const transaction = require('./transaction.cjs');
14
+ const { readJson, tryGit } = require('./fsutil.cjs');
15
+
16
+ const SCHEMA = 1;
17
+ const DIR = '.prd/evidence/changes';
18
+ const KEYS = ['schema', 'change', 'evaluations'];
19
+ const ENTRY_KEYS = ['candidate', 'base', 'prd', 'prd_revision', 'agreement', 'manifest', 'recorded'];
20
+ const SHA256 = /^[0-9a-f]{64}$/;
21
+ const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
22
+ const file = id => `${DIR}/${id}.json`;
23
+
24
+ function validate(doc, id) {
25
+ const bad = p => ({ code: 'MALFORMED', problem: `${file(id)}: ${p}` });
26
+ if (!isObject(doc)) return bad('locator must be a JSON object');
27
+ if (doc.schema !== SCHEMA) return { code: 'UNSUPPORTED_SCHEMA', problem: `${file(id)}: unsupported evaluation locator schema ${JSON.stringify(doc.schema)}` };
28
+ for (const k of Object.keys(doc)) if (!KEYS.includes(k)) return bad(`unknown key "${k}"`);
29
+ for (const k of KEYS) if (!(k in doc)) return bad(`missing key "${k}"`);
30
+ if (doc.change !== id) return bad(`locator names change "${doc.change}", not ${id}`);
31
+ if (!Array.isArray(doc.evaluations)) return bad('evaluations must be an array');
32
+ for (const [i, e] of doc.evaluations.entries()) {
33
+ if (!isObject(e)) return bad(`evaluations[${i}] must be an object`);
34
+ for (const k of Object.keys(e)) if (!ENTRY_KEYS.includes(k)) return bad(`evaluations[${i}].${k} is not allowed`);
35
+ for (const k of ENTRY_KEYS) if (!(k in e)) return bad(`evaluations[${i}].${k} is missing`);
36
+ if (!parse.HEX40.test(e.candidate) || !parse.HEX40.test(e.base)) return bad(`evaluations[${i}]: candidate and base must be full 40-hex commit IDs`);
37
+ if (!parse.PRD_REF.test(e.prd)) return bad(`evaluations[${i}]: prd must be .prd/prd-vN.md`);
38
+ if (!SHA256.test(e.prd_revision) || !SHA256.test(e.agreement)) return bad(`evaluations[${i}]: prd_revision and agreement must be 64-hex digests`);
39
+ if (e.manifest !== `.prd/evidence/prd-v${e.prd.match(parse.PRD_REF)[1]}/${e.candidate}/manifest.json`) return bad(`evaluations[${i}]: manifest must be .prd/evidence/prd-v<N>/${e.candidate}/manifest.json for ${e.prd}`);
40
+ if (!parse.TIMESTAMP.test(e.recorded)) return bad(`evaluations[${i}]: recorded must be an ISO UTC timestamp`);
41
+ }
42
+ return null;
43
+ }
44
+ // { locator } (possibly empty), or { code, problem }. A missing file is an empty locator.
45
+ function read(root, id) {
46
+ const r = readJson(path.join(root, file(id)));
47
+ if (r.error === 'missing') return { locator: { schema: SCHEMA, change: id, evaluations: [] }, missing: true };
48
+ if (r.error) return { code: 'MALFORMED', problem: `${file(id)}: ${r.error}` };
49
+ const invalid = validate(r.data, id);
50
+ if (invalid) return invalid;
51
+ return { locator: r.data };
52
+ }
53
+ const latest = locator => (locator.evaluations.length ? locator.evaluations[locator.evaluations.length - 1] : null);
54
+
55
+ // Append an evaluation reference (after the manifest was written and validated).
56
+ function append(root, id, entry) {
57
+ try {
58
+ const out = transaction.run(root, { command: `evidence export ${id}` }, ctx => {
59
+ const r = read(root, id);
60
+ if (r.code) ctx.refuse(r.code, r.problem);
61
+ const doc = r.locator;
62
+ const same = doc.evaluations.find(e => e.candidate === entry.candidate && e.manifest === entry.manifest && e.agreement === entry.agreement && e.prd_revision === entry.prd_revision && e.base === entry.base);
63
+ if (same) return { action: 'unchanged', entry: same };
64
+ doc.evaluations.push(entry);
65
+ const invalid = validate(doc, id);
66
+ if (invalid) ctx.refuse(invalid.code, invalid.problem);
67
+ ctx.write(file(id), doc);
68
+ return { action: 'recorded', entry };
69
+ });
70
+ return out.result;
71
+ } catch (error) {
72
+ if (error.refusal) return { code: error.code, problem: error.message };
73
+ if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ // The paths that may differ from `candidate` (docs/runtime-contracts.md,
79
+ // "Evaluation locator"): NOTES.md, every locator under .prd/evidence/changes/ that
80
+ // parses and validates for its own id, and, for each of their entries naming this
81
+ // candidate whose manifest validates with file digests for its candidate, base and
82
+ // PRD, that manifest and the files it lists. The set is computed from validated
83
+ // content, never from a directory or filename pattern: an unlisted file inside an
84
+ // evidence directory, an altered listed artifact and an unreadable or misnamed
85
+ // locator are candidate changes. Returns a Set of repository-relative paths.
86
+ function followers(root, candidate) {
87
+ const allowed = new Set(['NOTES.md']);
88
+ let names = [];
89
+ try { names = fs.readdirSync(path.join(root, DIR)).filter(n => n.endsWith('.json')); } catch { names = []; }
90
+ for (const name of names.sort()) {
91
+ const id = name.slice(0, -'.json'.length);
92
+ const r = read(root, id);
93
+ if (r.code || r.missing) continue;
94
+ allowed.add(file(id));
95
+ for (const e of r.locator.evaluations) {
96
+ if (e.candidate !== candidate) continue;
97
+ const opts = { files: true, candidate: e.candidate, base: e.base, prd: e.prd };
98
+ const problems = evidence.validate(path.resolve(root, e.manifest), opts, root);
99
+ if (problems.length || !Array.isArray(opts.list)) continue;
100
+ for (const listed of opts.list) allowed.add(listed);
101
+ }
102
+ }
103
+ return allowed;
104
+ }
105
+
106
+ // The candidate of a change from its locator, in the shape status reports for
107
+ // NOTES.md: { text, state: 'current' | 'stale' | 'missing', candidate, base,
108
+ // manifest, entry }. Current means: the latest entry's manifest validates for its
109
+ // candidate, base and PRD, the candidate is an ancestor of HEAD, and the diff
110
+ // from the candidate to HEAD plus the dirty tree contain nothing but the
111
+ // candidate's followers (NOTES.md, valid locators, validated listed artifacts).
112
+ function current(root, record) {
113
+ const r = read(root, record.change);
114
+ if (r.code) return { text: `stale: ${r.problem}`, state: 'stale', problem: r };
115
+ const entry = latest(r.locator);
116
+ if (!entry) return { text: `missing (no evaluation recorded in ${file(record.change)})`, state: 'missing' };
117
+ const base = { candidate: entry.candidate, base: entry.base, manifest: entry.manifest, entry, fields: { prd: entry.prd, candidate: entry.candidate, base: entry.base, evidence: entry.manifest } };
118
+ if (entry.prd !== record.prd) return { ...base, text: `stale: the evaluation is for ${entry.prd}, not ${record.prd}`, state: 'stale' };
119
+ const ok = args => !tryGit(root, args).error;
120
+ if (!ok(['rev-parse', '--verify', `${entry.candidate}^{commit}`]) || !ok(['rev-parse', '--verify', `${entry.base}^{commit}`]) ||
121
+ !ok(['merge-base', '--is-ancestor', entry.base, entry.candidate]) || !ok(['merge-base', '--is-ancestor', entry.candidate, 'HEAD'])) {
122
+ return { ...base, text: 'stale: evaluation commits or ancestry unavailable', state: 'stale' };
123
+ }
124
+ const opts = { files: true, candidate: entry.candidate, base: entry.base, prd: entry.prd };
125
+ const problems = evidence.validate(path.resolve(root, entry.manifest), opts, root);
126
+ if (problems.length) return { ...base, text: `stale: evidence invalid: ${problems[0]}`, state: 'stale' };
127
+ let manifestDoc = null;
128
+ try { manifestDoc = JSON.parse(fs.readFileSync(path.resolve(root, entry.manifest), 'utf8')); } catch { manifestDoc = null; }
129
+ if (!manifestDoc || !manifestDoc.change || manifestDoc.change.id !== record.change) return { ...base, text: `stale: ${entry.manifest} records change ${manifestDoc && manifestDoc.change ? manifestDoc.change.id : 'none'}, not ${record.change}`, state: 'stale' };
130
+ for (const f of opts.list) {
131
+ const tracked = tryGit(root, ['ls-files', '--error-unmatch', '--', f]);
132
+ if (tracked.error || !tracked.out.trim()) return { ...base, text: `stale: evidence not tracked: ${f}`, state: 'stale' };
133
+ }
134
+ const set = followers(root, entry.candidate);
135
+ const allowed = p => set.has(p);
136
+ const diff = tryGit(root, ['diff', '--name-only', '--relative', entry.candidate, 'HEAD']);
137
+ if (diff.error) return { ...base, text: 'stale: evaluation commits or ancestry unavailable', state: 'stale' };
138
+ const offending = diff.out.split('\n').filter(l => l && !allowed(l))[0];
139
+ if (offending) return { ...base, text: `stale: candidate changed after evaluation: ${offending}`, state: 'stale' };
140
+ const dirty = tryGit(root, ['status', '--porcelain', '--untracked-files=all']);
141
+ if (dirty.error) return { ...base, text: 'stale: git status failed', state: 'stale' };
142
+ const dirtyPath = dirty.out.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^"(.*)"$/, '$1')).filter(p => !allowed(p))[0];
143
+ if (dirtyPath) return { ...base, text: `stale: working tree has changes outside the candidate's evidence: ${dirtyPath}`, state: 'stale' };
144
+ return { ...base, text: `current (${entry.candidate})`, state: 'current' };
145
+ }
146
+ // The Evidence line for the locator's latest manifest, independent of currency.
147
+ function evidenceLine(root, record) {
148
+ const r = read(root, record.change);
149
+ if (r.code) return { manifest: file(record.change), ok: false, reason: r.problem, schema: null };
150
+ const entry = latest(r.locator);
151
+ if (!entry) return null;
152
+ const problems = evidence.validate(path.resolve(root, entry.manifest), { candidate: entry.candidate, base: entry.base, prd: entry.prd }, root);
153
+ let schema = null;
154
+ try { schema = JSON.parse(fs.readFileSync(path.resolve(root, entry.manifest), 'utf8')).schema; } catch { schema = null; }
155
+ return { manifest: entry.manifest, ok: problems.length === 0, reason: problems[0] || null, schema };
156
+ }
157
+
158
+ module.exports = { SCHEMA, DIR, file, validate, read, latest, append, followers, current, evidenceLine };