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
@@ -2,7 +2,9 @@
2
2
  // PINCER runtime — status (docs/runtime-contracts.md, "Readiness and reason
3
3
  // codes"). Gathers the artifacts on disk, computes readiness once, and renders
4
4
  // the human report (line for line the v0.4.1 format plus a `Runtime` line) or
5
- // the status JSON. Read-only: never executes a check, never writes.
5
+ // the status JSON. Read-only: never executes a check, never writes. In changes
6
+ // mode (schema 2 records) the report covers the locally selected change, or the
7
+ // one named with --change, and never picks a change on its own.
6
8
  const fs = require('node:fs');
7
9
  const os = require('node:os');
8
10
  const path = require('node:path');
@@ -12,6 +14,11 @@ const source = require('./source.cjs');
12
14
  const state = require('./state.cjs');
13
15
  const readiness = require('./readiness.cjs');
14
16
  const evidence = require('./evidence.cjs');
17
+ const changes = require('./changes.cjs');
18
+ const transaction = require('./transaction.cjs');
19
+ const agreement = require('./agreement.cjs');
20
+ const authorization = require('./authorization.cjs');
21
+ const locator = require('./locator.cjs');
15
22
  const { tryGit } = require('./fsutil.cjs');
16
23
 
17
24
  const RUNTIME = 1;
@@ -108,24 +115,42 @@ function evidenceLine(root, prd) {
108
115
  }
109
116
 
110
117
  // --- Gather ------------------------------------------------------------------
