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
@@ -1,45 +1,68 @@
1
1
  'use strict';
2
2
  // PINCER runtime — migration (docs/runtime-contracts.md, "Migration and
3
- // rollback"). Preview is read-only; apply backs up every authored file it
4
- // changes, strips legacy receipts into the binding's legacy_receipts (history,
5
- // never runtime evidence), ignores .pincer/, and writes the binding last.
6
- // Conflicts fail closed before the first write; repeated apply is a no-op.
3
+ // rollback"). Preview is read-only; apply is one transaction that backs up every
4
+ // authored file it changes, strips legacy receipts into the change record's
5
+ // `legacy.receipts` (history, never runtime evidence), converts a v0.5.0 schema 1
6
+ // binding into a schema 2 record (same id, base and registration; the free-text
7
+ // authorization retained as unvalidated history), rewrites the local candidate
8
+ // pointers that no longer resolve, ignores .pincer/, and selects the change in
9
+ // this worktree. The migrated change is planned with no authorization; existing
10
+ // attempts and evaluations stay history until verified again. Conflicts fail
11
+ // closed before the first write; repeated apply is a no-op.
7
12
  const fs = require('node:fs');
8
13
  const path = require('node:path');
9
14
  const parse = require('./parse.cjs');
10
15
  const identity = require('./identity.cjs');
16
+ const changes = require('./changes.cjs');
11
17
  const statusModule = require('./status.cjs');
18
+ const state = require('./state.cjs');
19
+ const transaction = require('./transaction.cjs');
12
20
  const { fmUnset } = require('./lifecycle.cjs');
13
- const { nowIso, atomicWrite } = require('./fsutil.cjs');
21
+ const { nowIso, readJson } = require('./fsutil.cjs');
14
22
 
15
23
  const IGNORE_LINE = '.pincer/';
24
+ const gitignoreHas = root => changes.gitignoreHas(fs.existsSync(path.join(root, '.gitignore')) ? fs.readFileSync(path.join(root, '.gitignore'), 'utf8') : '');
25
+ const OLD_CANDIDATE_KEY = /^candidate:([0-9a-f]{40}):(C-[0-9]{2,6})$/;
16
26
 
17
- function gitignoreHas(root) {
18
- const file = path.join(root, '.gitignore');
19
- if (!fs.existsSync(file)) return false;
20
- return fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).some(l => l === IGNORE_LINE || l === '/.pincer/' || l === '.pincer');
21
- }
22
-
23
- // Compute the plan. Returns { prd, change, conflicts: [{code, detail}], binding:
24
- // { file, exists, action }, tickets: [{file, id, verified, last_check}],
25
- // gitignore: boolean (needs the line), alreadyMigrated }.
27
+ // Compute the plan. Returns { prd, change, source: 'legacy' | 'binding' | 'record',
28
+ // conflicts, binding: { file, existing, action }, tickets, gitignore, index,
29
+ // selection, alreadyMigrated, partial, authorization }.
26
30
  function plan(root, { prd, change, authorization = null } = {}) {
27
31
  const conflicts = [];
28
32
  const conflict = (code, detail) => conflicts.push({ code, detail });
29
33
  const prdResult = parse.validatePrd(root, prd);
30
34
  if (!prdResult.ok) { conflict('INPUT_INVALID', `${prdResult.file || prd}: ${prdResult.problems[0]}`); return { prd, conflicts }; }
31
- const id = change || `prd-v${prd.match(parse.PRD_REF)[1]}`;
32
- if (!identity.CHANGE_ID.test(id)) conflict('INPUT_INVALID', `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}`);
35
+ let id = change || `prd-v${prd.match(parse.PRD_REF)[1]}`;
36
+ if (!changes.CHANGE_ID.test(id)) conflict('INPUT_INVALID', `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}`);
33
37
  if (!identity.head(root)) conflict('UNSUPPORTED_INPUT', 'migration needs a git repository with at least one commit');
34
- const bindings = identity.listBindings(root);
35
- let existing = null;
36
- if (bindings.length > 1) conflict('AMBIGUOUS', `several change bindings under .prd/changes/ (${bindings.map(b => path.basename(b)).join(', ')}); keep exactly one`);
37
- else if (bindings.length === 1) {
38
- const loaded = identity.loadBinding(root, { prd });
39
- if (loaded.code && loaded.code !== 'REVISION_CHANGED' && !loaded.other) conflict(loaded.code === 'UNSUPPORTED_SCHEMA' ? 'UNSUPPORTED_SCHEMA' : 'INPUT_INVALID', loaded.problem);
40
- else if (loaded.other) conflict('AMBIGUOUS', `${loaded.file} binds ${loaded.binding.prd}, not ${prd}; one change per worktree — register --replace or remove it first`);
41
- else if (loaded.binding && loaded.binding.change !== id) conflict('AMBIGUOUS', `${loaded.file} already binds ${prd} as change "${loaded.binding.change}"; pass --change ${loaded.binding.change}`);
42
- else existing = loaded.binding || null;
38
+ const scan = changes.scan(root);
39
+ let source = 'legacy', binding = null, existing = null, bindingFile = null;
40
+ if (scan.mode === 'invalid') for (const p of scan.problems) conflict(p.code, p.detail);
41
+ else if (scan.mode === 'migrated') {
42
+ const bindings = scan.entries.filter(e => e.schema === 1);
43
+ const bad = scan.entries.filter(e => e.code);
44
+ if (bindings.length > 1 || bad.length) conflict('AMBIGUOUS', `several files under .prd/changes/ (${scan.entries.map(e => path.basename(e.file)).join(', ')}); keep exactly one v0.5.0 binding`);
45
+ else {
46
+ const b = bindings[0].doc;
47
+ const invalid = identity.validateBinding(b);
48
+ if (invalid) conflict(/schema|runtime contract/.test(invalid) ? 'UNSUPPORTED_SCHEMA' : 'INPUT_INVALID', `${bindings[0].file}: ${invalid}`);
49
+ else if (path.basename(bindings[0].file, '.json') !== b.change) conflict('INPUT_INVALID', `${bindings[0].file}: filename does not match change "${b.change}"`);
50
+ else if (b.prd !== prd) conflict('AMBIGUOUS', `${bindings[0].file} binds ${b.prd}, not ${prd}; a v0.5.0 binding is converted first — migrate it with: node scripts/pincer-runtime.cjs migrate --preview --prd ${b.prd}, then register ${prd}`);
51
+ else if (change && change !== b.change) conflict('AMBIGUOUS', `${bindings[0].file} already binds ${prd} as change "${b.change}"; pass --change ${b.change}`);
52
+ else { id = b.change; source = 'binding'; binding = b; bindingFile = bindings[0].file; }
53
+ }
54
+ } else if (scan.mode === 'changes') {
55
+ const loaded = changes.loadRecords(root);
56
+ for (const p of loaded.problems) conflict(p.code, p.detail);
57
+ if (!loaded.problems.length) {
58
+ const owner = changes.ownerOf(loaded, prd);
59
+ if (owner && owner[0] !== id) conflict('INPUT_INVALID', `${prd} is owned by change "${owner[0]}" (${owner[1].file}); pass --change ${owner[0]}`);
60
+ else {
61
+ const rec = loaded.records.get(id);
62
+ if (rec && rec.record.prd !== prd) conflict('INPUT_INVALID', `${rec.file} names change "${id}" for ${rec.record.prd}; choose another --change ID for ${prd}`);
63
+ else if (rec) { existing = rec; source = 'record'; }
64
+ }
65
+ }
43
66
  }
44
67
  const set = parse.validateTicketSet(root);
45
68
  if (!set.ok) conflict('INPUT_INVALID', `pincer-ticket: ${set.file ? `${set.file}: ` : ''}${set.problems[0]}`);
@@ -55,9 +78,26 @@ function plan(root, { prd, change, authorization = null } = {}) {
55
78
  }
56
79
  }
57
80
  const gitignore = !gitignoreHas(root);
58
- const alreadyMigrated = Boolean(existing) && tickets.length === 0 && !gitignore;
59
- const partial = Boolean(existing) && tickets.length > 0;
60
- return { prd, change: id, authorization, conflicts, binding: { file: `.prd/changes/${id}.json`, exists: Boolean(existing), existing, action: existing ? (partial ? 'complete' : 'keep') : 'register' }, tickets, gitignore, alreadyMigrated, partial };
81
+ // Local pointers of a converted binding: candidate keys gain the change segment.
82
+ let index = null;
83
+ if (source === 'binding' && state.exists(root)) {
84
+ const read = state.readIndex(root);
85
+ if (read.error) conflict('INPUT_INVALID', read.error);
86
+ else {
87
+ const stale = Object.keys(read.index.current).filter(k => OLD_CANDIDATE_KEY.test(k));
88
+ if (stale.length) index = { stale, doc: read.index };
89
+ }
90
+ }
91
+ const sel = changes.readSelection(root);
92
+ const selection = sel.code || sel.change !== id ? id : null;
93
+ const alreadyMigrated = source === 'record' && tickets.length === 0 && !gitignore;
94
+ const partial = source === 'record' && tickets.length > 0;
95
+ return {
96
+ prd, change: id, source, authorization, conflicts, bindingFile,
97
+ binding: { file: changes.recordFile(id), existing: existing ? existing.record : binding, bindingFile, action: source === 'binding' ? 'convert' : source === 'record' ? (partial ? 'complete' : 'keep') : 'register' },
98
+ tickets, gitignore, index, selection, alreadyMigrated, partial,
99
+ historical: { attempts: state.exists(root) ? state.listAttempts(root).filter(a => a.schema === 1).length : 0 },
100
+ };
61
101
  }
