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