111
- function gather(root, { budget } = {}) {
118
+ function gather(root, { budget, change = null } = {}) {
112
119
  const now = Math.floor(Date.now() / 1000);
113
120
  const out = { lines: [], warnings: [], exit: 0, json: { schema: 1, runtime: RUNTIME, generated: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), root, mode: 'legacy', change: null, prd: null, tickets: [], history: 0, candidate: null, reasons: [], next: null } };
114
121
  const line = s => out.lines.push(s);
115
122
  const j = out.json;
116
123
  line(`PINCER status · ${new Date().toISOString().replace(/:\d{2}\.\d{3}Z$/, 'Z')} · ${root}`);
117
124
 
125
+ // A committed-but-unapplied transaction (a migration or transition killed
126
+ // mid-rename) is reported in every mode and never interpreted or repaired here.
127
+ const pendingTxn = transaction.pending(root);
128
+ if (pendingTxn.committed.length) {
129
+ const t = pendingTxn.committed[0];
130
+ const detail = `a committed transaction (${t.command || t.id}) was not fully applied; run: node scripts/pincer-runtime.cjs recover`;
131
+ j.mode = changes.scan(root).mode;
132
+ if (j.mode === 'changes') { j.schema = 3; j.runtime = changes.RUNTIME_STRICT; j.selection = { change: null, problem: null }; j.changes = []; }
133
+ line(`WARN STATE_INCOMPLETE: ${detail}`);
134
+ j.reasons.push({ code: 'STATE_INCOMPLETE', detail });
135
+ j.next = 'node scripts/pincer-runtime.cjs recover';
136
+ line(`Next ${j.next}`);
137
+ out.exit = 4; return out;
138
+ }
118
139
  // Mode and selected PRD.
119
140
  const bindingResult = identity.loadBinding(root);
141
+ if (bindingResult.code === 'CHANGES_MODE') return gatherChanges(root, out, { budget, now, change });
142
+ if (change) { line(`WARN --change applies to change records only (this project is ${bindingResult.code ? 'not in changes mode' : 'migrated'})`); line('Next inspect with status without --change'); out.exit = 4; return out; }
120
143
  let mode = 'legacy', binding = null, prd = null, prdResult = null;
121
144
  if (bindingResult.binding && !bindingResult.code) {
122
145
  mode = 'migrated'; binding = bindingResult.binding; prd = binding.prd; prdResult = bindingResult.prd;
123
- } else if (bindingResult.code === 'REVISION_CHANGED' || bindingResult.code === 'INPUT_INVALID') {
146
+ } else if ((bindingResult.code === 'REVISION_CHANGED' || bindingResult.code === 'INPUT_INVALID') && bindingResult.binding) {
124
147
  mode = 'migrated'; binding = bindingResult.binding; prd = binding.prd;
125
148
  const v = parse.validatePrd(root, prd);
126
149
  if (!v.ok) { line(`WARN invalid PRD: ${v.file || prd}: ${v.problems[0]}`); line('Next repair PRD input before continuing'); out.exit = 4; return out; }
127
150
  prdResult = v;
128
- } else if (['MALFORMED', 'AMBIGUOUS', 'UNSUPPORTED_SCHEMA'].includes(bindingResult.code)) {
151
+ } else if (['MALFORMED', 'AMBIGUOUS', 'UNSUPPORTED_SCHEMA', 'INPUT_INVALID'].includes(bindingResult.code)) {
152
+ // Unreadable or mixed records: neither legacy nor migrated (never a fallback).
153
+ j.mode = 'invalid';
129
154
  line(`WARN invalid change binding: ${bindingResult.problem}`);
130
155
  line('Next repair .prd/changes/ before continuing (remove or restore the binding; see docs/runtime-contracts.md)');
131
156
  j.reasons.push({ code: 'INPUT_INVALID', detail: bindingResult.problem });
@@ -136,6 +161,7 @@ function gather(root, { budget } = {}) {
136
161
  prd = latest.prd; prdResult = latest.prdResult;
137
162
  }
138
163
  j.mode = mode;
164
+ j.coverage = { strict: false, label: 'unverified', reason: mode === 'legacy' ? 'legacy project (no change record); strict coverage needs change records' : 'v0.5.0 binding (migrated mode); migrate to change records, then adopt' };
139
165
  const prdStatus = prdResult ? prdResult.fields.status : '';
140
166
  if (!prd) line('PRD none');
141
167
  else {
@@ -152,7 +178,20 @@ function gather(root, { budget } = {}) {
152
178
  } else {
153
179
  line(`Runtime legacy · no change binding${prd ? ` · migrate with node scripts/pincer-runtime.cjs migrate --preview --prd ${prd}` : ''}`);
154
180
  }
181
+ const result = gatherBody(root, out, { mode, binding, prd, prdResult, bindingResult, budget, now });
182
+ // Old modes label their coverage unverified in the human report too.
183
+ const at = result.lines.findIndex(l => l.startsWith('Notes '));
184
+ if (at !== -1) result.lines.splice(at, 0, `Coverage unverified · ${j.coverage.reason}`);
185
+ return result;
186
+ }
155
187
 
188
+ // Tickets, candidate and next action for the selected PRD. `ctx.decideNext`, when
189
+ // given, replaces the default next-action rule (changes mode).
190
+ function gatherBody(root, out, ctx) {
191
+ const { mode, binding, prd, prdResult, bindingResult, budget, now } = ctx;
192
+ const line = s => out.lines.push(s);
193
+ const j = out.json;
194
+ const prdStatus = prdResult ? prdResult.fields.status : '';
156
195
  // Tickets: reject malformed input instead of guessing.
157
196
  const set = parse.validateTicketSet(root);
158
197
  if (!set.ok) {
@@ -177,12 +216,13 @@ function gather(root, { budget } = {}) {
177
216
 
178
217
  // Current inputs for migrated readiness (computed once; read-only).
179
218
  let current = {}, sourceProblems = [], manifestNow = null;
180
- if (mode === 'migrated') {
219
+ const runtimeMode = mode !== 'legacy';
220
+ if (runtimeMode) {
181
221
  manifestNow = source.snapshot(root);
182
222
  sourceProblems = manifestNow.problems;
183
223
  current = { prdRevision: binding.prd_revision, sourceDigest: manifestNow.digest };
184
224
  }
185
- const indexRead = mode === 'migrated' && state.exists(root) ? state.readIndex(root) : { index: null };
225
+ const indexRead = runtimeMode && state.exists(root) ? state.readIndex(root) : { index: null };
186
226
  if (indexRead.error) { line(`WARN invalid runtime state: ${indexRead.error}`); line('Next repair or remove .pincer/runtime (see docs/runtime-contracts.md); run recover for a diagnosis'); j.reasons.push({ code: 'INPUT_INVALID', detail: indexRead.error }); out.exit = 4; return out; }
187
227
  const byId = new Map(tickets.map(t => [t.fields.ticket, t]));
188
228
  const readinessOf = new Map();
@@ -199,7 +239,7 @@ function gather(root, { budget } = {}) {
199
239
  if (before) changedPaths = source.diffManifests(before, manifestNow);
200
240
  }
201
241
  const legacyReceipt = (binding.legacy_receipts && binding.legacy_receipts[t.fields.ticket]) || (t.fields.verified || t.fields.last_check ? { verified: t.fields.verified, last_check: t.fields.last_check } : null);
202
- r = readiness.migratedTicketReadiness({ text: t.text, fields: t.fields, timeout: t.timeout, attempt, legacyReceipt, current, sourceProblems, changedPaths, contextKey: key, pointedId: indexRead.index ? indexRead.index.current[key] || null : null });
242
+ r = readiness.migratedTicketReadiness({ text: t.text, fields: t.fields, timeout: t.timeout, attempt, legacyReceipt, current, sourceProblems, changedPaths, contextKey: key, pointedId: indexRead.index ? indexRead.index.current[key] || null : null, mode, strict: Boolean(binding && binding.strict) });
203
243
  r.attempt = attempt;
204
244
  }
205
245
  readinessOf.set(t.file, r);
@@ -209,7 +249,7 @@ function gather(root, { budget } = {}) {
209
249
  let nOpen = 0, nProg = 0, nDone = 0, firstStart = null, localMissing = 0;
210
250
  // Without local runtime state (a fresh clone) done tickets rely on the saved
211
251
  // candidate evidence; they are not re-verify work until verified here.
212
- const localUnavailable = mode === 'migrated' && !state.exists(root);
252
+ const localUnavailable = runtimeMode && !state.hasIndex(root);
213
253
  const inProg = [], reverify = [];
214
254
  let nextOpen = null;
215
255
  const rows = [];
@@ -223,7 +263,7 @@ function gather(root, { budget } = {}) {
223
263
  if (mode === 'legacy' && st !== 'done' && f.last_check && !/ passed /.test(` ${f.last_check} `)) {
224
264
  out.warnings.push(` WARN ${id} latest verification: ${f.last_check} — re-run verify`);
225
265
  }
226
- if (mode === 'migrated' && st !== 'done' && r.attempt && !r.ready) {
266
+ if (runtimeMode && st !== 'done' && r.attempt && !r.ready) {
227
267
  // Unticked criteria are expected while work is in progress; anything else
228
268
  // (a failed, stale or superseded attempt) is a warning here too.
229
269
  const blocking = r.reasons.find(x => x.code !== 'CRITERIA_UNTICKED');
@@ -274,18 +314,24 @@ function gather(root, { budget } = {}) {
274
314
  }
275
315
  }
276
316
 
277
- // Candidate.
278
- const notes = prd ? notesCurrent(root, prd) : { text: 'missing', state: 'missing' };
279
- line(`Notes NOTES.md: ${notes.text}`);
280
- const ev = evidenceLine(root, prd);
317
+ // Candidate: NOTES.md in legacy and migrated mode; the change's evaluation
318
+ // locator in changes mode (NOTES.md is then a compatibility summary).
319
+ const notes = ctx.notes ? ctx.notes() : prd ? notesCurrent(root, prd) : { text: 'missing', state: 'missing' };
320
+ if (ctx.notes) {
321
+ const compat = prd && fs.existsSync(path.join(root, 'NOTES.md')) ? notesCurrent(root, prd).text : 'missing';
322
+ line(`Notes NOTES.md: ${compat} (compatibility summary; the evaluation locator decides)`);
323
+ line(`Evaluation ${ctx.locatorFile}: ${notes.text}`);
324
+ } else line(`Notes NOTES.md: ${notes.text}`);
325
+ const ev = ctx.evidence ? ctx.evidence() : evidenceLine(root, prd);
281
326
  if (ev) line(`Evidence ${ev.manifest} · ${ev.ok ? 'ok' : ev.reason}`);
282
327
  j.candidate = {
283
328
  notes: notes.state, reason: notes.state === 'current' ? null : notes.text,
284
329
  candidate: notes.candidate || (notes.fields && notes.fields.candidate) || null, base: notes.base || (notes.fields && notes.fields.base) || null,
285
- evidence: ev ? { manifest: ev.manifest, schema: ev.schema, provenance: ev.schema === 2 ? 'runtime' : ev.schema === 1 ? 'legacy' : null, verdict: ev.ok ? 'ok' : ev.reason } : null,
286
- local_attempts: mode === 'migrated' ? (state.exists(root) ? 'available' : 'unavailable') : 'not applicable (legacy mode)',
330
+ evidence: ev ? { manifest: ev.manifest, schema: ev.schema, provenance: ev.schema >= 2 ? 'runtime' : ev.schema === 1 ? 'legacy' : null, verdict: ev.ok ? 'ok' : ev.reason } : null,
331
+ local_attempts: runtimeMode ? (state.hasIndex(root) ? 'available' : 'unavailable') : 'not applicable (legacy mode)',
287
332
  newer_attempts: [],
288
333
  reasons: notes.state === 'current' ? [] : [{ code: notes.state === 'missing' ? 'EVIDENCE_MISSING' : 'CANDIDATE_STALE', detail: notes.text }],
334
+ ...(ctx.notes ? { locator: ctx.locatorFile, evaluation: notes.entry ? { candidate: notes.entry.candidate, base: notes.entry.base, manifest: notes.entry.manifest, recorded: notes.entry.recorded, agreement: notes.entry.agreement } : null } : {}),
289
335
  };
290
336
  // Provenance and newer local attempts (contract "Evidence schema 2"): a newer
291
337
  // nonpassing attempt for the same check and candidate on the same source inputs
@@ -293,9 +339,9 @@ function gather(root, { budget } = {}) {
293
339
  // state only the saved record can be validated.
294
340
  const newerBlockers = [];
295
341
  if (ev && ev.ok) {
296
- if (ev.schema !== 2) line('Provenance legacy (schema 1, authored command results)');
297
- else if (!state.exists(root)) {
298
- line('Provenance runtime (schema 2) · local verification history unavailable; saved candidate evidence validated only');
342
+ if (ev.schema < 2) line('Provenance legacy (schema 1, authored command results)');
343
+ else if (!state.hasIndex(root)) {
344
+ line(`Provenance runtime (schema ${ev.schema}) · local verification history unavailable; saved candidate evidence validated only`);
299
345
  } else {
300
346
  let manifestDoc = null;
301
347
  try { manifestDoc = JSON.parse(fs.readFileSync(path.resolve(root, ev.manifest), 'utf8')); } catch { manifestDoc = null; }
@@ -307,7 +353,7 @@ function gather(root, { budget } = {}) {
307
353
  // Every attempt newer than the exported one counts: a same-source
308
354
  // nonpassing attempt blocks until the candidate is re-exported, even
309
355
  // when a later attempt passed again.
310
- const key = state.contextKey({ kind: 'candidate', candidate: cand, check: check.id });
356
+ const key = state.contextKey({ kind: 'candidate', change: binding.change, candidate: cand, check: check.id, mode });
311
357
  const newer = idx ? state.listAttempts(root, key).filter(a => a.sequence > check.attempt.sequence && a.outcome !== 'passed') : [];
312
358
  for (const latest of newer) {
313
359
  const sameSource = latest.source && latest.source.before === check.attempt.source_before;
@@ -319,7 +365,7 @@ function gather(root, { budget } = {}) {
319
365
  } else details.push(`${check.id} ${latest.outcome} (${latest.id}, different source: historical)`);
320
366
  }
321
367
  }
322
- line(`Provenance runtime (schema 2) · local attempts ${details.length ? details.join('; ') : 'consistent with the exported checks'}`);
368
+ line(`Provenance runtime (schema ${ev.schema}) · local attempts ${details.length ? details.join('; ') : 'consistent with the exported checks'}`);
323
369
  j.candidate.reasons.push(...newerBlockers);
324
370
  }
325
371
  }
@@ -328,7 +374,7 @@ function gather(root, { budget } = {}) {
328
374
  let next;
329
375
  if (!prd) next = '/pincer-plan <brief> — no PRD yet';
330
376
  else if (unresolved > 0) next = 'resolve PRD association with pincer-ticket.sh bind T-NN .prd/prd-vN.md before continuing';
331
- else if (bindingResult.code === 'REVISION_CHANGED') next = `node scripts/pincer-runtime.cjs register --prd ${prd} --rebind — the PRD content changed since registration; readiness for the old revision no longer applies`;
377
+ else if (bindingResult && bindingResult.code === 'REVISION_CHANGED') next = `node scripts/pincer-runtime.cjs register --prd ${prd} --rebind — the PRD content changed since registration; readiness for the old revision no longer applies`;
332
378
  else if (sourceProblems.length) next = `repair the source view: ${sourceProblems[0].code} ${sourceProblems[0].detail}`;
333
379
  else if (prdStatus === 'draft') next = '/pincer-narrow — current PRD is draft; earlier tickets and notes do not complete it';
334
380
  else if (tickets.length === 0) next = '/pincer-narrow — PRD exists, no tickets yet';
@@ -341,12 +387,122 @@ function gather(root, { budget } = {}) {
341
387
  } else if (newerBlockers.length) {
342
388
  next = `/pincer-code — a newer local attempt is not passing for ${newerBlockers.map(b => b.detail.split(':')[0]).join(', ')}; repair, re-run the check and /pincer-evaluate before release`;
343
389
  } else next = '/pincer-release — evaluation matches the current PRD and candidate; audit the artifacts';
390
+ const computed = { defaultNext: next, inProg, nOpen, nextOpen, reverify, notes, prdStatus, newerBlockers, unresolved, sourceProblems, nDone, tickets: tickets.length, allReady: tickets.length > 0 && nOpen === 0 && nProg === 0 && reverify.length === 0 && unresolved === 0 };
391
+ // The gathered view is available to the next-action rule (strict coverage reads it).
392
+ out.gathered = { mode, binding, prd, prdResult, tickets, computeReadiness, notes, unresolved, inProg, nOpen, reverify, computed };
393
+ if (ctx.decideNext) next = ctx.decideNext(computed);
344
394
  line(`Next ${next}`);
345
395
  j.next = next;
346
396
  for (const t of j.tickets) for (const r of t.readiness.reasons) j.reasons.push({ code: r.code, detail: `${t.id}: ${r.detail}` });
347
397
  for (const r of j.candidate.reasons) j.reasons.push(r);
348
398
  if (unresolved > 0) out.exit = 4;
349
- out.gathered = { mode, binding, prd, prdResult, tickets, computeReadiness, notes, unresolved, inProg, nOpen, reverify };
399
+ out.gathered = { mode, binding, prd, prdResult, tickets, computeReadiness, notes, unresolved, inProg, nOpen, reverify, computed };
400
+ return out;
401
+ }
402
+
403
+ // Changes mode (schema 2 records): status JSON schema 2 over the selected change
404
+ // (or the one named with --change, without selecting it). Without a local
405
+ // selection nothing is selected — never the highest PRD or the only record.
406
+ function gatherChanges(root, out, { budget, now, change }) {
407
+ const j = out.json;
408
+ const line = s => out.lines.push(s);
409
+ j.schema = 3; j.runtime = changes.RUNTIME_STRICT; j.mode = 'changes';
410
+ const resolved = changes.resolveSelected(root, { change });
411
+ const loaded = resolved.loaded;
412
+ const selection = resolved.selection;
413
+ j.selection = selection ? { change: selection.change, problem: null } : { change: null, problem: resolved.selectionProblem ? { code: resolved.selectionProblem.code, detail: resolved.selectionProblem.problem } : null };
414
+ j.changes = [...loaded.records.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, e]) => { const v = loaded.problems.length ? null : authorization.verdict(root, e.record); return { ...changes.summarize(id, e), selected: Boolean(selection && selection.change === id), authorization: v ? v.verdict : null, agreement: v ? v.current : null }; });
415
+ const retained = j.changes.map(c => c.id).join(', ') || 'none';
416
+ if (loaded.problems.length && (resolved.code || !resolved.record)) {
417
+ for (const p of loaded.problems) { line(`WARN invalid change records: ${p.code}: ${p.detail}`); j.reasons.push({ code: p.code, detail: p.detail }); }
418
+ j.next = loaded.problems[0].code === 'STATE_INCOMPLETE' ? 'node scripts/pincer-runtime.cjs recover' : 'repair .prd/changes/ by hand before continuing (see docs/runtime-contracts.md, "Change records")';
419
+ line(`Next ${j.next}`);
420
+ out.exit = 4; return out;
421
+ }
422
+ if (resolved.code) {
423
+ // No selection, a dangling one, or an unknown --change: report, never choose.
424
+ const problem = { code: resolved.code, detail: resolved.problem };
425
+ j.reasons.push(problem);
426
+ if (resolved.code === 'SELECTION_INVALID' || resolved.code === 'SELECTION_REQUIRED') j.selection = { change: selection ? selection.change : null, problem };
427
+ line('PRD none selected');
428
+ line(resolved.code === 'SELECTION_REQUIRED' ? 'Runtime changes · no selection · change select <id>' : `Runtime changes · ${resolved.code}: ${resolved.problem}`);
429
+ line(`Changes ${j.changes.length} retained: ${j.changes.map(c => `${c.id} (${c.state})`).join(', ') || 'none'}`);
430
+ const set = parse.validateTicketSet(root);
431
+ if (set.ok) { j.history = set.files.length; if (set.files.length) line(`History ${set.files.length} ticket(s); none belongs to a selected change`); }
432
+ line('Tickets none (no change selected)');
433
+ line('Notes none (no change selected)');
434
+ j.candidate = null;
435
+ j.next = resolved.code === 'SELECTION_REQUIRED' ? `node scripts/pincer-runtime.cjs change select <id> — select the change to work on (retained: ${retained}${j.changes.length ? '' : '; register one first'})`
436
+ : resolved.code === 'SELECTION_INVALID' ? `node scripts/pincer-runtime.cjs change select <id> — the selection is not usable (retained: ${retained})`
437
+ : `${resolved.code}: ${resolved.problem}`;
438
+ line(`Next ${j.next}`);
439
+ if (['MALFORMED', 'INPUT_INVALID'].includes(resolved.code)) out.exit = 4;
440
+ out.gathered = { mode: 'changes', binding: null, prd: null, prdResult: null, tickets: [], computeReadiness: null, notes: null, unresolved: 0, inProg: [], nOpen: 0, reverify: [] };
441
+ return out;
442
+ }
443
+ const record = resolved.record;
444
+ const id = resolved.id;
445
+ const v = changes.view(root, record);
446
+ const prdResult = v.prdResult;
447
+ const prd = record.prd;
448
+ const prdStatus = prdResult.ok ? prdResult.fields.status : '?';
449
+ const binding = { change: id, prd, prd_revision: prdResult.ok ? parse.prdDigest(prdResult.text) : null, base: record.base, legacy_receipts: record.legacy.receipts, strict: changes.isStrict(record) };
450
+ // The current agreement (read-only): the digest of the authored inputs now, and
451
+ // which recorded entry it equals, if any.
452
+ const agreed = prdResult.ok ? agreement.compute(root, record) : { code: 'INPUT_INVALID', problem: prdResult.problems[0] };
453
+ const entry = agreed.code ? null : agreement.entryFor(record, agreed.digest);
454
+ const latest = agreement.latestEntry(record);
455
+ let difference = null;
456
+ if (!agreed.code && !entry && latest) { const snap = agreement.readSnapshot(root, record, latest); if (!snap.code) difference = agreement.difference(snap.snapshot, agreed); }
457
+ const auth = authorization.verdict(root, record, agreed);
458
+ j.change = { id, prd, prd_revision: binding.prd_revision, base: record.base, sequence: record.sequence, lifecycle: { ...record.lifecycle }, agreement: { current: agreed.code ? null : agreed.digest, recorded: entry ? entry.id : null, latest: latest ? { id: latest.id, digest: latest.digest, recorded: latest.recorded } : null, difference, authorized: auth.authorized ? { id: auth.authorized.id, agreement: auth.authorized.agreement, digest: auth.authorized.digest, disposition: auth.authorized.disposition, recorded: auth.authorized.recorded } : null, verdict: auth.verdict, verdict_detail: auth.detail, open_decisions: auth.open }, view: { head: v.head, branch: v.branch, base_is_ancestor: v.base_is_ancestor, dirty: v.dirty } };
459
+ if (prdResult.ok) { line(`PRD ${prd} · status: ${prdStatus} · profile: ${prdResult.profile} · date: ${prdResult.fields.date || ''}`); j.prd = { path: prd, status: prdStatus, profile: prdResult.profile, date: prdResult.fields.date || null }; }
460
+ else line(`PRD ${prd} · invalid: ${prdResult.problems[0]}`);
461
+ const agreementText = agreed.code ? 'agreement unavailable' : `agreement ${short(agreed.digest)}${entry ? ` (${entry.id})` : latest ? ` (≠ ${latest.id}: ${agreement.renderDifference(difference || { same: false, prd_changed: false, tickets_added: [], tickets_removed: [], tickets_changed: [], decisions_added: [], decisions_removed: [] })})` : ' (not recorded)'}`;
462
+ line(`Runtime changes · ${resolved.explicit ? 'inspecting' : 'selected'} ${id} · ${record.lifecycle.state} · ${agreementText} · authorization ${auth.verdict}${auth.authorized ? ` (${auth.authorized.id})` : ''} · base ${record.base.slice(0, 7)}`);
463
+ line(`Changes ${j.changes.length} retained: ${j.changes.map(c => `${c.id} (${c.state}${c.selected ? ', selected' : ''})`).join(', ')}`);
464
+ line(`View HEAD ${v.head ? v.head.slice(0, 7) : 'none'} · branch ${v.branch || 'detached'} · base ${v.base_is_ancestor ? 'is an ancestor' : 'is NOT an ancestor'} · dirty ${v.dirty.length} path(s)${v.dirty.length ? `: ${v.dirty.slice(0, 5).join(', ')}${v.dirty.length > 5 ? ` (+${v.dirty.length - 5})` : ''}` : ''}`);
465
+ if (record.lifecycle.reason || record.lifecycle.note) line(`Handoff (authored) ${record.lifecycle.reason ? `reason: ${record.lifecycle.reason}` : ''}${record.lifecycle.reason && record.lifecycle.note ? ' · ' : ''}${record.lifecycle.note ? `note: ${record.lifecycle.note}` : ''}`);
466
+ for (const p of v.problems) { line(`WARN ${p.code}: ${p.detail}`); j.reasons.push(p); }
467
+ if (!prdResult.ok) { line('Next repair the PRD input before continuing'); j.next = 'repair the PRD input before continuing'; out.exit = 4; return out; }
468
+ const running = transaction.runningAttempts(root, id);
469
+ // Strict coverage summary (docs/runtime-contracts.md, "Coverage and impact commands"):
470
+ // computed once the tickets are gathered (below), consumed by the Coverage line,
471
+ // the JSON and the next action. A change without the capability is labeled unverified.
472
+ const phases = require('./phases.cjs');
473
+ let coverageReport = null;
474
+ const coverageOf = gathered => { if (!coverageReport) coverageReport = phases.compute(root, record, { gathered, verdict: auth }); return coverageReport; };
475
+ const decideNext = computed => {
476
+ const cmd = sub => `node scripts/pincer-runtime.cjs change ${sub} ${id}`;
477
+ const st = record.lifecycle.state;
478
+ if (running.length) return `attempt ${running[0].id} of this change is running: wait for it, or run node scripts/pincer-runtime.cjs recover if its owner died`;
479
+ if (changes.TERMINAL.includes(st)) return `change ${id} is ${st}${record.lifecycle.superseded_by ? ` by ${record.lifecycle.superseded_by}` : ''}: inspect it with ${cmd('show')}; execution needs a new change (register one and reference this record)`;
480
+ if (v.problems.length) return `${v.problems[0].code}: ${v.problems[0].detail}`;
481
+ if (computed.unresolved > 0 || computed.sourceProblems.length) return computed.defaultNext;
482
+ if (auth.verdict !== 'current' && !agreed.code) return `${auth.verdict}: ${auth.detail}`;
483
+ if (changes.isStrict(record) && out.gathered) {
484
+ const cov = coverageOf(out.gathered);
485
+ const gap = cov.structure.problems[0];
486
+ if (gap) return `${gap.code}: ${gap.detail} — see: node scripts/pincer-runtime.cjs coverage`;
487
+ }
488
+ if (st === 'planned') return `${cmd('activate')} — activate the change before executing tickets`;
489
+ if (st === 'paused') return `${cmd('resume')} — the change is paused${record.lifecycle.reason ? ` (${record.lifecycle.reason})` : ''}; resume it before executing tickets`;
490
+ if (st === 'active' && computed.allReady && computed.prdStatus !== 'draft') return `${cmd('complete')} — every ticket is done and ready; complete the change before choosing the candidate`;
491
+ return computed.defaultNext;
492
+ };
493
+ gatherBody(root, out, { mode: 'changes', binding, prd, prdResult, bindingResult: null, budget, now, decideNext, notes: () => locator.current(root, record), evidence: () => locator.evidenceLine(root, record), locatorFile: locator.file(id) });
494
+ if (out.gathered) {
495
+ out.gathered.record = record;
496
+ const cov = coverageOf(out.gathered);
497
+ j.coverage = { ...phases.summary(cov), next: cov.blockers[0] ? `${cov.blockers[0].code}: ${cov.blockers[0].detail}` : null };
498
+ const coverageLine = phases.summaryLine(cov, record);
499
+ const at = out.lines.findIndex(l => l.startsWith('Notes '));
500
+ out.lines.splice(at === -1 ? out.lines.length : at, 0, coverageLine);
501
+ if (changes.isStrict(record)) for (const p of cov.structure.problems) if (!j.reasons.some(r => r.code === p.code && r.detail === p.detail)) j.reasons.push({ code: p.code, detail: p.detail });
502
+ } else j.coverage = { strict: changes.isStrict(record), label: changes.isStrict(record) ? 'strict' : 'unverified', reason: changes.isStrict(record) ? null : 'strict coverage not adopted', structure: null, implementation: null, candidate: null, next: null };
503
+ // Reasons in gate order: repository view first, then the authorization verdict, then the rest.
504
+ const front = [...v.problems, ...(auth.verdict !== 'current' && !agreed.code ? [{ code: auth.verdict, detail: auth.detail }] : [])];
505
+ j.reasons = [...front, ...j.reasons.filter(r => !front.includes(r))];
350
506
  return out;
351
507
  }
352
508
 
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+ // PINCER runtime — transactions (docs/runtime-contracts.md, "Transactions and
3
+ // recovery"). Every write of a change record, agreement snapshot, selection,
4
+ // evaluation locator or migration goes through `run`: the worktree lock is held
5
+ // for the whole validate-and-write operation, the caller's function computes the
6
+ // outcome in memory, the new files are staged under the journal, a manifest is
7
+ // written last as the commit point, and the staged files are renamed onto their
8
+ // targets. A process killed before the manifest leaves the old state; killed
9
+ // after it, the next transaction or `recover` completes the renames. A
10
+ // projection is therefore never observable without its event.
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const crypto = require('node:crypto');
15
+ const state = require('./state.cjs');
16
+ const { nowIso, readJson } = require('./fsutil.cjs');
17
+
18
+ const MANIFEST_SCHEMA = 1;
19
+ const TXN_PREFIX = 'txn-';
20
+ const MAX_TEXT = 2000;
21
+ const SAFE_RELATIVE = /^(?!\/)(?!.*(^|\/)\.\.(\/|$))[^\0]+$/;
22
+
23
+ class Refusal extends Error {
24
+ constructor(code, message, extra = {}) { super(message); this.code = code; this.refusal = true; Object.assign(this, extra); }
25
+ }
26
+ const refuse = (code, message, extra) => { throw new Refusal(code, message, extra); };
27
+
28
+ const journalDir = root => state.paths(root).journal;
29
+ const compact = () => nowIso().replace(/[-:]/g, '');
30
+ const serialize = content => (typeof content === 'string' || Buffer.isBuffer(content) ? content : `${JSON.stringify(content, null, 2)}\n`);
31
+
32
+ // --- Pending transactions (read-only) -------------------------------------------
33
+ // A staging directory with a manifest is a committed transaction that was not
34
+ // fully applied; one without a manifest is uncommitted staging. Never writes.
35
+ function pending(root) {
36
+ const dir = journalDir(root);
37
+ if (!fs.existsSync(dir)) return { committed: [], uncommitted: [] };
38
+ const committed = [], uncommitted = [];
39
+ for (const name of fs.readdirSync(dir).sort()) {
40
+ if (!name.startsWith(TXN_PREFIX)) continue;
41
+ const staging = path.join(dir, name);
42
+ let stat = null;
43
+ try { stat = fs.statSync(staging); } catch { continue; }
44
+ if (!stat.isDirectory()) continue;
45
+ const read = readJson(path.join(staging, 'manifest.json'));
46
+ if (read.error === 'missing') { uncommitted.push({ id: name, dir: `${state.RUNTIME_DIR}/journal/${name}` }); continue; }
47
+ if (read.error || !validManifest(read.data)) { committed.push({ id: name, dir: `${state.RUNTIME_DIR}/journal/${name}`, manifest: null, problem: read.error || 'malformed manifest' }); continue; }
48
+ committed.push({ id: name, dir: `${state.RUNTIME_DIR}/journal/${name}`, manifest: read.data, command: read.data.command, started: read.data.started });
49
+ }
50
+ return { committed, uncommitted };
51
+ }
52
+ function validManifest(m) {
53
+ return m && typeof m === 'object' && !Array.isArray(m) && m.schema === MANIFEST_SCHEMA && typeof m.id === 'string' && typeof m.command === 'string'
54
+ && typeof m.started === 'string' && Array.isArray(m.writes)
55
+ && m.writes.every(w => w && typeof w === 'object' && typeof w.target === 'string' && SAFE_RELATIVE.test(w.target) && typeof w.staged === 'string' && /^[0-9]{2,}-[^/\0]+$/.test(w.staged));
56
+ }
57
+
58
+ // --- Recovery (caller holds the lock) -------------------------------------------
59
+ // Complete every committed transaction (each rename is idempotent: a staged file
60
+ // that is already gone was renamed before the crash) and discard uncommitted
61
+ // staging. Returns what was done. A manifest that cannot be read is left in place
62
+ // and reported: completing it would mean guessing its targets.
63
+ function recoverPending(root) {
64
+ const report = { completed: [], discarded: [], unreadable: [] };
65
+ const found = pending(root);
66
+ for (const t of found.committed) {
67
+ if (!t.manifest) { report.unreadable.push({ id: t.id, problem: t.problem }); continue; }
68
+ applyManifest(root, path.join(root, t.dir), t.manifest);
69
+ report.completed.push({ id: t.id, command: t.manifest.command, targets: t.manifest.writes.map(w => w.target) });
70
+ }
71
+ for (const t of found.uncommitted) {
72
+ fs.rmSync(path.join(root, t.dir), { recursive: true, force: true });
73
+ report.discarded.push({ id: t.id });
74
+ }
75
+ return report;
76
+ }
77
+ function applyManifest(root, staging, manifest, hooks = null) {
78
+ manifest.writes.forEach((w, i) => {
79
+ const staged = path.join(staging, w.staged);
80
+ const target = path.join(root, w.target);
81
+ if (fs.existsSync(staged)) {
82
+ fs.mkdirSync(path.dirname(target), { recursive: true });
83
+ fs.renameSync(staged, target);
84
+ }
85
+ if (hooks) hooks(`rename:${i}`);
86
+ });
87
+ fs.rmSync(path.join(staging, 'manifest.json'), { force: true });
88
+ if (hooks) hooks('cleanup');
89
+ fs.rmSync(staging, { recursive: true, force: true });
90
+ }
91
+
92
+ // --- Running attempts of a change ---------------------------------------------
93
+ // Attempts listed as running in the index whose context names the change. The
94
+ // owner's liveness is reported so callers can name `recover` as the next step
95
+ // for a dead owner; the transaction never terminates an attempt itself.
96
+ function runningAttempts(root, changeId) {
97
+ if (!state.exists(root)) return [];
98
+ const read = state.readIndex(root);
99
+ if (read.error) refuse('INPUT_INVALID', read.error);
100
+ const out = [];
101
+ for (const id of read.index.running) {
102
+ const attempt = state.readAttempt(root, id).attempt;
103
+ if (!attempt || attempt.outcome !== 'running') continue;
104
+ if (!attempt.context || attempt.context.change !== changeId) continue;
105
+ const owner = attempt.owner || {};
106
+ out.push({ id, context: attempt.context, owner, alive: owner.host === os.hostname() ? state.isAlive(owner.pid) : null });
107
+ }
108
+ return out;
109
+ }
110
+ function requireIdle(root, changeId) {
111
+ const running = runningAttempts(root, changeId);
112
+ if (!running.length) return;
113
+ const a = running[0];
114
+ const hint = a.alive === false ? ' (its owner is no longer running: run recover first)' : a.alive === true ? ` (pid ${a.owner.pid} is still running)` : ` (owned by ${a.owner.host || 'another host'})`;
115
+ refuse('ATTEMPT_RUNNING', `attempt ${a.id} of change ${changeId} is running${hint}; the transition is refused and the attempt is not terminated`, { attempt: a.id });
116
+ }
117
+
118
+ // --- The transaction --------------------------------------------------------------
119
+ // run(root, { command, waitMs, hooks }, fn): fn receives a context and returns
120
+ // the result to hand back. Inside fn, ctx.read(rel) reads a repository-relative
121
+ // JSON file (null when missing), ctx.text(rel) a text file, ctx.write(rel,
122
+ // content) stages a write, ctx.expect(rel, key, value) refuses STATE_CHANGED when
123
+ // the file's field differs from what the caller prepared against, ctx.idle(id)
124
+ // refuses ATTEMPT_RUNNING for a change with a running attempt, ctx.refuse(code,
125
+ // message) aborts with nothing written. `hooks(point)` is a test seam invoked at
126
+ // 'validated', 'staged', 'manifest', 'rename:<i>' and 'cleanup'.
127
+ function run(root, { command = 'transaction', waitMs, hooks = null, log } = {}, fn) {
128
+ return state.withLock(root, () => {
129
+ // Before anything else: finish what a killed writer committed and drop what
130
+ // it only staged, so every reader inside the lock sees consistent state.
131
+ recoverPending(root);
132
+ const writes = [];
133
+ const targets = new Set();
134
+ const ctx = {
135
+ root,
136
+ read(rel) {
137
+ assertRelative(rel);
138
+ const read = readJson(path.join(root, rel));
139
+ if (read.error === 'missing') return null;
140
+ if (read.error) refuse('MALFORMED', `${rel}: ${read.error}`);
141
+ return read.data;
142
+ },
143
+ text(rel) {
144
+ assertRelative(rel);
145
+ try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch (error) { return error.code === 'ENOENT' ? null : refuse('INPUT_INVALID', `${rel}: ${error.message}`); }
146
+ },
147
+ exists(rel) { assertRelative(rel); return fs.existsSync(path.join(root, rel)); },
148
+ expect(rel, key, value) {
149
+ const doc = ctx.read(rel);
150
+ const actual = doc ? doc[key] : null;
151
+ if (actual !== value) refuse('STATE_CHANGED', `${rel}: ${key} is ${JSON.stringify(actual)}, expected ${JSON.stringify(value)}; the record changed since the operation was prepared — inspect it and repeat the command against the current state`);
152
+ return doc;
153
+ },
154
+ idle(changeId) { requireIdle(root, changeId); },
155
+ running(changeId) { return runningAttempts(root, changeId); },
156
+ refuse,
157
+ write(rel, content) {
158
+ assertRelative(rel);
159
+ if (rel.startsWith(`${state.RUNTIME_DIR}/journal/`) || rel.startsWith(`${state.RUNTIME_DIR}/lock`)) refuse('INPUT_INVALID', `${rel}: the journal and the lock are not transaction targets`);
160
+ if (targets.has(rel)) refuse('INPUT_INVALID', `${rel}: written twice in one transaction`);
161
+ targets.add(rel);
162
+ writes.push({ target: rel, content: serialize(content) });
163
+ },
164
+ now: nowIso(),
165
+ };
166
+ const result = fn(ctx);
167
+ if (hooks) hooks('validated');
168
+ if (!writes.length) return { result, writes: [], id: null };
169
+ const id = `${TXN_PREFIX}${compact()}-${crypto.randomBytes(3).toString('hex')}`;
170
+ const staging = path.join(journalDir(root), id);
171
+ fs.mkdirSync(staging, { recursive: true });
172
+ const manifest = { schema: MANIFEST_SCHEMA, id, command, started: ctx.now, writes: [] };
173
+ writes.forEach((w, i) => {
174
+ const staged = `${String(i + 1).padStart(2, '0')}-${path.basename(w.target)}`;
175
+ fs.writeFileSync(path.join(staging, staged), w.content);
176
+ manifest.writes.push({ target: w.target, staged });
177
+ });
178
+ if (hooks) hooks('staged');
179
+ // The manifest is the commit point: written to a temporary name and renamed.
180
+ const temp = path.join(staging, `.manifest.${process.pid}.tmp`);
181
+ fs.writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`);
182
+ fs.renameSync(temp, path.join(staging, 'manifest.json'));
183
+ if (hooks) hooks('manifest');
184
+ applyManifest(root, staging, manifest, hooks);
185
+ return { result, writes: manifest.writes.map(w => w.target), id };
186
+ }, { command, waitMs, log });
187
+ }
188
+ function assertRelative(rel) {
189
+ if (typeof rel !== 'string' || !SAFE_RELATIVE.test(rel) || path.isAbsolute(rel)) refuse('INPUT_INVALID', `${rel}: paths must be repository-relative without ".."`);
190
+ }
191
+
192
+ // Bounded text arguments stored verbatim in records (reasons, notes, references…).
193
+ function boundedText(value, name, { required = true } = {}) {
194
+ if (value === undefined || value === null) { if (required) refuse('INPUT_INVALID', `--${name} is required`); return null; }
195
+ if (typeof value !== 'string' || (required && !value.trim())) refuse('INPUT_INVALID', `--${name} must be a nonempty string`);
196
+ if (value.length > MAX_TEXT) refuse('INPUT_INVALID', `--${name} is longer than ${MAX_TEXT} characters; store a reference, not a transcript`);
197
+ return value;
198
+ }
199
+
200
+ module.exports = { Refusal, refuse, run, pending, recoverPending, runningAttempts, requireIdle, boundedText, MANIFEST_SCHEMA, MAX_TEXT, TXN_PREFIX };