62
102
 
63
103
  function renderPlan(p) {
@@ -68,23 +108,33 @@ function renderPlan(p) {
68
108
  return lines.join('\n') + '\n';
69
109
  }
70
110
  lines.push(`migration plan for ${p.prd} (change ${p.change})`);
71
- if (p.alreadyMigrated) { lines.push(' already migrated: binding present, no legacy receipts remain, .pincer/ ignored'); return lines.join('\n') + '\n'; }
72
- if (p.partial) lines.push(' note an earlier migration was partially applied (binding present, receipts remain); apply completes it');
73
- lines.push(` binding ${p.binding.file} (${p.binding.exists ? 'present, legacy_receipts extended' : 'new; base = HEAD'})`);
74
- for (const t of p.tickets) lines.push(` ticket ${t.file}: remove ${[t.verified ? 'verified' : null, t.last_check ? 'last_check' : null].filter(Boolean).join(', ')} → legacy_receipts[${t.id}] (history, not runtime evidence)`);
111
+ if (p.alreadyMigrated) { lines.push(' already migrated: change record present, no legacy receipts remain, .pincer/ ignored'); if (p.selection) lines.push(` selection .pincer/runtime/selection.json → ${p.change} is not set in this worktree; select with: node scripts/pincer-runtime.cjs change select ${p.change}`); return lines.join('\n') + '\n'; }
112
+ if (p.source === 'binding') {
113
+ const b = p.binding.existing;
114
+ lines.push(` binding ${p.bindingFile || p.binding.file} (v0.5.0, schema 1) → change record (schema 2) at the same path: change ${b.change}, base ${b.base.slice(0, 7)} and registration ${b.registered} kept; ${Object.keys(b.legacy_receipts).length} receipt(s) already imported carried over`);
115
+ if (b.authorization) lines.push(` note the binding's authorization text "${b.authorization}" is retained as legacy.authorization_text (unvalidated history); it never authorizes execution`);
116
+ } else if (p.source === 'record') lines.push(` record ${p.binding.file} (present; ${p.tickets.length} receipt(s) imported into legacy.receipts by a migrate event)`);
117
+ else lines.push(` record ${p.binding.file} (new schema 2 record; base = HEAD; planned)`);
118
+ if (p.partial) lines.push(' note an earlier migration was partially applied (record present, receipts remain); apply completes it');
119
+ for (const t of p.tickets) lines.push(` ticket ${t.file}: remove ${[t.verified ? 'verified' : null, t.last_check ? 'last_check' : null].filter(Boolean).join(', ')} → legacy.receipts[${t.id}] (history, not runtime evidence)`);
75
120
  if (!p.tickets.length) lines.push(' tickets no legacy receipts to import');
76
121
  lines.push(p.gitignore ? ` gitignore add \`${IGNORE_LINE}\`` : ' gitignore already ignores .pincer/');
77
- lines.push(` backups .pincer/backups/<timestamp>/ for every changed authored file`);
78
- if (!p.authorization) lines.push(' note no --authorization given; registration does not prove human approval');
122
+ if (p.index) lines.push(` index .pincer/runtime/index.json: ${p.index.stale.length} candidate pointer(s) dropped — a v0.5.0 attempt record cannot satisfy the change-scoped key, so its check is run again (attempt records untouched, kept as history)`);
123
+ lines.push(` history ${p.historical.attempts} existing attempt(s) and any saved evaluation stay history (HISTORICAL_EVIDENCE) until verified again; the change is planned with no authorization`);
124
+ if (p.selection) lines.push(` selection .pincer/runtime/selection.json → ${p.change} (this worktree only; a fresh clone selects explicitly)`);
125
+ lines.push(' backups .pincer/backups/<timestamp>/ for every changed authored file (tickets, .gitignore, the binding, the local index)');
126
+ if (p.authorization) lines.push(` note --authorization "${p.authorization}" is retained as legacy.authorization_text (unvalidated history); record the user's actual instruction afterwards with: node scripts/pincer-runtime.cjs change authorize ${p.change} --agreement <digest> --reference <text> --excerpt <text>`);
127
+ else if (!(p.source === 'binding' && p.binding.existing.authorization)) lines.push(` note no --authorization given; migration grants no authorization — record the user's instruction afterwards with: node scripts/pincer-runtime.cjs change authorize ${p.change} …`);
79
128
  lines.push(`apply with: node scripts/pincer-runtime.cjs migrate --apply --prd ${p.prd}${p.change !== `prd-v${p.prd.match(parse.PRD_REF)[1]}` ? ` --change ${p.change}` : ''}`);
80
129
  return lines.join('\n') + '\n';
81
130
  }
