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,514 @@
1
+ 'use strict';
2
+ // PINCER runtime — status (docs/runtime-contracts.md, "Readiness and reason
3
+ // codes"). Gathers the artifacts on disk, computes readiness once, and renders
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. 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.
8
+ const fs = require('node:fs');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
11
+ const parse = require('./parse.cjs');
12
+ const identity = require('./identity.cjs');
13
+ const source = require('./source.cjs');
14
+ const state = require('./state.cjs');
15
+ const readiness = require('./readiness.cjs');
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');
22
+ const { tryGit } = require('./fsutil.cjs');
23
+
24
+ const RUNTIME = 1;
25
+ const pad = (s, n) => String(s).padEnd(n);
26
+ const toEpoch = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? Math.floor(t / 1000) : 0; };
27
+ const mins = (a, b) => `${Math.floor((b - a) / 60)}m`;
28
+ const hhmm = iso => (iso ? iso.slice(11, 16) : '—');
29
+ const short = s => (typeof s === 'string' ? s.slice(0, 12) : '?');
30
+
31
+ // --- PRD selection -----------------------------------------------------------
32
+ function prdFiles(root) {
33
+ const dir = path.join(root, '.prd');
34
+ if (!fs.existsSync(dir)) return [];
35
+ return fs.readdirSync(dir).filter(n => /^prd-v.*\.md$/.test(n)).map(n => `.prd/${n}`);
36
+ }
37
+ // Legacy: the highest-numbered PRD. Returns { prd } (prd may be null) or { problem }.
38
+ function latestPrd(root) {
39
+ let latest = null, highest = 0;
40
+ for (const ref of prdFiles(root)) {
41
+ const m = ref.match(parse.PRD_REF);
42
+ if (!m) return { problem: `invalid PRD filename: ${ref}` };
43
+ if (Number(m[1]) > highest) { highest = Number(m[1]); latest = ref; }
44
+ }
45
+ if (!latest) return { prd: null };
46
+ const result = parse.validatePrd(root, latest);
47
+ if (!result.ok) return { problem: `${result.file ? `${result.file}: ` : ''}${result.problems[0]}` };
48
+ return { prd: latest, prdResult: result };
49
+ }
50
+ // Ticket → PRD association without writing (legacy tickets with one PRD are inferred).
51
+ function ticketPrd(root, file, fields) {
52
+ let ref = fields.prd || '';
53
+ if (!ref) {
54
+ const all = prdFiles(root);
55
+ if (all.length !== 1) return { problem: `${file}: missing or ambiguous PRD association; use pincer-ticket.sh bind ${fields.ticket} .prd/prd-vN.md` };
56
+ ref = all[0];
57
+ }
58
+ const result = parse.validatePrd(root, ref);
59
+ if (!result.ok) return { problem: `${result.file ? `${result.file}: ` : ''}${result.problems[0]}` };
60
+ return { prd: ref, prdResult: result };
61
+ }
62
+ const usablePrd = prdResult => ['ticketed', 'built'].includes(prdResult.fields.status);
63
+
64
+ // --- Candidate (NOTES.md + evidence) ------------------------------------------
65
+ // Port of notes_current: exact wording, same checks, one pass.
66
+ function notesCurrent(root, prd) {
67
+ const notesPath = path.join(root, 'NOTES.md');
68
+ if (!fs.existsSync(notesPath)) return { text: 'missing', state: 'missing' };
69
+ const meta = parse.validateMetadata(fs.readFileSync(notesPath, 'utf8'));
70
+ if (!meta.ok) return { text: 'stale: invalid or missing evaluation metadata', state: 'stale' };
71
+ const f = meta.fields;
72
+ if (f.prd !== prd) return { text: 'stale: evaluation PRD does not match', state: 'stale', fields: f };
73
+ const candidate = f.candidate || '', base = f.base || '';
74
+ if (!parse.HEX40.test(candidate) || !parse.HEX40.test(base)) return { text: 'stale: candidate and base must be full 40-hex commit IDs', state: 'stale', fields: f };
75
+ const ok = args => !tryGit(root, args).error;
76
+ if (!ok(['rev-parse', '--verify', `${candidate}^{commit}`]) || !ok(['rev-parse', '--verify', `${base}^{commit}`]) ||
77
+ !ok(['merge-base', '--is-ancestor', base, candidate]) || !ok(['merge-base', '--is-ancestor', candidate, 'HEAD'])) {
78
+ return { text: 'stale: evaluation commits or ancestry unavailable', state: 'stale', fields: f };
79
+ }
80
+ const manifest = f.evidence || '';
81
+ if (!manifest) return { text: 'stale: legacy evaluation without evidence manifest — re-run /pincer-evaluate for evidence schema 1', state: 'stale', fields: f };
82
+ const opts = { files: true, candidate, base, prd };
83
+ const problems = evidence.validate(path.resolve(root, manifest), opts, root);
84
+ if (problems.length) return { text: `stale: evidence invalid: ${problems[0]}`, state: 'stale', fields: f, manifest };
85
+ const canonical = new Set();
86
+ for (const file of opts.list) {
87
+ const tracked = tryGit(root, ['ls-files', '--error-unmatch', '--', file]);
88
+ if (tracked.error || !tracked.out.trim()) return { text: `stale: evidence not tracked: ${file}`, state: 'stale', fields: f, manifest };
89
+ for (const line of tracked.out.split('\n')) if (line) canonical.add(line);
90
+ }
91
+ const diff = tryGit(root, ['diff', '--name-only', '--relative', candidate, 'HEAD', '--', '.', ':(exclude)NOTES.md']);
92
+ if (diff.error) return { text: 'stale: evaluation commits or ancestry unavailable', state: 'stale', fields: f, manifest };
93
+ const offending = diff.out.split('\n').filter(l => l && !canonical.has(l))[0];
94
+ if (offending) return { text: `stale: candidate changed after evaluation: ${offending}`, state: 'stale', fields: f, manifest };
95
+ const dirty = tryGit(root, ['status', '--porcelain', '--untracked-files=all', '--', '.', ':(exclude)NOTES.md']);
96
+ if (dirty.error || dirty.out.trim()) return { text: 'stale: working tree has changes outside NOTES.md', state: 'stale', fields: f, manifest };
97
+ return { text: `current (${candidate})`, state: 'current', fields: f, manifest, candidate, base };
98
+ }
99
+ // The Evidence line: the validator's verdict for the manifest NOTES names,
100
+ // independent of whether the candidate is still current.
101
+ function evidenceLine(root, prd) {
102
+ const notesPath = path.join(root, 'NOTES.md');
103
+ if (!fs.existsSync(notesPath)) return null;
104
+ const meta = parse.validateMetadata(fs.readFileSync(notesPath, 'utf8'));
105
+ if (!meta.ok || !meta.fields.evidence) return null;
106
+ const f = meta.fields;
107
+ const opts = {};
108
+ if (parse.HEX40.test(f.candidate || '')) opts.candidate = f.candidate;
109
+ if (parse.HEX40.test(f.base || '')) opts.base = f.base;
110
+ if (parse.PRD_REF.test(prd || '')) opts.prd = prd;
111
+ const problems = evidence.validate(path.resolve(root, f.evidence), opts, root);
112
+ let schema = null;
113
+ try { schema = JSON.parse(fs.readFileSync(path.resolve(root, f.evidence), 'utf8')).schema; } catch { schema = null; }
114
+ return { manifest: f.evidence, ok: problems.length === 0, reason: problems[0] || null, schema };
115
+ }
116
+
117
+ // --- Gather ------------------------------------------------------------------
118
+ function gather(root, { budget, change = null } = {}) {
119
+ const now = Math.floor(Date.now() / 1000);
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 } };
121
+ const line = s => out.lines.push(s);
122
+ const j = out.json;
123
+ line(`PINCER status · ${new Date().toISOString().replace(/:\d{2}\.\d{3}Z$/, 'Z')} · ${root}`);
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
+ }
139
+ // Mode and selected PRD.
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; }
143
+ let mode = 'legacy', binding = null, prd = null, prdResult = null;
144
+ if (bindingResult.binding && !bindingResult.code) {
145
+ mode = 'migrated'; binding = bindingResult.binding; prd = binding.prd; prdResult = bindingResult.prd;
146
+ } else if ((bindingResult.code === 'REVISION_CHANGED' || bindingResult.code === 'INPUT_INVALID') && bindingResult.binding) {
147
+ mode = 'migrated'; binding = bindingResult.binding; prd = binding.prd;
148
+ const v = parse.validatePrd(root, prd);
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; }
150
+ prdResult = v;
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';
154
+ line(`WARN invalid change binding: ${bindingResult.problem}`);
155
+ line('Next repair .prd/changes/ before continuing (remove or restore the binding; see docs/runtime-contracts.md)');
156
+ j.reasons.push({ code: 'INPUT_INVALID', detail: bindingResult.problem });
157
+ out.exit = 4; return out;
158
+ } else {
159
+ const latest = latestPrd(root);
160
+ if (latest.problem) { line(`WARN invalid PRD: ${latest.problem}`); line('Next repair PRD input before continuing'); out.exit = 4; return out; }
161
+ prd = latest.prd; prdResult = latest.prdResult;
162
+ }
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' };
165
+ const prdStatus = prdResult ? prdResult.fields.status : '';
166
+ if (!prd) line('PRD none');
167
+ else {
168
+ line(`PRD ${prd} · status: ${prdStatus || '?'} · profile: ${prdResult.profile} · date: ${prdResult.fields.date || ''}`);
169
+ j.prd = { path: prd, status: prdStatus, profile: prdResult.profile, date: prdResult.fields.date || null };
170
+ }
171
+ if (mode === 'migrated') {
172
+ j.change = { id: binding.change, prd: binding.prd, prd_revision: binding.prd_revision, base: binding.base };
173
+ let runtimeLine = `Runtime change ${binding.change} · revision ${short(binding.prd_revision)} · base ${binding.base.slice(0, 7)}`;
174
+ if (bindingResult.code === 'REVISION_CHANGED') { runtimeLine += ` · REVISION_CHANGED: PRD content is now ${short(bindingResult.revision)}`; j.reasons.push({ code: 'REVISION_CHANGED', detail: bindingResult.problem }); }
175
+ const newer = prdFiles(root).filter(ref => { const m = ref.match(parse.PRD_REF); return m && Number(m[1]) > Number(prd.match(parse.PRD_REF)[1]); });
176
+ if (newer.length) runtimeLine += ` · unregistered newer PRD: ${newer.join(', ')}`;
177
+ line(runtimeLine);
178
+ } else {
179
+ line(`Runtime legacy · no change binding${prd ? ` · migrate with node scripts/pincer-runtime.cjs migrate --preview --prd ${prd}` : ''}`);
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
+ }
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 : '';
195
+ // Tickets: reject malformed input instead of guessing.
196
+ const set = parse.validateTicketSet(root);
197
+ if (!set.ok) {
198
+ const prefix = set.file ? `pincer-ticket: ${set.file}: ` : 'pincer-ticket: ';
199
+ line(`WARN invalid tickets: ${prefix}${set.problems[0]}`);
200
+ line('Next repair ticket input before continuing');
201
+ j.reasons.push({ code: 'INPUT_INVALID', detail: `${prefix}${set.problems[0]}` });
202
+ out.exit = 4; return out;
203
+ }
204
+ const tickets = [];
205
+ let historical = 0, unresolved = 0;
206
+ for (const file of set.files) {
207
+ const text = fs.readFileSync(path.join(root, file), 'utf8');
208
+ const v = parse.validateTicket(file, text);
209
+ const assoc = ticketPrd(root, file, v.fields);
210
+ if (assoc.problem) { line(`WARN unresolved ticket PRD: pincer: ${assoc.problem}`); unresolved++; continue; }
211
+ if (assoc.prd === prd) tickets.push({ file, text, fields: v.fields, timeout: v.timeout, prdResult: assoc.prdResult });
212
+ else historical++;
213
+ }
214
+ j.history = historical;
215
+ if (historical) line(`History ${historical} ticket(s) associated with other PRDs`);
216
+
217
+ // Current inputs for migrated readiness (computed once; read-only).
218
+ let current = {}, sourceProblems = [], manifestNow = null;
219
+ const runtimeMode = mode !== 'legacy';
220
+ if (runtimeMode) {
221
+ manifestNow = source.snapshot(root);
222
+ sourceProblems = manifestNow.problems;
223
+ current = { prdRevision: binding.prd_revision, sourceDigest: manifestNow.digest };
224
+ }
225
+ const indexRead = runtimeMode && state.exists(root) ? state.readIndex(root) : { index: null };
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; }
227
+ const byId = new Map(tickets.map(t => [t.fields.ticket, t]));
228
+ const readinessOf = new Map();
229
+ const computeReadiness = t => {
230
+ if (readinessOf.has(t.file)) return readinessOf.get(t.file);
231
+ let r;
232
+ if (mode === 'legacy') r = readiness.legacyTicketReadiness(t.text, t.fields);
233
+ else {
234
+ const key = state.contextKey({ kind: 'ticket', change: binding.change, ticket: t.fields.ticket });
235
+ const attempt = indexRead.index ? state.inspectArtifacts(root, state.latestAttempt(root, key, indexRead.index)) : null;
236
+ let changedPaths = [];
237
+ if (attempt && attempt.source && manifestNow && manifestNow.digest && attempt.source.after !== manifestNow.digest) {
238
+ const before = source.readManifest(root, attempt.source.after);
239
+ if (before) changedPaths = source.diffManifests(before, manifestNow);
240
+ }
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);
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) });
243
+ r.attempt = attempt;
244
+ }
245
+ readinessOf.set(t.file, r);
246
+ return r;
247
+ };
248
+
249
+ let nOpen = 0, nProg = 0, nDone = 0, firstStart = null, localMissing = 0;
250
+ // Without local runtime state (a fresh clone) done tickets rely on the saved
251
+ // candidate evidence; they are not re-verify work until verified here.
252
+ const localUnavailable = runtimeMode && !state.hasIndex(root);
253
+ const inProg = [], reverify = [];
254
+ let nextOpen = null;
255
+ const rows = [];
256
+ if (tickets.length === 0) line('Tickets none');
257
+ for (const t of tickets) {
258
+ const f = t.fields;
259
+ const id = f.ticket;
260
+ const st = f.status || 'open';
261
+ const r = computeReadiness(t);
262
+ const entry = { id, file: t.file, status: st, size: f.size || null, depends_on: parse.dependencies(f), started: f.started || null, finished: f.finished || null, readiness: { ready: r.ready, reasons: r.reasons.map(({ code, detail }) => ({ code, detail })), next: r.reasons[0] ? r.reasons[0].next : null }, latest_attempt: r.attempt ? { id: r.attempt.id, sequence: r.attempt.sequence, outcome: r.attempt.outcome, started: r.attempt.started, finished: r.attempt.finished } : null, legacy_receipt: (f.verified || f.last_check) ? { verified: f.verified || null, last_check: f.last_check || null } : null };
263
+ if (mode === 'legacy' && st !== 'done' && f.last_check && !/ passed /.test(` ${f.last_check} `)) {
264
+ out.warnings.push(` WARN ${id} latest verification: ${f.last_check} — re-run verify`);
265
+ }
266
+ if (runtimeMode && st !== 'done' && r.attempt && !r.ready) {
267
+ // Unticked criteria are expected while work is in progress; anything else
268
+ // (a failed, stale or superseded attempt) is a warning here too.
269
+ const blocking = r.reasons.find(x => x.code !== 'CRITERIA_UNTICKED');
270
+ if (blocking) out.warnings.push(` WARN ${id} ${blocking.code}: ${blocking.detail} — ${blocking.next}`);
271
+ }
272
+ if (f.started) { const se = toEpoch(f.started); if (firstStart === null || se < firstStart) firstStart = se; }
273
+ let detail;
274
+ if (st === 'done') {
275
+ nDone++;
276
+ detail = `started ${hhmm(f.started)} · finished ${hhmm(f.finished)}`;
277
+ if (f.started && f.finished) detail += ` (${mins(toEpoch(f.started), toEpoch(f.finished))})`;
278
+ if (!r.ready) {
279
+ if (localUnavailable && r.reasons[0].code === 'EVIDENCE_MISSING') localMissing++;
280
+ else {
281
+ const message = mode === 'legacy' ? r.legacyMessage : `${r.reasons[0].code}: ${r.reasons[0].detail} — ${r.reasons[0].next}`;
282
+ out.warnings.push(` WARN ${id} ${message}`);
283
+ reverify.push(id);
284
+ }
285
+ }
286
+ } else if (st === 'in_progress') {
287
+ nProg++; inProg.push(id);
288
+ detail = `started ${hhmm(f.started)}`;
289
+ if (f.started) detail += ` · elapsed ${mins(toEpoch(f.started), now)}`;
290
+ if (mode === 'legacy') detail += f.verified ? ' · receipt ✓' : ' · no receipt yet';
291
+ else detail += r.attempt ? ` · latest attempt ${r.attempt.outcome}` : ' · no attempt yet';
292
+ } else {
293
+ nOpen++;
294
+ const blocked = [];
295
+ for (const dep of parse.dependencies(f)) {
296
+ const d = byId.get(dep);
297
+ if (!d || d.fields.status !== 'done' || !usablePrd(d.prdResult) || !computeReadiness(d).ready) blocked.push(dep);
298
+ }
299
+ if (blocked.length) { detail = `blocked by ${blocked.join(' ')}`; entry.readiness = { ready: false, reasons: [{ code: 'DEPENDENCY_BLOCKED', detail: `blocked by ${blocked.join(' ')}` }], next: 'finish the dependency' }; }
300
+ else { detail = 'ready'; if (!nextOpen) nextOpen = id; }
301
+ }
302
+ rows.push(` ${pad(id, 5)} ${pad(st, 12)} ${pad(f.size || '?', 2)} ${detail}`);
303
+ j.tickets.push(entry);
304
+ }
305
+ if (tickets.length) {
306
+ line(`Tickets ${nOpen + nProg + nDone} total · ${nDone} done · ${nProg} in progress · ${nOpen} open`);
307
+ for (const r of rows) line(r);
308
+ for (const w of out.warnings) line(w);
309
+ if (localMissing) line(`Local verification history unavailable: ${localMissing} done ticket(s) rely on the saved candidate evidence until verified here`);
310
+ if (firstStart !== null && (nProg > 0 || budget)) {
311
+ let build = `Build wall-clock elapsed ${mins(firstStart, now)} since the first ticket started (not active execution time)`;
312
+ if (budget) build += ` · budget ${budget}m`;
313
+ line(build);
314
+ }
315
+ }
316
+
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);
326
+ if (ev) line(`Evidence ${ev.manifest} · ${ev.ok ? 'ok' : ev.reason}`);
327
+ j.candidate = {
328
+ notes: notes.state, reason: notes.state === 'current' ? null : notes.text,
329
+ candidate: notes.candidate || (notes.fields && notes.fields.candidate) || null, base: notes.base || (notes.fields && notes.fields.base) || null,
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)',
332
+ newer_attempts: [],
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 } : {}),
335
+ };
336
+ // Provenance and newer local attempts (contract "Evidence schema 2"): a newer
337
+ // nonpassing attempt for the same check and candidate on the same source inputs
338
+ // blocks local readiness; on different inputs it is history; without local
339
+ // state only the saved record can be validated.
340
+ const newerBlockers = [];
341
+ if (ev && ev.ok) {
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`);
345
+ } else {
346
+ let manifestDoc = null;
347
+ try { manifestDoc = JSON.parse(fs.readFileSync(path.resolve(root, ev.manifest), 'utf8')); } catch { manifestDoc = null; }
348
+ const cand = manifestDoc && manifestDoc.candidate;
349
+ const idx = indexRead.index || (state.readIndex(root).index || null);
350
+ const details = [];
351
+ for (const check of (manifestDoc && manifestDoc.checks) || []) {
352
+ if (check.provenance !== 'runtime' || !check.attempt) continue;
353
+ // Every attempt newer than the exported one counts: a same-source
354
+ // nonpassing attempt blocks until the candidate is re-exported, even
355
+ // when a later attempt passed again.
356
+ const key = state.contextKey({ kind: 'candidate', change: binding.change, candidate: cand, check: check.id, mode });
357
+ const newer = idx ? state.listAttempts(root, key).filter(a => a.sequence > check.attempt.sequence && a.outcome !== 'passed') : [];
358
+ for (const latest of newer) {
359
+ const sameSource = latest.source && latest.source.before === check.attempt.source_before;
360
+ j.candidate.newer_attempts.push({ check: check.id, attempt: latest.id, outcome: latest.outcome, same_source: Boolean(sameSource) });
361
+ if (sameSource) {
362
+ const code = { failed: 'CHECK_FAILED', running: 'ATTEMPT_RUNNING', timed_out: 'ATTEMPT_TIMED_OUT', interrupted: 'ATTEMPT_INTERRUPTED', error: 'ATTEMPT_ERROR' }[latest.outcome] || 'ATTEMPT_ERROR';
363
+ newerBlockers.push({ code, detail: `${check.id}: newer local attempt ${latest.id} ${latest.outcome} on the same source inputs as the exported pass` });
364
+ details.push(`${check.id} ${latest.outcome} (${latest.id}, same source: blocks until re-exported)`);
365
+ } else details.push(`${check.id} ${latest.outcome} (${latest.id}, different source: historical)`);
366
+ }
367
+ }
368
+ line(`Provenance runtime (schema ${ev.schema}) · local attempts ${details.length ? details.join('; ') : 'consistent with the exported checks'}`);
369
+ j.candidate.reasons.push(...newerBlockers);
370
+ }
371
+ }
372
+
373
+ // Next action.
374
+ let next;
375
+ if (!prd) next = '/pincer-plan <brief> — no PRD yet';
376
+ else if (unresolved > 0) next = 'resolve PRD association with pincer-ticket.sh bind T-NN .prd/prd-vN.md before continuing';
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`;
378
+ else if (sourceProblems.length) next = `repair the source view: ${sourceProblems[0].code} ${sourceProblems[0].detail}`;
379
+ else if (prdStatus === 'draft') next = '/pincer-narrow — current PRD is draft; earlier tickets and notes do not complete it';
380
+ else if (tickets.length === 0) next = '/pincer-narrow — PRD exists, no tickets yet';
381
+ else if (inProg.length) next = `resume ${inProg.join(' ')}: /pincer-code ${inProg.join(' ')} (check git status for uncommitted work; then verify → done)`;
382
+ else if (nOpen > 0) next = `/pincer-code — next ready ticket: ${nextOpen || 'none (all remaining are blocked — check depends_on)'}`;
383
+ else if (reverify.length) next = `/pincer-code — re-run verify for ${reverify.join(' ')}; resolve readiness warnings before evaluation or release`;
384
+ else if (notes.state !== 'current' || prdStatus !== 'built') {
385
+ next = '/pincer-evaluate — all tickets done';
386
+ if (prdStatus !== 'built') next += ` (PRD status is '${prdStatus || '?'}', expected 'built')`;
387
+ } else if (newerBlockers.length) {
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`;
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);
394
+ line(`Next ${next}`);
395
+ j.next = next;
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}` });
397
+ for (const r of j.candidate.reasons) j.reasons.push(r);
398
+ if (unresolved > 0) out.exit = 4;
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))];
506
+ return out;
507
+ }
508
+
509
+ function render(root, options) {
510
+ const result = gather(root, options);
511
+ return { text: `${result.lines.join('\n')}\n`, json: result.json, exit: result.exit, gathered: result.gathered };
512
+ }
513
+
514
+ module.exports = { gather, render, notesCurrent, evidenceLine, latestPrd, ticketPrd, usablePrd, prdFiles };