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
@@ -79,18 +79,34 @@ class Capture {
79
79
  // Run one attempt. `context` is the attempt context (kind, change, prd, prd_revision,
80
80
  // base, ticket/ticket_digest or candidate/check); `commands` the block lines;
81
81
  // `timeoutSeconds` the effective timeout; `echo` when the output should also reach
82
- // the terminal. Returns { attempt } or { code, problem } for refusals before launch.
83
- async function runAttempt({ root, context, commands, timeoutSeconds, command = 'verify', echo = true, declared = {} }) {
82
+ // the terminal. `revalidate`, when given, is called under the worktree lock
83
+ // immediately before the `running` record is written and returns the context to
84
+ // record (docs/runtime-contracts.md, "Command gates"): the gates the caller passed
85
+ // before the lock are evaluated again there, so a transition, revision or
86
+ // authorization committed in between is seen and the attempt is refused (a thrown
87
+ // transaction Refusal becomes { code, problem, refused: true }) or recorded against
88
+ // the current agreement. `announce` is called once the record is registered and
89
+ // before the launch, so a refusal prints nothing. Returns { attempt } or { code,
90
+ // problem } for refusals before launch; nothing is written for a refusal.
91
+ async function runAttempt({ root, context, commands, timeoutSeconds, command = 'verify', echo = true, declared = {}, revalidate = null, announce = null, cwd = null }) {
84
92
  const block = commands.length ? `${commands.join('\n')}\n` : '';
85
93
  const checkDigest = parse.sha256(`${block}timeout=${timeoutSeconds}\n`);
86
94
  const display = sanitizeText(block).text.slice(0, 2000);
87
95
  const before = source.snapshot(root);
88
96
  if (before.problems.length) return { code: before.problems[0].code, problem: before.problems.map(p => `${p.code}: ${p.detail}`).join('\n'), problems: before.problems };
89
97
 
90
- const key = state.contextKey(context);
91
98
  let attempt, logDir, relLogDir;
92
99
  try {
93
100
  state.withLock(root, () => {
101
+ // Finish any committed transaction before writing: its staged files would
102
+ // otherwise be renamed over this attempt and its index pointer later.
103
+ require('./transaction.cjs').recoverPending(root);
104
+ if (revalidate) context = revalidate();
105
+ const key = state.contextKey(context);
106
+ // Changes mode records (schema 2) carry the agreement digest; `mode` only
107
+ // selects the key format and is not part of the record.
108
+ const { mode, ...persisted } = context;
109
+ const schema = persisted.coverage ? 3 : persisted.agreement ? 2 : 1;
94
110
  const read = state.readIndex(root);
95
111
  if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
96
112
  const index = read.index;
@@ -101,10 +117,10 @@ async function runAttempt({ root, context, commands, timeoutSeconds, command = '
101
117
  logDir = path.join(root, relLogDir);
102
118
  fs.mkdirSync(logDir, { recursive: true });
103
119
  attempt = {
104
- schema: 1, runtime: 1, id, sequence, context,
120
+ schema, runtime: schema, id, sequence, context: persisted,
105
121
  check: { digest: checkDigest, display, timeout_seconds: timeoutSeconds },
106
122
  outcome: 'running', exit_code: null, signal: null,
107
- runner: runnerInfo(), cwd: '.',
123
+ runner: runnerInfo(), cwd: cwd || '.',
108
124
  environment: { os: `${os.platform()} ${os.release()}`, node: process.version, declared },
109
125
  started: nowIso(), finished: null,
110
126
  source: { before: before.digest, after: null, files: before.files.length, limitations: before.limitations },
@@ -119,11 +135,13 @@ async function runAttempt({ root, context, commands, timeoutSeconds, command = '
119
135
  state.writeIndex(root, index);
120
136
  }, { command });
121
137
  } catch (error) {
138
+ if (error && error.refusal) return { code: error.code, problem: error.message, refused: true };
122
139
  if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
123
140
  if (error.code === 'INVALID') return { code: 'INPUT_INVALID', problem: error.message };
124
141
  return { code: 'ATTEMPT_ERROR', problem: `cannot persist the attempt record: ${error.message}` };
125
142
  }
126
143
 
144
+ if (announce) announce(attempt);
127
145
  // Execute in a fresh process group so timeouts and signals reach every descendant.
128
146
  let child, launchError = null;
129
147
  const stdout = new Capture(path.join(logDir, 'stdout.log'), echo ? process.stdout : null);
@@ -162,7 +180,7 @@ async function runAttempt({ root, context, commands, timeoutSeconds, command = '
162
180
  let settled = false;
163
181
  settle = result => { if (!settled) { settled = true; resolve(result); } };
164
182
  try {
165
- child = spawn(runnerInfo().shell, [...RUNNER_ARGS, block], { cwd: root, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
183
+ child = spawn(runnerInfo().shell, [...RUNNER_ARGS, block], { cwd: cwd ? path.join(root, cwd) : root, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
166
184
  } catch (error) { launchError = error.message; return settle({ code: null, signal: null }); }
167
185
  child.on('error', error => { launchError = error.message; });
168
186
  if (child.pid) {
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+ // PINCER runtime — the coverage draft (docs/runtime-contracts.md, "Coverage draft").
3
+ // `coverage scaffold --change <id>` projects the validated PRD inventory, the change's
4
+ // tickets and any authored map into one reviewable draft. It exists because authoring
5
+ // `.prd/coverage/<id>.json` means transcribing every live scenario, every ticket role
6
+ // and every check declaration out of documents the runtime has already parsed; that
7
+ // transcription is the measured cost this removes (docs/prd-v7-pilots.md).
8
+ //
9
+ // What it must never do is decide. The draft carries no semantic judgment: an
10
+ // unresolved scenario stays unresolved, a check command is never invented, a ticket
11
+ // role is never guessed, and a scope disposition is never chosen. Candidate tickets
12
+ // and their existing verification text are listed as *material to read*, with their
13
+ // file provenance, never promoted into a link or a declaration.
14
+ //
15
+ // The envelope is `draft: 1` with no `schema` key, so `coverage.validateMap` refuses
16
+ // it: a draft written to disk is not a coverage map and cannot become one by being
17
+ // saved. The real map is authored by hand, validated by `coverage`, adopted by
18
+ // `coverage adopt` and authorized by `change authorize`, exactly as before.
19
+ //
20
+ // Pure and read-only: nothing here writes a file, launches a check, changes a
21
+ // selection or records an approval. The draft body carries no timestamp, so two calls
22
+ // on identical authored inputs produce byte-identical output.
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+ const parse = require('./parse.cjs');
26
+ const requirements = require('./requirements.cjs');
27
+ const coverage = require('./coverage.cjs');
28
+
29
+ const DRAFT = 1;
30
+ const KIND = 'coverage-draft';
31
+ const RUNTIME = 'node scripts/pincer-runtime.cjs';
32
+ // Unresolved codes, in report order. These describe the draft, not the runtime state:
33
+ // they say what a human still has to author, never what a gate will refuse.
34
+ const UNRESOLVED_ORDER = ['SCENARIO_UNLINKED', 'CHECK_UNDECLARED', 'TICKET_UNCLASSIFIED', 'SCENARIO_STALE', 'TICKET_FOREIGN'];
35
+
36
+ const AUTHORED = 'authored';
37
+ const UNRESOLVED = 'unresolved';
38
+
39
+ // The first line under a ticket heading, used as the objective a reviewer reads.
40
+ function firstLine(text, heading) {
41
+ const rows = parse.lines(text);
42
+ const i = rows.findIndex(l => l.trim() === heading);
43
+ if (i === -1) return null;
44
+ const next = rows.slice(i + 1).find(l => l.trim() !== '');
45
+ return next ? next.trim() : null;
46
+ }
47
+ // The IDs a ticket's own Context section claims ("- Implements: R-04." and
48
+ // "- Scenarios: S-10, S-11."). Classified against the live inventory rather than by
49
+ // prefix, because tickets name other tickets on the same lines — T-03 of the strict
50
+ // fixture says "Implements: none (enables T-01, T-02)", and reading that as two
51
+ // scenario claims would be the draft inventing a link out of prose. An ID the
52
+ // inventory does not define is not reported at all.
53
+ const CLAIM_LINE = /^[ \t]*[-+*][ \t]+(?:Implements|Scenarios|Requirements):/;
54
+ function claimsOf(text, inventory) {
55
+ const ids = new Set();
56
+ for (const row of parse.lines(text)) {
57
+ if (!CLAIM_LINE.test(row)) continue;
58
+ for (const id of row.match(new RegExp(requirements.ID, 'g')) || []) ids.add(id);
59
+ }
60
+ const claimed = [...ids];
61
+ return {
62
+ requirements: requirements.sortIds(claimed.filter(id => inventory.requirements[id])),
63
+ scenarios: requirements.sortIds(claimed.filter(id => inventory.scenarios[id])),
64
+ };
65
+ }
66
+ // The ticket's own Verification fence, verbatim, as provenance for a reviewer. It is
67
+ // quoted, never turned into a check declaration: whether it proves the scenario is a
68
+ // judgment the draft does not make.
69
+ function verificationOf(text) {
70
+ const commands = parse.verificationCommands(text);
71
+ return commands && commands.length ? commands.join('\n') : null;
72
+ }
73
+
74
+ // build(root, record) → { ok, code, problems, draft }
75
+ // `code` is set only when the inputs cannot be read; a missing map is the normal case
76
+ // this command exists for, not an error.
77
+ function build(root, record) {
78
+ const fail = (code, problems) => ({ ok: false, code, problems, draft: null });
79
+ const inv = requirements.readInventory(root, record.prd);
80
+ if (!inv.ok) return fail(inv.code, inv.problems);
81
+ const inventory = inv.inventory;
82
+
83
+ // An absent map is expected; an unreadable one is not — silently dropping authored
84
+ // content would be the one way this command could destroy work.
85
+ const rel = coverage.file(record.change);
86
+ const real = coverage.realFile(root, rel);
87
+ let authored = null;
88
+ if (!real) {
89
+ const m = coverage.readMap(root, record);
90
+ if (!m.ok) return fail(m.code, m.problems);
91
+ authored = m;
92
+ } else if (!real.endsWith(': missing')) {
93
+ return fail('COVERAGE_INVALID', [`${rel}: ${real.slice(rel.length + 2)}`]);
94
+ }
95
+
96
+ const t = coverage.ticketsOf(root, record);
97
+ if (!t.ok) return fail(t.code, t.problems);
98
+
99
+ const map = authored ? authored.map : null;
100
+ const unresolved = [];
101
+ const note = (code, id, detail) => unresolved.push({ code, id, detail });
102
+
103
+ // --- scenarios: every live scenario exactly once, authored rows preserved ----------
104
+ const scenarios = {};
105
+ const scope = {};
106
+ for (const id of requirements.sortIds(Object.keys(inventory.scenarios))) {
107
+ const live = inventory.scenarios[id];
108
+ const authoredScope = map && map.scope ? map.scope[id] : null;
109
+ if (authoredScope) {
110
+ scope[id] = { ...authoredScope, state: AUTHORED };
111
+ continue;
112
+ }
113
+ const row = map && map.scenarios ? map.scenarios[id] : null;
114
+ const tickets = row ? [...row.tickets] : [];
115
+ const checks = row ? [...row.checks] : [];
116
+ const resolved = Boolean(row) && tickets.length > 0 && checks.length > 0;
117
+ scenarios[id] = { requirement: live.requirement, state: resolved ? AUTHORED : UNRESOLVED, tickets, checks };
118
+ if (!row) note('SCENARIO_UNLINKED', id, `${id} (${live.requirement}) has no row in scenarios or scope`);
119
+ else if (!tickets.length) note('SCENARIO_UNLINKED', id, `${id} is linked to no ticket`);
120
+ else if (!checks.length) note('CHECK_UNDECLARED', id, `${id} is linked to no check`);
121
+ }
122
+ // A map row naming a scenario the inventory no longer defines is authored content:
123
+ // it is preserved and flagged, never deleted, because removing an obligation is a
124
+ // decision with its own disposition and authorization.
125
+ if (map) {
126
+ for (const id of requirements.sortIds(Object.keys(map.scenarios))) {
127
+ if (scenarios[id] || scope[id]) continue;
128
+ scenarios[id] = { requirement: null, state: UNRESOLVED, tickets: [...map.scenarios[id].tickets], checks: [...map.scenarios[id].checks] };
129
+ note('SCENARIO_STALE', id, `${id} has a row but is not a scenario of ${record.prd}`);
130
+ }
131
+ for (const id of requirements.sortIds(Object.keys(map.scope))) {
132
+ if (scope[id] || inventory.scenarios[id]) continue;
133
+ scope[id] = { ...map.scope[id], state: UNRESOLVED };
134
+ note('SCENARIO_STALE', id, `${id} has a scope row but is not a scenario of ${record.prd}`);
135
+ }
136
+ }
137
+
138
+ // --- tickets: authored roles preserved; unclassified tickets named, not classified --
139
+ const tickets = {};
140
+ for (const id of requirements.sortIds(Object.keys(t.tickets))) {
141
+ const row = map && map.tickets ? map.tickets[id] : null;
142
+ if (row) tickets[id] = { role: row.role, rationale: row.rationale, state: AUTHORED };
143
+ else {
144
+ tickets[id] = { role: null, rationale: null, state: UNRESOLVED };
145
+ note('TICKET_UNCLASSIFIED', id, `${id} has no role; classify it as implements or enables (an enabling ticket needs a rationale)`);
146
+ }
147
+ }
148
+ if (map) {
149
+ for (const id of requirements.sortIds(Object.keys(map.tickets))) {
150
+ if (tickets[id]) continue;
151
+ tickets[id] = { role: map.tickets[id].role, rationale: map.tickets[id].rationale, state: UNRESOLVED };
152
+ note(t.others[id] ? 'TICKET_FOREIGN' : 'SCENARIO_STALE', id,
153
+ t.others[id] ? `${id} is a ticket of ${t.others[id]}, not of ${record.prd}` : `${id} has a row but no ticket file is associated with ${record.prd}`);
154
+ }
155
+ }
156
+
157
+ // --- checks: authored declarations preserved verbatim; none invented ---------------
158
+ const checks = {};
159
+ if (map) for (const id of Object.keys(map.checks).sort()) checks[id] = { ...map.checks[id], state: AUTHORED };
160
+ // Every check a scenario links to must be declared; an undeclared one is named.
161
+ for (const [sid, row] of Object.entries(scenarios)) {
162
+ for (const c of row.checks) {
163
+ if (checks[c]) continue;
164
+ note('CHECK_UNDECLARED', c, `${c} is linked from ${sid} but has no declaration`);
165
+ }
166
+ }
167
+
168
+ // --- candidates: material to read, with provenance. Never a link. -----------------
169
+ const candidates = { tickets: {} };
170
+ for (const id of requirements.sortIds(Object.keys(t.tickets))) {
171
+ const entry = t.tickets[id];
172
+ const claims = claimsOf(entry.text, inventory);
173
+ candidates.tickets[id] = {
174
+ file: entry.file,
175
+ objective: firstLine(entry.text, '## Objective'),
176
+ implements: claims.requirements,
177
+ scenarios: claims.scenarios,
178
+ verification: verificationOf(entry.text),
179
+ };
180
+ }
181
+
182
+ unresolved.sort((a, b) => (UNRESOLVED_ORDER.indexOf(a.code) - UNRESOLVED_ORDER.indexOf(b.code)) || requirements.compareIds(a.id, b.id));
183
+ const draft = {
184
+ draft: DRAFT,
185
+ kind: KIND,
186
+ change: record.change,
187
+ prd: record.prd,
188
+ inventory: { digest: inventory.digest, requirements: Object.keys(inventory.requirements).length, scenarios: Object.keys(inventory.scenarios).length },
189
+ authored: { map: authored ? authored.file : null, digest: authored ? authored.digest : null },
190
+ scenarios, scope, tickets, checks, candidates, unresolved,
191
+ next: nextAction(record, unresolved, authored),
192
+ };
193
+ return { ok: true, code: null, problems: [], draft };
194
+ }
195
+
196
+ // What the reader does next. A complete draft does not adopt anything: it says the map
197
+ // is ready to be reviewed and validated, which is a different claim from "correct".
198
+ function nextAction(record, unresolved, authored) {
199
+ if (unresolved.length) {
200
+ const first = unresolved[0];
201
+ return { action: `author the unresolved entries (${unresolved.length}), starting with ${first.id}`, command: `edit ${coverage.file(record.change)} — ${first.detail}` };
202
+ }
203
+ if (!authored) return { action: 'author the map from this draft, then validate it', command: `edit ${coverage.file(record.change)}, then ${RUNTIME} coverage --change ${record.change}` };
204
+ return { action: 'review the authored map, then preview adoption', command: `${RUNTIME} coverage adopt --preview --change ${record.change}` };
205
+ }
206
+
207
+ function render(d) {
208
+ const lines = [];
209
+ const count = o => Object.keys(o).length;
210
+ lines.push(`PINCER coverage draft · change ${d.change} · ${d.prd}`);
211
+ lines.push(`Inventory ${d.inventory.requirements} requirement(s), ${d.inventory.scenarios} scenario(s) · digest ${d.inventory.digest.slice(0, 12)}`);
212
+ lines.push(`Authored ${d.authored.map ? `${d.authored.map} · digest ${d.authored.digest.slice(0, 12)}` : 'no map yet'}`);
213
+ lines.push('');
214
+ lines.push(`Scenarios ${count(d.scenarios)} linked row(s), ${count(d.scope)} scope row(s)`);
215
+ for (const [id, s] of Object.entries(d.scenarios)) {
216
+ const marker = s.state === AUTHORED ? ' ' : '?';
217
+ lines.push(` ${marker} ${id} ${s.requirement || '(not in the inventory)'} tickets ${s.tickets.length ? s.tickets.join(', ') : '—'} checks ${s.checks.length ? s.checks.join(', ') : '—'}`);
218
+ }
219
+ for (const [id, s] of Object.entries(d.scope)) {
220
+ lines.push(` ${s.state === AUTHORED ? ' ' : '?'} ${id} scope: ${s.disposition}${s.decision ? ` (${s.decision})` : ''}${s.note ? ` — ${s.note}` : ''}`);
221
+ }
222
+ lines.push('');
223
+ lines.push(`Tickets ${count(d.tickets)}`);
224
+ for (const [id, t] of Object.entries(d.tickets)) {
225
+ const c = d.candidates.tickets[id];
226
+ lines.push(` ${t.state === AUTHORED ? ' ' : '?'} ${id} role ${t.role || '—'}${t.rationale ? ` (${t.rationale})` : ''}${c && c.objective ? ` · ${c.objective}` : ''}`);
227
+ if (t.state !== AUTHORED && c) {
228
+ const claimed = [...c.implements, ...c.scenarios];
229
+ if (claimed.length) lines.push(` the ticket says it implements ${claimed.join(', ')} (${c.file}) — a statement to check, not a link`);
230
+ if (c.verification) lines.push(` its Verification block runs: ${c.verification.split('\n')[0]}${c.verification.includes('\n') ? ' …' : ''}`);
231
+ }
232
+ }
233
+ lines.push('');
234
+ lines.push(`Checks ${count(d.checks)}`);
235
+ for (const [id, c] of Object.entries(d.checks)) {
236
+ lines.push(` ${c.state === AUTHORED ? ' ' : '?'} ${id} ${c.kind}${c.required ? ', required' : ''} ${c.kind === 'command' ? c.command : c.obligation}`);
237
+ }
238
+ if (!count(d.checks)) lines.push(' none declared — a check is authored, never derived from a ticket');
239
+ lines.push('');
240
+ if (d.unresolved.length) {
241
+ lines.push(`Unresolved ${d.unresolved.length}`);
242
+ for (const u of d.unresolved) lines.push(` ${u.code} ${u.detail}`);
243
+ } else {
244
+ lines.push('Unresolved none — every scenario has a row, every ticket a role, every linked check a declaration');
245
+ }
246
+ lines.push('');
247
+ lines.push('This is a draft, not a coverage map: it carries no schema, and `coverage`,');
248
+ lines.push('`coverage adopt` and the agreement digest do not accept it. Author');
249
+ lines.push(`${coverage.file(d.change)} yourself, review the links, then validate and adopt it.`);
250
+ lines.push(`Next ${d.next.action}: ${d.next.command}`);
251
+ return `${lines.join('\n')}\n`;
252
+ }
253
+
254
+ module.exports = { DRAFT, KIND, UNRESOLVED_ORDER, AUTHORED, UNRESOLVED, build, render, nextAction };
@@ -9,6 +9,7 @@ const os = require('node:os');
9
9
  const path = require('node:path');
10
10
  const crypto = require('node:crypto');
11
11
  const { nowIso, atomicWrite, readJson } = require('./fsutil.cjs');
12
+ const io = require('./io.cjs');
12
13
 
13
14
  const RUNTIME_DIR = '.pincer/runtime';
14
15
  const INDEX_SCHEMA = 1;
@@ -34,6 +35,9 @@ function ensureLayout(root) {
34
35
  return p;
35
36
  }
36
37
  const exists = root => fs.existsSync(paths(root).dir);
38
+ // Local attempt history exists once an index was written; a selection alone (a fresh
39
+ // clone that ran `change select`) is not attempt history.
40
+ const hasIndex = root => fs.existsSync(paths(root).index);
37
41
 
38
42
  const emptyIndex = () => ({ schema: INDEX_SCHEMA, sequence: 0, current: {}, running: [] });
39
43
  function validateIndex(doc) {
@@ -73,7 +77,7 @@ class StateBusy extends Error {
73
77
  // Acquire the exclusive lock: mkdir is atomic; a lock whose owner pid is dead on
74
78
  // this host is reclaimed with a diagnostic; a live or foreign-host owner is never
75
79
  // stolen. Returns a release function.
76
- function acquireLock(root, { waitMs, command = 'runtime', log = message => process.stderr.write(`${message}\n`) } = {}) {
80
+ function acquireLock(root, { waitMs, command = 'runtime', log = message => io.err(`${message}\n`) } = {}) {
77
81
  const p = ensureLayout(root);
78
82
  const bound = waitMs ?? (Number(process.env.PINCER_LOCK_WAIT_MS) > 0 ? Number(process.env.PINCER_LOCK_WAIT_MS) : LOCK_WAIT_MS);
79
83
  const deadline = Date.now() + bound;
@@ -124,7 +128,12 @@ function withLock(root, fn, options) {
124
128
 
125
129
  const compactTimestamp = () => nowIso().replace(/[-:]/g, '');
126
130
  const attemptId = sequence => `${String(sequence).padStart(6, '0')}-${compactTimestamp()}-${crypto.randomBytes(3).toString('hex')}`;
127
- const contextKey = context => (context.kind === 'candidate' ? `candidate:${context.candidate}:${context.check}` : `ticket:${context.change}:${context.ticket}`);
131
+ // Context keys (docs/runtime-contracts.md, "Attempts"): ticket keys are change-scoped
132
+ // in every runtime mode; candidate keys gain the change in changes mode (schema 2
133
+ // records) so two changes sharing a check ID and a candidate never share a pointer.
134
+ const contextKey = context => (context.kind === 'candidate'
135
+ ? (context.mode === 'changes' || context.agreement ? `candidate:${context.change}:${context.candidate}:${context.check}` : `candidate:${context.candidate}:${context.check}`)
136
+ : `ticket:${context.change}:${context.ticket}`);
128
137
 
129
138
  const OUTCOMES = ['running', 'passed', 'failed', 'interrupted', 'timed_out', 'error'];
130
139
  const SHA256 = /^[0-9a-f]{64}$/;
@@ -140,13 +149,19 @@ function validateAttempt(a, key, pointedId) {
140
149
  const digestOrNull = v => v === null || (typeof v === 'string' && SHA256.test(v));
141
150
  if (!obj(a)) return 'record is not a JSON object';
142
151
  const bad = [];
143
- if (a.schema !== 1) bad.push('schema');
144
- if (a.runtime !== 1) bad.push('runtime');
152
+ // Schema 1 (migrated mode), schema 2 (changes mode: the agreement digest is part
153
+ // of the context) and schema 3 (strict coverage: inventory and coverage digests
154
+ // too) share every other field.
155
+ if (![1, 2, 3].includes(a.schema)) bad.push('schema');
156
+ if (a.runtime !== a.schema) bad.push('runtime');
145
157
  if (!str(a.id)) bad.push('id');
146
158
  if (!Number.isInteger(a.sequence) || a.sequence < 1) bad.push('sequence');
147
159
  const c = a.context;
148
160
  if (!obj(c) || !['ticket', 'candidate'].includes(c.kind) || !str(c.change) || !str(c.prd) || !str(c.prd_revision) || !str(c.base)
149
- || (c.kind === 'ticket' ? !str(c.ticket) || !str(c.ticket_digest) : !str(c.candidate) || !str(c.check))) bad.push('context');
161
+ || (c.kind === 'ticket' ? !str(c.ticket) || !str(c.ticket_digest) : !str(c.candidate) || !str(c.check))
162
+ || (a.schema === 1 ? 'agreement' in c || 'inventory' in c || 'coverage' in c
163
+ : !(typeof c.agreement === 'string' && SHA256.test(c.agreement))
164
+ || (a.schema === 2 ? 'inventory' in c || 'coverage' in c : !(typeof c.inventory === 'string' && SHA256.test(c.inventory) && typeof c.coverage === 'string' && SHA256.test(c.coverage))))) bad.push('context');
150
165
  if (!obj(a.check) || typeof a.check.digest !== 'string' || !SHA256.test(a.check.digest) || typeof a.check.display !== 'string'
151
166
  || !Number.isInteger(a.check.timeout_seconds) || a.check.timeout_seconds <= 0) bad.push('check');
152
167
  if (!OUTCOMES.includes(a.outcome)) bad.push('outcome');
@@ -231,10 +246,14 @@ function latestAttempt(root, key, index) {
231
246
  function recover(root, options = {}) {
232
247
  return withLock(root, () => {
233
248
  const p = paths(root);
249
+ // Committed-but-unapplied transactions are completed and uncommitted staging
250
+ // is discarded before anything else is read (docs/runtime-contracts.md,
251
+ // "Transactions and recovery"); required lazily to avoid a module cycle.
252
+ const transactions = require('./transaction.cjs').recoverPending(root);
234
253
  const read = readIndex(root);
235
254
  if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
236
255
  const index = read.index;
237
- const report = { finalized: [], live: [], foreign: [], missing: [], journal: [] };
256
+ const report = { finalized: [], live: [], foreign: [], missing: [], journal: [], transactions };
238
257
  const stillRunning = [];
239
258
  for (const id of index.running) {
240
259
  const attempt = readAttempt(root, id).attempt;
@@ -274,6 +293,9 @@ function recover(root, options = {}) {
274
293
  if (fs.existsSync(p.journal)) {
275
294
  for (const name of fs.readdirSync(p.journal)) {
276
295
  const file = path.join(p.journal, name);
296
+ let stat = null;
297
+ try { stat = fs.lstatSync(file); } catch { continue; }
298
+ if (stat.isDirectory()) continue; // transaction staging is handled above; an unreadable manifest stays for inspection
277
299
  try { fs.rmSync(file, { force: true }); report.journal.push(`${RUNTIME_DIR}/journal/${name}`); } catch { /* ignore */ }
278
300
  }
279
301
  }
@@ -287,6 +309,6 @@ function recover(root, options = {}) {
287
309
 
288
310
  module.exports = {
289
311
  RUNTIME_DIR, INDEX_SCHEMA, LOCK_WAIT_MS, StateBusy,
290
- paths, ensureLayout, exists, emptyIndex, readIndex, writeIndex, isAlive,
312
+ paths, ensureLayout, exists, hasIndex, emptyIndex, readIndex, writeIndex, isAlive,
291
313
  acquireLock, withLock, attemptId, contextKey, validateAttempt, inspectArtifacts, writeAttempt, readAttempt, listAttempts, latestAttempt, recover,
292
314
  };