82
131
 
83
- // Apply the plan: backups, tickets, .gitignore, then the binding last.
132
+ // Apply the plan as one transaction: backups first (copies of the originals),
133
+ // then tickets, .gitignore, the record, the index and the selection together.
84
134
  function apply(root, options) {
85
- const p = plan(root, options);
86
- if (p.conflicts.length) return { plan: p, applied: false };
87
- if (p.alreadyMigrated) return { plan: p, applied: false, already: true };
135
+ const first = plan(root, options);
136
+ if (first.conflicts.length) return { plan: first, applied: false };
137
+ if (first.alreadyMigrated) return { plan: first, applied: false, already: true };
88
138
  const stamp = nowIso().replace(/[-:]/g, '');
89
139
  const backupDir = path.join(root, '.pincer', 'backups', stamp);
90
140
  const backups = [];
@@ -96,32 +146,59 @@ function apply(root, options) {
96
146
  fs.copyFileSync(src, dest);
97
147
  backups.push(`.pincer/backups/${stamp}/${rel}`);
98
148
  };
99
- const receipts = {};
100
- for (const t of p.tickets) {
101
- backup(t.file);
102
- receipts[t.id] = { verified: t.verified, last_check: t.last_check };
103
- let text = fmUnset(t.text, 'verified');
104
- text = fmUnset(text, 'last_check');
105
- atomicWrite(path.join(root, t.file), text);
106
- }
107
- if (p.gitignore) {
108
- backup('.gitignore');
109
- const file = path.join(root, '.gitignore');
110
- const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
111
- const lead = existing && !existing.endsWith('\n') ? '\n' : '';
112
- fs.appendFileSync(file, `${lead}${existing ? '\n' : ''}# pincer runtime state (added by migrate)\n${IGNORE_LINE}\n`);
113
- }
114
- let binding;
115
- if (p.binding.existing) {
116
- binding = { ...p.binding.existing, legacy_receipts: { ...p.binding.existing.legacy_receipts, ...receipts } };
117
- if (options.authorization) binding.authorization = options.authorization;
118
- } else {
119
- const registered = identity.register(root, { prd: p.prd, change: p.change, authorization: options.authorization || null });
120
- if (registered.code) return { plan: p, applied: false, error: registered.problem, backups };
121
- binding = { ...registered.binding, legacy_receipts: receipts };
149
+ try {
150
+ const out = transaction.run(root, { command: `migrate --apply ${options.prd}`, hooks: options.hooks || null }, ctx => {
151
+ const p = plan(root, options);
152
+ if (p.conflicts.length) ctx.refuse(p.conflicts[0].code, p.conflicts[0].detail);
153
+ if (p.alreadyMigrated) return { plan: p, already: true };
154
+ for (const t of p.tickets) backup(t.file);
155
+ if (p.gitignore) backup('.gitignore');
156
+ if (p.source === 'binding') backup(p.bindingFile);
157
+ if (p.index) backup(`${state.RUNTIME_DIR}/index.json`);
158
+ const receipts = {};
159
+ for (const t of p.tickets) {
160
+ receipts[t.id] = { verified: t.verified, last_check: t.last_check };
161
+ ctx.write(t.file, fmUnset(fmUnset(t.text, 'verified'), 'last_check'));
162
+ }
163
+ if (p.gitignore) {
164
+ const existing = ctx.text('.gitignore') || '';
165
+ const lead = existing && !existing.endsWith('\n') ? '\n' : '';
166
+ ctx.write('.gitignore', `${existing}${lead}${existing ? '\n' : ''}# pincer runtime state (added by migrate)\n${IGNORE_LINE}\n`);
167
+ }
168
+ let record;
169
+ if (p.source === 'record') {
170
+ record = JSON.parse(JSON.stringify(p.binding.existing));
171
+ record.legacy.receipts = { ...record.legacy.receipts, ...receipts };
172
+ if (options.authorization) record.legacy.authorization_text = options.authorization;
173
+ const sequence = record.sequence + 1;
174
+ record.events.push({ sequence, kind: 'migrate', from: record.lifecycle.state, to: record.lifecycle.state, at: ctx.now, reason: null, agreement: null, authorization: null, decision: null, replacement: null, note: `${Object.keys(receipts).length} legacy receipt(s) imported` });
175
+ record.sequence = sequence;
176
+ } else if (p.source === 'binding') {
177
+ const b = p.binding.existing;
178
+ record = changes.newRecord({ change: b.change, prd: b.prd, base: b.base, now: ctx.now, kind: 'migrate', legacy: { receipts: { ...b.legacy_receipts, ...receipts }, authorization_text: options.authorization || b.authorization || null, migrated_from: 'binding', migrated: ctx.now } });
179
+ record.registered = b.registered;
180
+ record.events[0].note = `converted from the v0.5.0 binding (registered ${b.registered})`;
181
+ } else {
182
+ record = changes.newRecord({ change: p.change, prd: p.prd, base: identity.head(root), now: ctx.now, kind: 'migrate', legacy: { receipts, authorization_text: options.authorization || null, migrated_from: 'legacy', migrated: ctx.now } });
183
+ }
184
+ const invalid = changes.validateRecord(record, p.binding.file);
185
+ if (invalid) ctx.refuse(invalid.code, invalid.problem);
186
+ ctx.write(p.binding.file, record);
187
+ if (p.index) {
188
+ const doc = p.index.doc;
189
+ for (const key of p.index.stale) delete doc.current[key];
190
+ ctx.write(`${state.RUNTIME_DIR}/index.json`, doc);
191
+ }
192
+ if (p.selection) ctx.write(changes.SELECTION_FILE, { schema: 1, change: p.change, selected: ctx.now });
193
+ return { plan: p, record };
194
+ });
195
+ if (out.result.already) return { plan: out.result.plan, applied: false, already: true };
196
+ return { plan: out.result.plan, applied: true, record: out.result.record, backups, backupDir: backups.length ? `.pincer/backups/${stamp}/` : null };
197
+ } catch (error) {
198
+ if (error.refusal) return { plan: first, applied: false, error: error.message, code: error.code, backups };
199
+ if (error.code === 'STATE_BUSY') return { plan: first, applied: false, error: error.message, code: 'STATE_BUSY', backups };
200
+ throw error;
122
201
  }
123
- identity.writeBinding(root, binding);
124
- return { plan: p, applied: true, binding, backups, backupDir: backups.length ? `.pincer/backups/${stamp}/` : null };
125
202
  }
126
203
 
127
204
  module.exports = { plan, renderPlan, apply, IGNORE_LINE, gitignoreHas };
@@ -252,6 +252,25 @@ function normalizePrd(text) {
252
252
  }
253
253
  return `${out.join('\n')}\n`;
254
254
  }
255
+ // Split a ticket text into the parts the structural agreement difference names:
256
+ // the frontmatter, the Acceptance Criteria section, the Verification section and
257
+ // everything else (headings inside fenced blocks are ignored, as elsewhere).
258
+ function ticketSections(text) {
259
+ const rows = lines(text);
260
+ const parts = { frontmatter: [], acceptance: [], verification: [], other: [] };
261
+ let inFront = rows[0] === '---', section = 'other', fence = false;
262
+ for (let i = 0; i < rows.length; i++) {
263
+ const line = rows[i];
264
+ if (inFront) { parts.frontmatter.push(line); if (i > 0 && line === '---') inFront = false; continue; }
265
+ if (fence) { parts[section].push(line); if (/^[ \t]*```/.test(line)) fence = false; continue; }
266
+ if (/^[ \t]*```/.test(line)) { fence = true; parts[section].push(line); continue; }
267
+ if (/^## Acceptance Criteria[ \t]*$/.test(line)) section = 'acceptance';
268
+ else if (/^## Verification[ \t]*$/.test(line)) section = 'verification';
269
+ else if (/^## /.test(line)) section = 'other';
270
+ parts[section].push(line);
271
+ }
272
+ return Object.fromEntries(Object.entries(parts).map(([k, v]) => [k, v.join('\n')]));
273
+ }
255
274
  const ticketDigest = text => sha256(normalizeTicket(text));
256
275
  const prdDigest = text => sha256(normalizePrd(text));
257
276
  const checkDigest = (text, timeoutSeconds = DEFAULT_TIMEOUT) => sha256(`${blockText(text)}timeout=${timeoutSeconds}\n`);
@@ -292,6 +311,6 @@ module.exports = {
292
311
  sha256, lines, normalizeId, canonicalId, parseFrontmatter, frontmatterField,
293
312
  verificationCommands, blockText, legacyBlockHash, unticked, effectiveTimeout,
294
313
  validateTicket, dependencies, validateMetadata, validatePrd,
295
- normalizeTicket, normalizePrd, ticketDigest, prdDigest, checkDigest,
314
+ normalizeTicket, normalizePrd, ticketDigest, prdDigest, checkDigest, ticketSections,
296
315
  ticketFiles, ticketFile, validateTicketSet,
297
316
  };
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+ // PINCER runtime — phase-specific coverage (docs/runtime-contracts.md, "Strict
3
+ // coverage" → "Phase-specific coverage"). One pure computation over the validated
4
+ // graph, consumed by `coverage`, `change complete`, `evidence export`, `ready`,
5
+ // status and resume: `structure` (every obligation linked or authorized as not
6
+ // delivered, nothing missing from the baseline), `implementation` (structure plus
7
+ // every ticket done and ready under the v5 rules) and `candidate` (the evaluated
8
+ // candidate's reconciled evidence). The three are separate fields; none implies
9
+ // another, and a linked check, a done ticket or a passing syntax check never
10
+ // becomes a delivery or adequacy verdict. A change without the capability is
11
+ // labeled `unverified`. Nothing here writes, launches or judges semantics.
12
+ const changes = require('./changes.cjs');
13
+ const routing = require('./routing.cjs');
14
+ const agreement = require('./agreement.cjs');
15
+ const authorization = require('./authorization.cjs');
16
+ const coverage = require('./coverage.cjs');
17
+ const dispositions = require('./dispositions.cjs');
18
+ const requirements = require('./requirements.cjs');
19
+ const locator = require('./locator.cjs');
20
+ const state = require('./state.cjs');
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ const STRUCTURE_ORDER = ['INPUT_INVALID', 'INVENTORY_INVALID', 'COVERAGE_INVALID', 'COVERAGE_INCOMPLETE', 'SCOPE_UNAUTHORIZED', 'OBLIGATION_MISSING'];
25
+ const byOrder = (a, b) => STRUCTURE_ORDER.indexOf(a.code) - STRUCTURE_ORDER.indexOf(b.code);
26
+
27
+ const unverified = reason => ({ strict: false, label: 'unverified', reason, inventory: null, map: null, graph: null, scope: [], structure: null, implementation: null, candidate: null, blockers: [] });
28
+
29
+ // compute(root, record, { gathered, verdict }) — `gathered` is status.render(...).gathered
30
+ // for the record (computed here when absent); `verdict` the authorization verdict.
31
+ function compute(root, record, { gathered = null, verdict = null } = {}) {
32
+ if (!record) return unverified('no change record');
33
+ if (!changes.isStrict(record)) return unverified(`strict coverage not adopted (node scripts/pincer-runtime.cjs coverage adopt --preview --change ${record.change})`);
34
+ const out = { strict: true, label: 'strict', reason: null, inventory: null, map: null, graph: null, scope: [], structure: { complete: false, problems: [] }, implementation: { complete: false, scenarios: {}, problems: [] }, candidate: { evaluated: false, candidate: null, manifest: null, delivery: null, adequacy: null, scenarios: {}, problems: [] }, blockers: [] };
35
+ const priorInventory = gid => agreement.inventoryOf(root, record, record.agreements.find(g => g.id === gid) || null);
36
+ const cov = coverage.load(root, record, { priorInventory });
37
+ out.inventory = cov.inventory ? { digest: cov.inventory.digest, requirements: Object.fromEntries(Object.entries(cov.inventory.requirements).map(([id, r]) => [id, { title: r.title, line: r.line, end: r.end, scenarios: r.scenarios }])), scenarios: Object.fromEntries(Object.entries(cov.inventory.scenarios).map(([id, s]) => [id, { requirement: s.requirement, line: s.line, end: s.end }])) } : null;
38
+ out.map = cov.map ? { path: cov.map.file, digest: cov.map.digest, checks: Object.fromEntries(Object.entries(cov.map.map.checks).map(([id, c]) => [id, { kind: c.kind, required: c.required, declared: coverage.definitionDigest(c) }])) } : null;
39
+ if (cov.code && cov.code !== 'COVERAGE_INCOMPLETE') {
40
+ out.structure.problems = cov.problems.map(detail => ({ code: cov.code, detail, ids: [] }));
41
+ out.blockers = out.structure.problems.map(p => ({ code: p.code, detail: p.detail }));
42
+ return out;
43
+ }
44
+ out.graph = cov.graph;
45
+ const v = verdict || authorization.verdict(root, record);
46
+ const scope = dispositions.scopeProblems(record, cov.graph, v);
47
+ out.scope = scope.resolved;
48
+ const problems = [...cov.graph.problems, ...scope.problems, ...dispositions.obligationProblems(dispositions.baseline(root, record), cov.inventory, cov.map.map)].sort(byOrder);
49
+ out.structure = { complete: problems.length === 0, problems };
50
+ // Implementation: every in-scope scenario's tickets done and ready; every ticket of the change done and ready.
51
+ const g = gathered || require('./status.cjs').render(root, { change: record.change }).gathered;
52
+ // A fresh clone (no local attempt history) of a completed change validates the saved
53
+ // candidate record only: a done ticket's missing local attempt is its stated limit, not
54
+ // unfinished work. Before completion the v5 rule holds: a fresh clone must verify.
55
+ const localUnavailable = !state.hasIndex(root) && record.lifecycle.state === 'completed';
56
+ out.implementation.limitations = localUnavailable ? ['local verification history unavailable; done tickets rely on the saved candidate evidence until verified here'] : [];
57
+ const ticketState = id => {
58
+ const t = g && g.tickets ? g.tickets.find(x => x.fields.ticket === id) : null;
59
+ if (!t) return { id, status: null, ready: false, code: 'EVIDENCE_MISSING', detail: 'no ticket file' };
60
+ const r = g.computeReadiness(t);
61
+ if (localUnavailable && t.fields.status === 'done' && !r.ready && r.reasons[0].code === 'EVIDENCE_MISSING') return { id, status: 'done', ready: true, code: null, detail: null, limitation: 'no local attempt history' };
62
+ return { id, status: t.fields.status, ready: r.ready, code: r.ready ? null : r.reasons[0].code, detail: r.ready ? null : `${r.reasons[0].detail} — ${r.reasons[0].next}` };
63
+ };
64
+ const implProblems = [];
65
+ const seen = new Set();
66
+ for (const id of requirements.sortIds(Object.keys(cov.graph.scenarios))) {
67
+ const s = cov.graph.scenarios[id];
68
+ const tickets = s.tickets.map(ticketState);
69
+ const unfinished = tickets.find(t => t.status !== 'done');
70
+ const unverifiedT = tickets.find(t => t.status === 'done' && !t.ready);
71
+ const state = unfinished ? 'unfinished' : unverifiedT ? 'unverified' : 'complete';
72
+ const detail = unfinished ? `${unfinished.id} is ${unfinished.status || 'missing'}, not done` : unverifiedT ? `${unverifiedT.id} ${unverifiedT.code}: ${unverifiedT.detail}` : null;
73
+ out.implementation.scenarios[id] = { scope: 'in-scope', requirement: s.requirement, implementation: state, tickets, checks: s.checks, detail };
74
+ if (state !== 'complete' && !seen.has(detail)) { seen.add(detail); implProblems.push({ code: unfinished ? 'LIFECYCLE_BLOCKED' : unverifiedT.code, detail: `${id}: ${detail}`, ids: [id, (unfinished || unverifiedT).id] }); }
75
+ }
76
+ for (const id of requirements.sortIds(Object.keys(cov.graph.scope))) {
77
+ const s = cov.graph.scope[id];
78
+ out.implementation.scenarios[id] = { scope: s.disposition, requirement: s.requirement, implementation: 'not applicable', tickets: [], checks: [], detail: `${s.disposition} by decision ${s.decision}` };
79
+ }
80
+ // Enabling tickets and any other ticket of the change must be done and ready too.
81
+ for (const id of Object.keys(cov.tickets || {})) {
82
+ const t = ticketState(id);
83
+ if (t.status === 'done' && t.ready) continue;
84
+ const detail = t.status !== 'done' ? `${id} is ${t.status}, not done` : `${id} ${t.code}: ${t.detail}`;
85
+ if (!implProblems.some(p => p.ids.includes(id))) implProblems.push({ code: t.status !== 'done' ? 'LIFECYCLE_BLOCKED' : t.code, detail, ids: [id] });
86
+ }
87
+ out.implementation.problems = implProblems;
88
+ out.implementation.complete = out.structure.complete && implProblems.length === 0;
89
+ candidateCoverage(root, record, out.candidate);
90
+ // A missing or stale evaluation blocks only a completed change: before completion no candidate is expected.
91
+ out.blockers = [...out.structure.problems, ...implProblems, ...(record.lifecycle.state === 'completed' ? out.candidate.problems : [])].map(p => ({ code: p.code, detail: p.detail }));
92
+ return out;
93
+ }
94
+
95
+ // Candidate coverage: the change's latest evaluation (schema 3) reconciled by the
96
+ // locator, with per-scenario dispositions and the codes that block release.
97
+ function candidateCoverage(root, record, out) {
98
+ const loc = locator.current(root, record);
99
+ if (loc.state === 'missing') { out.problems = [{ code: 'EVIDENCE_MISSING', detail: 'candidate: not evaluated', ids: [] }]; return; }
100
+ out.evaluated = true; out.candidate = loc.candidate || null; out.manifest = loc.manifest || null;
101
+ let m = null;
102
+ try { m = JSON.parse(fs.readFileSync(path.join(root, loc.manifest), 'utf8')); } catch { m = null; }
103
+ const problems = [];
104
+ if (loc.state === 'stale') problems.push({ code: 'CANDIDATE_STALE', detail: loc.text, ids: [] });
105
+ if (!m || m.schema !== 3) { problems.push({ code: 'EVIDENCE_MISSING', detail: `candidate evidence is ${m && m.schema ? `schema ${m.schema}` : 'unreadable'}, not the strict schema 3; evaluate again`, ids: [] }); out.problems = problems; return; }
106
+ const checkOf = Object.fromEntries((m.checks || []).filter(c => c && c.id).map(c => [c.id, c]));
107
+ for (const row of m.scenarios || []) {
108
+ if (!row || !row.id) continue;
109
+ const checks = (row.checks || []).map(id => { const c = checkOf[id] || {}; return { id, kind: c.kind || null, required: c.required ?? null, result: c.result || null }; });
110
+ const failing = checks.filter(c => c.required && c.result !== 'passed');
111
+ out.scenarios[row.id] = { disposition: row.disposition, checks, detail: row.disposition === 'blocked' ? `blocked by ${failing.map(c => `${c.id} (${c.kind} ${c.result || 'missing'})`).join(', ') || 'a missing check'}` : row.disposition === 'delivered' ? 'every required check passed' : `${row.disposition} by decision ${row.decision} (${row.authorization || 'unauthorized'})` };
112
+ for (const c of failing) problems.push({ code: c.kind === 'command' ? ({ failed: 'CHECK_FAILED', unverified: 'ATTEMPT_ERROR' }[c.result] || 'CHECK_FAILED') : 'REVIEW_MISSING', detail: `${row.id}: ${c.id} (${c.kind}) is ${c.result || 'missing'}`, ids: [row.id, c.id] });
113
+ }
114
+ out.delivery = m.delivery || null;
115
+ out.adequacy = m.adequacy || null;
116
+ if (!m.adequacy || m.adequacy.verdict !== 'adequate') problems.push({ code: 'ADEQUACY_REQUIRED', detail: m.adequacy ? `the reviewer judged the checks inadequate: ${m.adequacy.note}` : 'no adequacy judgment recorded', ids: [] });
117
+ out.problems = problems;
118
+ }
119
+
120
+ // The first blocking problem for a phase, in the contract's order, or null.
121
+ function firstBlocker(report, phase) {
122
+ if (!report.strict) return null;
123
+ if (report.structure.problems.length) return report.structure.problems[0];
124
+ if (phase === 'structure') return null;
125
+ if (report.implementation.problems.length) return report.implementation.problems[0];
126
+ if (phase === 'implementation') return null;
127
+ return report.candidate.problems[0] || null;
128
+ }
129
+
130
+ // --- The coverage report (docs/runtime-contracts.md, "Coverage and impact commands") ------
131
+ const RUNTIME_CMD = 'node scripts/pincer-runtime.cjs';
132
+ // One ordered next action for a strict change: the first blocking code, else the
133
+ // phase that is due (verify/start/complete/evaluate/release), or nothing to do.
134
+ function nextAction(report, record, { verdict = null, running = [], selected = true } = {}) {
135
+ const cmd = (action, command, extra = {}) => ({ action, command, ticket: null, check: null, decision: null, ...extra });
136
+ if (!report.strict) return cmd('adopt strict coverage when wanted', `${RUNTIME_CMD} coverage adopt --preview --change ${record.change}`);
137
+ const id = record.change;
138
+ // Rules 2 and 3 outrank everything below, including the agreement verdict: a
139
+ // historical change is never routed to execution or to authorization, and a
140
+ // running attempt is waited for rather than raced.
141
+ const ahead = routing.preface({ id, lifecycle: record.lifecycle, base: record.base, running });
142
+ if (ahead) return { ...cmd(ahead.action, ahead.command), ...ahead };
143
+ if (verdict && verdict.verdict !== 'current') {
144
+ if (verdict.verdict === 'DECISION_REQUIRED') return cmd("record the user's decision", `${RUNTIME_CMD} change decide ${id} --resolve ${verdict.open[0]} --reference <text> --excerpt <text>`, { decision: verdict.open[0] });
145
+ if (['AUTHORIZATION_REQUIRED', 'AGREEMENT_CHANGED'].includes(verdict.verdict)) return cmd("record the user's authorization of the current agreement", `${RUNTIME_CMD} change authorize ${id} --agreement ${verdict.current} --reference <text> --excerpt <text>`);
146
+ if (verdict.verdict === 'COVERAGE_INVALID') return cmd('repair the coverage map', verdict.detail);
147
+ if (verdict.verdict === 'INVENTORY_INVALID') return cmd('repair the PRD definitions', verdict.detail);
148
+ return cmd('repair the agreement inputs', `${verdict.verdict}: ${verdict.detail}`);
149
+ }
150
+ const p = report.structure.problems[0];
151
+ if (p) {
152
+ if (p.code === 'INVENTORY_INVALID') return cmd('repair the PRD definitions', p.detail);
153
+ if (p.code === 'COVERAGE_INVALID') return cmd('repair the coverage map', p.detail);
154
+ if (p.code === 'COVERAGE_INCOMPLETE') return cmd('author the missing coverage rows, then authorize', `edit .prd/coverage/${id}.json: ${p.detail}; then ${RUNTIME_CMD} change authorize ${id} --agreement <digest> …`);
155
+ if (p.code === 'SCOPE_UNAUTHORIZED') return cmd('record the scope decision and its user authorization', `${RUNTIME_CMD} change decide ${id} --summary <text> / --resolve D-NN …, then change authorize ${id} --agreement <digest> --decision D-NN …`, { decision: p.decision || null });
156
+ if (p.code === 'OBLIGATION_MISSING') return cmd('restore the obligation, or record the decision and the removed tombstone', p.detail);
157
+ return cmd('repair the coverage inputs', `${p.code}: ${p.detail}`);
158
+ }
159
+ const staged = routing.lifecycleAction({ id, lifecycle: record.lifecycle });
160
+ if (staged) return { ...cmd(staged.action, staged.command), ...staged };
161
+ const i = report.implementation.problems[0];
162
+ if (i) {
163
+ const ticket = i.ids.find(x => /^T-/.test(x)) || null;
164
+ if (i.code === 'LIFECYCLE_BLOCKED' && ticket) return cmd(`finish ${ticket}`, `scripts/pincer-ticket.sh start ${ticket} → verify ${ticket} → done ${ticket}`, { ticket });
165
+ if (ticket) return cmd(`re-verify ${ticket} (${i.code})`, `scripts/pincer-ticket.sh verify ${ticket}`, { ticket });
166
+ return cmd('finish the mapped work', i.detail);
167
+ }
168
+ if (record.lifecycle.state === 'active') return cmd('complete the change', `${RUNTIME_CMD} change complete ${id}`);
169
+ const c = report.candidate.problems[0];
170
+ if (c) {
171
+ const check = c.ids.find(x => /^C-/.test(x)) || null;
172
+ if (c.code === 'EVIDENCE_MISSING' && !report.candidate.evaluated) return cmd('evaluate the candidate', '/pincer-evaluate (declare the candidate, run every declared check, record the reviews and the adequacy judgment, export)');
173
+ if (['CHECK_FAILED', 'ATTEMPT_ERROR'].includes(c.code) && check) return cmd(`re-run ${check} and re-evaluate`, `${RUNTIME_CMD} check ${check} --candidate <sha>, then /pincer-evaluate`, { check });
174
+ if (c.code === 'REVIEW_MISSING' && check) return cmd(`perform and record the review ${check}`, `record ${check} with its candidate-bound artifact in the draft, then /pincer-evaluate`, { check });
175
+ if (c.code === 'ADEQUACY_REQUIRED') return cmd("record the reviewer's adequacy judgment", '/pincer-evaluate with adequacy { verdict, note } in the draft');
176
+ return cmd('evaluate the candidate again', `${c.code}: ${c.detail}`);
177
+ }
178
+ return cmd('read-only release audit', '/pincer-release');
179
+ }
180
+
181
+ // report(root, record, { gathered, verdict, generated }) → coverage JSON schema 1.
182
+ function report(root, record, { gathered = null, verdict = null, generated = null, selected = true } = {}) {
183
+ const authorization = require('./authorization.cjs');
184
+ const running = record ? require('./transaction.cjs').runningAttempts(root, record.change) : [];
185
+ const v = verdict || (record ? authorization.verdict(root, record) : null);
186
+ const r = compute(root, record, { gathered, verdict: v });
187
+ const reviewed = v && v.authorized && record ? (() => { const g = record.agreements.find(x => x.id === v.authorized.agreement); return g ? { agreement: g.id, authorization: v.authorized.id, digest: g.digest } : null; })() : null;
188
+ const base = record && r.strict ? require('./dispositions.cjs').baseline(root, record) : null;
189
+ const out = {
190
+ schema: 1, runtime: changes.RUNTIME_STRICT, generated: generated || require('./fsutil.cjs').nowIso(), root, mode: 'changes', change: record ? record.change : null,
191
+ strict: r.strict, label: r.label, reason: r.reason,
192
+ inventory: r.inventory ? { digest: r.inventory.digest, requirements: Object.entries(r.inventory.requirements).map(([id, x]) => ({ id, title: x.title, line: x.line, end: x.end, scenarios: x.scenarios })), scenarios: Object.entries(r.inventory.scenarios).map(([id, x]) => ({ id, requirement: x.requirement, line: x.line, end: x.end })) } : null,
193
+ map: r.map ? { path: r.map.path, digest: r.map.digest, checks: Object.entries(r.map.checks).map(([id, c]) => ({ id, ...c })) } : null,
194
+ agreement: { current: v && v.current ? v.current : null, reviewed, verdict: v ? v.verdict : null },
195
+ baseline: base ? { agreements: base.agreements, scenarios: Object.keys(base.scenarios) } : null,
196
+ structure: r.structure, implementation: r.implementation, candidate: r.candidate, scope: r.scope,
197
+ blockers: [
198
+ ...(running.length ? [{ code: 'ATTEMPT_RUNNING', detail: `attempt ${running[0].id} of change ${record.change} is running${running[0].alive === false ? ' (its owner is no longer running)' : ''}` }] : []),
199
+ ...(v && v.verdict !== 'current' ? [{ code: v.verdict, detail: v.detail }] : []), ...r.blockers],
200
+ next: routing.qualify(nextAction(r, record, { verdict: v, running, selected }), { id: record ? record.change : null, selected }),
201
+ };
202
+ return out;
203
+ }
204
+
205
+ function render(j) {
206
+ const lines = [];
207
+ const short = s => (typeof s === 'string' ? s.slice(0, 12) : '—');
208
+ lines.push(`PINCER coverage · ${j.generated} · ${j.root}`);
209
+ lines.push(`Change ${j.change || 'none'} · coverage ${j.label}${j.reason ? ` (${j.reason})` : ''}`);
210
+ if (j.strict) {
211
+ lines.push(`Agreement current ${short(j.agreement.current)} · reviewed ${j.agreement.reviewed ? `${j.agreement.reviewed.agreement} (${j.agreement.reviewed.authorization}, ${short(j.agreement.reviewed.digest)})` : 'none'} · verdict ${j.agreement.verdict}`);
212
+ lines.push(`Inventory ${j.inventory ? `${j.inventory.requirements.length} requirement(s), ${j.inventory.scenarios.length} scenario(s) · ${short(j.inventory.digest)}` : 'unreadable'}`);
213
+ lines.push(`Map ${j.map ? `${j.map.path} · ${short(j.map.digest)} · checks ${j.map.checks.map(c => `${c.id} (${c.kind}${c.required ? ', required' : ''})`).join(', ')}` : 'unreadable'}`);
214
+ lines.push(`Structure ${j.structure.complete ? 'complete' : `incomplete: ${j.structure.problems.map(p => `${p.code} ${p.detail}`).join('; ')}`}`);
215
+ const sc = Object.entries(j.implementation.scenarios);
216
+ lines.push(`Implementation ${j.implementation.complete ? 'complete' : 'incomplete'} · ${sc.filter(([, s]) => s.implementation === 'complete').length}/${sc.filter(([, s]) => s.scope === 'in-scope').length} in-scope scenario(s) complete${sc.some(([, s]) => s.scope !== 'in-scope') ? ` · ${sc.filter(([, s]) => s.scope !== 'in-scope').map(([id, s]) => `${id} ${s.scope}`).join(', ')}` : ''}`);
217
+ for (const [id, s] of sc) lines.push(` ${id.padEnd(6)} ${s.scope === 'in-scope' ? s.implementation.padEnd(14) : s.scope.padEnd(14)} ${s.scope === 'in-scope' ? `tickets ${s.tickets.map(t => `${t.id} ${t.status || 'missing'}${t.ready ? '' : t.code ? ` (${t.code})` : ''}`).join(', ')} · checks ${s.checks.join(', ')}` : s.detail}${j.candidate.scenarios[id] ? ` · candidate ${j.candidate.scenarios[id].disposition}` : ''}`);
218
+ const c = j.candidate;
219
+ lines.push(`Candidate ${c.evaluated ? `${c.candidate ? c.candidate.slice(0, 7) : '?'} · delivery original ${c.delivery ? c.delivery.original : '?'}, agreed ${c.delivery ? c.delivery.agreed : '?'} · adequacy ${c.adequacy ? `${c.adequacy.verdict} ("${c.adequacy.note}")` : 'not recorded'}` : 'not evaluated · adequacy: not recorded'}${c.problems.length ? ` · ${c.problems.map(p => `${p.code} ${p.detail}`).join('; ')}` : ''}`);
220
+ if (j.scope.length) lines.push(`Scope ${j.scope.map(s => `${s.id} ${s.disposition} by ${s.decision} (${s.authorization}; reviewer judgment: "${s.excerpt}" must support it)`).join('; ')}`);
221
+ }
222
+ lines.push(`Blockers ${j.blockers.length ? j.blockers.map(b => `${b.code} ${b.detail}`).join('\n ') : 'none'}`);
223
+ lines.push(`Next ${j.next.action}: ${j.next.command}`);
224
+ return `${lines.join('\n')}\n`;
225
+ }
226
+
227
+ // A one-line summary for status and resume.
228
+ function summary(r) {
229
+ if (!r.strict) return { strict: false, label: 'unverified', reason: r.reason, structure: null, implementation: null, candidate: null };
230
+ const sc = Object.values(r.implementation.scenarios);
231
+ return {
232
+ strict: true, label: 'strict', reason: null,
233
+ structure: { complete: r.structure.complete, problems: r.structure.problems.map(p => ({ code: p.code, detail: p.detail })) },
234
+ implementation: { complete: r.implementation.complete, scenarios: { total: sc.length, complete: sc.filter(s => s.implementation === 'complete').length, unfinished: sc.filter(s => s.implementation === 'unfinished').length, unverified: sc.filter(s => s.implementation === 'unverified').length, dispositioned: sc.filter(s => s.scope !== 'in-scope').length } },
235
+ candidate: r.candidate.evaluated ? { evaluated: true, delivery: r.candidate.delivery, adequacy: r.candidate.adequacy ? r.candidate.adequacy.verdict : null } : { evaluated: false, delivery: null, adequacy: null },
236
+ };
237
+ }
238
+ function summaryLine(r, record) {
239
+ if (!r.strict) return `Coverage unverified · ${r.reason}`;
240
+ const s = summary(r);
241
+ const reviewed = record && record.authorizations.length ? `${record.authorizations.at(-1).agreement} (${record.authorizations.at(-1).id})` : 'none';
242
+ return `Coverage strict · agreement ${reviewed} · structure ${s.structure.complete ? 'complete' : `incomplete (${s.structure.problems[0].code})`} · implementation ${s.implementation.scenarios.complete}/${s.implementation.scenarios.total - s.implementation.scenarios.dispositioned} scenarios${s.implementation.scenarios.dispositioned ? ` (${s.implementation.scenarios.dispositioned} dispositioned)` : ''} · candidate ${s.candidate.evaluated ? `${s.candidate.delivery ? `delivery original ${s.candidate.delivery.original}, agreed ${s.candidate.delivery.agreed}` : 'delivery not recorded (evidence predates strict coverage)'}, adequacy ${s.candidate.adequacy || 'not recorded'}` : 'not evaluated'}`;
243
+ }
244
+
245
+ module.exports = { STRUCTURE_ORDER, compute, firstBlocker, nextAction, report, render, summary, summaryLine };
@@ -39,7 +39,7 @@ function legacyTicketReadiness(text, fields) {
39
39
  // `contextKey` is the key the attempt was read for and `pointedId` the id the
40
40
  // index names for it (the record must match both); the attempt's artifacts
41
41
  // carry `missing`/`altered` from state.inspectArtifacts.
42
- function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt, current, sourceProblems = [], changedPaths = [], contextKey = null, pointedId = null }) {
42
+ function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt, current, sourceProblems = [], changedPaths = [], contextKey = null, pointedId = null, mode = 'migrated', strict = false }) {
43
43
  const reasons = [];
44
44
  for (const p of sourceProblems) reasons.push(reason(p.code, p.detail, p.code === 'SECRET_PATH' ? 'remove or ignore the secret file' : 'remove the input or change the configuration'));
45
45
  if (reasons.length) return { ready: false, reasons };
@@ -52,6 +52,14 @@ function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt
52
52
  // outcome is honored: a stripped or foreign record is never a pass.
53
53
  const invalid = validateAttempt(attempt, contextKey, pointedId);
54
54
  if (invalid) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} ${invalid}`, 'verify')] };
55
+ // A record written before the migration to change records keeps its identity
56
+ // as history; it never becomes current evidence for the new change context.
57
+ if (mode === 'changes' && attempt.schema === 1) return { ready: false, reasons: [reason('HISTORICAL_EVIDENCE', `attempt ${id} was recorded under schema ${attempt.schema} (before this project used change records) and is history, not current evidence`, 'verify')] };
58
+ // Strict coverage (schema 3 attempts): an attempt recorded before adoption is history;
59
+ // a strict attempt read for a change without the capability is never evidence.
60
+ if (mode === 'changes' && strict && attempt.schema !== 3) return { ready: false, reasons: [reason('HISTORICAL_EVIDENCE', `attempt ${id} was recorded under schema ${attempt.schema} (before this change adopted strict coverage) and is history, not current evidence`, 'verify')] };
61
+ if (mode === 'changes' && !strict && attempt.schema === 3) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} is a strict-coverage (schema 3) attempt; this change has not adopted strict coverage`, 'verify')] };
62
+ if (mode !== 'changes' && attempt.schema !== 1) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} is a change-record (schema ${attempt.schema}) attempt; this project is not in changes mode`, 'verify')] };
55
63
  switch (attempt.outcome) {
56
64
  case 'running': return { ready: false, reasons: [reason('ATTEMPT_RUNNING', `attempt ${id} is running`, 'wait, or run recover if its owner died')] };
57
65
  case 'interrupted': return { ready: false, reasons: [reason('ATTEMPT_INTERRUPTED', `attempt ${id} was interrupted`, 'verify')] };
@@ -62,7 +70,12 @@ function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt
62
70
  default: return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} has unknown outcome ${JSON.stringify(attempt.outcome)}`, 'verify')] };
63
71
  }
64
72
  if (current.prdRevision && attempt.context && attempt.context.prd_revision !== current.prdRevision) {
65
- reasons.push(reason('REVISION_CHANGED', 'the PRD revision changed since the passing attempt', 'register --rebind, then verify'));
73
+ // The remedy is mode-specific. `register --rebind` is the v0.4.1/v0.5.0 repair and
74
+ // changes mode refuses it outright (AGREEMENT_CHANGED: "--rebind is not supported
75
+ // for change records"), so naming it there sends the reader to a command the
76
+ // runtime will not run — the defect class T-80 closed for the coverage report.
77
+ reasons.push(reason('REVISION_CHANGED', 'the PRD revision changed since the passing attempt',
78
+ mode === 'changes' ? 'change revise, record its authorization, then verify' : 'register --rebind, then verify'));
66
79
  }
67
80
  if (attempt.check && attempt.check.digest !== parse.checkDigest(text, timeout)) {
68
81
  reasons.push(reason('CHECK_CHANGED', 'the Verification block or timeout changed since the passing attempt', 'verify'));