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
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+ // PINCER runtime — the requirement inventory (docs/runtime-contracts.md, "Strict
3
+ // coverage" → "Requirement inventory"). Parses a PRD into the complete set of
4
+ // requirement and scenario definitions under the frozen, deliberately bounded
5
+ // grammar: ATX headings `### R-NN — Title` define requirements, bold list items
6
+ // `- **S-NN:** text` inside a requirement section define scenarios, fenced code,
7
+ // table rows and quotes are reference contexts, and anything that looks like a
8
+ // definition in another shape is refused. The PRD prose is authoritative: this
9
+ // module derives a view and never edits, renumbers or infers a definition. No
10
+ // partial inventory is returned for a PRD with a grammar problem.
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const parse = require('./parse.cjs');
14
+
15
+ const PROJECTION_VERSION = 1;
16
+ const ID = '[A-Z][A-Z0-9]{0,7}-[0-9]{1,6}';
17
+ const ID_RE = new RegExp(`^${ID}$`);
18
+ const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*$/;
19
+ const REQUIREMENT_DEF = new RegExp(`^(${ID})(?:[ \\t]+(?:—|–|-)[ \\t]+|:[ \\t]+)(.+)$`);
20
+ const STARTS_WITH_ID = new RegExp(`^(${ID})(?![A-Z0-9-])`);
21
+ const ITEM = /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]+(.*)$/;
22
+ const SCENARIO_DEF = new RegExp(`^\\*\\*(${ID})(:?)\\*\\*(:?)(?:[ \\t]+(?:—|–|-)[ \\t]+|[ \\t]+|$)(.*)$`);
23
+ const SCENARIO_LIKE = new RegExp(`^(?:\\*\\*)?${ID}[:.]|^\\*\\*${ID}\\*\\*[ \\t]*$`);
24
+ const FENCE = /^[ \t]*`{3,}/;
25
+ const TILDE = /^[ \t]*~~~/;
26
+ const REFERENCE_CONTEXT = /^[ \t]*[|>]/;
27
+ const CONTINUATION = /^(?:[ ]{2,}|\t)(\S.*)$/;
28
+
29
+ // Prefix in byte order, then numerically: R-02 < R-10, AC-1 < R-01.
30
+ function compareIds(a, b) {
31
+ const [pa, na] = split(a), [pb, nb] = split(b);
32
+ if (pa !== pb) return pa < pb ? -1 : 1;
33
+ return na - nb;
34
+ }
35
+ const split = id => { const i = id.lastIndexOf('-'); return [id.slice(0, i), Number(id.slice(i + 1))]; };
36
+ const sortIds = ids => [...ids].sort(compareIds);
37
+
38
+ const rtrim = s => s.replace(/[ \t]+$/, '');
39
+ // Right-trimmed lines, leading/trailing blank lines removed, blank runs collapsed.
40
+ function normalizeBlock(lines) {
41
+ const out = [];
42
+ for (const raw of lines) {
43
+ const line = rtrim(raw);
44
+ if (line === '' && (out.length === 0 || out[out.length - 1] === '')) continue;
45
+ out.push(line);
46
+ }
47
+ while (out.length && out[out.length - 1] === '') out.pop();
48
+ return out.join('\n');
49
+ }
50
+
51
+ // Parse a PRD text. Returns { ok: true, inventory } or { ok: false, problems }
52
+ // (each problem a string; the code is always INVENTORY_INVALID). `prd` is the
53
+ // repository-relative PRD path recorded in the projection.
54
+ function parseInventory(text, { prd } = {}) {
55
+ const rows = parse.lines(text);
56
+ const problems = [];
57
+ const defined = new Map(); // id -> { kind, line }
58
+ const requirements = {}; // id -> { title, lines: [], digest, line, end, scenarios: [] }
59
+ const scenarios = {}; // id -> { requirement, lines: [], digest, line, end }
60
+ const order = [];
61
+ let i = 0;
62
+ if (rows[0] === '---') { i = 1; while (i < rows.length && rows[i] !== '---') i++; i++; }
63
+ let fenceLine = null, current = null, lastScenario = null;
64
+ // Blank lines seen while a scenario is still open. A blank line does not end a
65
+ // list item, so they are held until the next line decides where they belong:
66
+ // to the scenario, when an indented line follows and the item had a second
67
+ // paragraph, or to the requirement, when anything else follows.
68
+ const pending = [];
69
+ const endScenario = () => {
70
+ if (current) for (const blank of pending) requirements[current].lines.push(blank);
71
+ pending.length = 0;
72
+ lastScenario = null;
73
+ };
74
+ // Close the open section at the line before `boundary` (1-based; rows.length + 1 at
75
+ // the end of the file): its span ends at its last nonblank line.
76
+ const closeRequirement = boundary => {
77
+ if (!current) return;
78
+ const r = requirements[current];
79
+ let end = boundary - 1;
80
+ while (end > r.line && rows[end - 1].trim() === '') end--;
81
+ r.end = end;
82
+ if (!r.scenarios.length) problems.push(`requirement ${current} at line ${r.line} has no scenario`);
83
+ endScenario(); current = null;
84
+ };
85
+ const define = (kind, id, line) => {
86
+ const prior = defined.get(id);
87
+ if (prior) { problems.push(`duplicate definition ${id} at line ${line}`); return false; }
88
+ defined.set(id, { kind, line });
89
+ return true;
90
+ };
91
+ for (; i < rows.length; i++) {
92
+ const line = rows[i], n = i + 1;
93
+ if (fenceLine !== null) {
94
+ if (FENCE.test(line)) fenceLine = null;
95
+ if (current) requirements[current].lines.push(line);
96
+ continue;
97
+ }
98
+ if (TILDE.test(line)) { problems.push(`tilde fences are unsupported (line ${n})`); endScenario(); if (current) requirements[current].lines.push(line); continue; }
99
+ if (FENCE.test(line)) { fenceLine = n; endScenario(); if (current) requirements[current].lines.push(line); continue; }
100
+ if (REFERENCE_CONTEXT.test(line)) { endScenario(); if (current) requirements[current].lines.push(line); continue; }
101
+ const heading = line.match(HEADING);
102
+ if (heading) {
103
+ endScenario();
104
+ const level = heading[1].length, textOf = heading[2];
105
+ const def = textOf.match(REQUIREMENT_DEF);
106
+ if (def && level >= 2 && level <= 4 && def[2].trim() !== '') {
107
+ closeRequirement(n);
108
+ if (define('requirement', def[1], n)) {
109
+ current = def[1];
110
+ requirements[current] = { title: def[2].trim(), lines: [], line: n, end: n, level, scenarios: [] };
111
+ order.push(current);
112
+ }
113
+ continue;
114
+ }
115
+ if (STARTS_WITH_ID.test(textOf)) { problems.push(`unsupported requirement definition syntax at line ${n}; use "### R-NN — Title"`); closeRequirement(n); continue; }
116
+ if (current && level <= requirements[current].level) { closeRequirement(n); continue; }
117
+ if (current) requirements[current].lines.push(line);
118
+ continue;
119
+ }
120
+ const item = line.match(ITEM);
121
+ if (item) {
122
+ endScenario();
123
+ const body = item[3].replace(/^\[[ xX]\][ \t]+/, '');
124
+ const def = body.match(SCENARIO_DEF);
125
+ const numbered = /^[0-9]/.test(item[2]);
126
+ if (def && !numbered) {
127
+ if (def[4].trim() === '') { problems.push(`empty definition ${def[1]} at line ${n}`); continue; }
128
+ if (!current) { problems.push(`orphan scenario ${def[1]} at line ${n}`); continue; }
129
+ if (define('scenario', def[1], n)) {
130
+ scenarios[def[1]] = { requirement: current, lines: [def[4].trim()], line: n, end: n };
131
+ requirements[current].scenarios.push(def[1]);
132
+ lastScenario = def[1];
133
+ }
134
+ continue;
135
+ }
136
+ if (SCENARIO_LIKE.test(body) || (def && numbered)) { problems.push(`unsupported scenario definition syntax at line ${n}; use "- **S-NN:** text"`); continue; }
137
+ if (current) requirements[current].lines.push(line);
138
+ continue;
139
+ }
140
+ if (lastScenario && line.trim() === '') { pending.push(line); continue; }
141
+ const cont = lastScenario ? line.match(CONTINUATION) : null;
142
+ if (cont) {
143
+ const s = scenarios[lastScenario];
144
+ while (pending.length) { pending.pop(); s.lines.push(''); }
145
+ s.lines.push(rtrim(cont[1])); s.end = n; continue;
146
+ }
147
+ endScenario();
148
+ if (current) requirements[current].lines.push(line);
149
+ }
150
+ if (fenceLine !== null) problems.push(`unclosed fence opened at line ${fenceLine}`);
151
+ closeRequirement(rows.length + 1);
152
+ if (!order.length) problems.push('no requirement definitions');
153
+ if (problems.length) return { ok: false, problems };
154
+ const inventory = { prd: prd || null, requirements: {}, scenarios: {} };
155
+ for (const id of sortIds(order)) {
156
+ const r = requirements[id];
157
+ const text = normalizeBlock([r.title, ...r.lines]);
158
+ inventory.requirements[id] = { title: r.title, text, digest: parse.sha256(`requirement ${id}\n${text}\n`), line: r.line, end: r.end, scenarios: sortIds(r.scenarios) };
159
+ }
160
+ for (const id of sortIds(Object.keys(scenarios))) {
161
+ const s = scenarios[id];
162
+ const text = rtrim(s.lines.join('\n'));
163
+ inventory.scenarios[id] = { requirement: s.requirement, text, digest: parse.sha256(`scenario ${id}\n${text}\n`), line: s.line, end: s.end };
164
+ }
165
+ inventory.projection = projectionText(inventory);
166
+ inventory.digest = parse.sha256(inventory.projection);
167
+ return { ok: true, inventory };
168
+ }
169
+
170
+ // The exact inventory projection (each line terminated by \n).
171
+ function projectionText(inventory) {
172
+ const lines = [`pincer inventory ${PROJECTION_VERSION}`, `prd ${inventory.prd}`];
173
+ for (const id of sortIds(Object.keys(inventory.requirements))) lines.push(`requirement ${id} ${inventory.requirements[id].digest}`);
174
+ for (const id of sortIds(Object.keys(inventory.scenarios))) lines.push(`scenario ${id} ${inventory.scenarios[id].requirement} ${inventory.scenarios[id].digest}`);
175
+ return `${lines.join('\n')}\n`;
176
+ }
177
+
178
+ // Read and parse a PRD of the repository. Returns { ok, inventory } or
179
+ // { ok: false, code: 'INPUT_INVALID' | 'INVENTORY_INVALID', problems }.
180
+ function readInventory(root, prdRef) {
181
+ const v = parse.validatePrd(root, prdRef);
182
+ if (!v.ok) return { ok: false, code: 'INPUT_INVALID', problems: v.problems.map(p => `${prdRef}: ${p}`) };
183
+ const result = parseInventory(v.text, { prd: prdRef });
184
+ if (!result.ok) return { ok: false, code: 'INVENTORY_INVALID', problems: result.problems.map(p => `${prdRef}: ${p}`), prdResult: v };
185
+ return { ok: true, inventory: result.inventory, prdResult: v };
186
+ }
187
+
188
+ // Recompute a snapshot inventory's projection from its own digests (readSnapshot).
189
+ function projectionOf({ prd, requirements, scenarios }) {
190
+ return projectionText({ prd, requirements, scenarios });
191
+ }
192
+
193
+ // The snapshot shape (agreement snapshot schema 2 and evidence coverage/inventory.json).
194
+ function snapshotOf(inventory) {
195
+ const requirements = {}, scenarios = {};
196
+ for (const id of Object.keys(inventory.requirements)) { const r = inventory.requirements[id]; requirements[id] = { title: r.title, text: r.text, digest: r.digest, line: r.line, end: r.end, scenarios: [...r.scenarios] }; }
197
+ for (const id of Object.keys(inventory.scenarios)) { const s = inventory.scenarios[id]; scenarios[id] = { requirement: s.requirement, text: s.text, digest: s.digest, line: s.line, end: s.end }; }
198
+ return { digest: inventory.digest, projection: inventory.projection, requirements, scenarios };
199
+ }
200
+
201
+ // Validate a snapshot inventory read from a file: shape, per-definition digests
202
+ // recompute from the texts, the projection recomputes and hashes to `digest`.
203
+ // Returns null or a problem string.
204
+ function validateSnapshot(snap, prd) {
205
+ const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
206
+ const SHA = /^[0-9a-f]{64}$/;
207
+ if (!isObject(snap)) return 'inventory must be an object';
208
+ for (const k of ['digest', 'projection', 'requirements', 'scenarios']) if (!(k in snap)) return `inventory.${k} is missing`;
209
+ for (const k of Object.keys(snap)) if (!['digest', 'projection', 'requirements', 'scenarios'].includes(k)) return `inventory.${k} is not allowed`;
210
+ if (!isObject(snap.requirements) || !isObject(snap.scenarios)) return 'inventory.requirements and inventory.scenarios must be objects';
211
+ for (const [id, r] of Object.entries(snap.requirements)) {
212
+ if (!ID_RE.test(id) || !isObject(r) || typeof r.title !== 'string' || typeof r.text !== 'string' || !SHA.test(r.digest || '') || !Number.isInteger(r.line) || !Number.isInteger(r.end) || !Array.isArray(r.scenarios)) return `inventory.requirements[${id}] is malformed`;
213
+ if (parse.sha256(`requirement ${id}\n${r.text}\n`) !== r.digest) return `requirement ${id} text does not hash to its recorded digest`;
214
+ for (const s of r.scenarios) if (!snap.scenarios[s] || snap.scenarios[s].requirement !== id) return `requirement ${id} lists scenario ${s}, which is not its scenario`;
215
+ }
216
+ for (const [id, s] of Object.entries(snap.scenarios)) {
217
+ if (!ID_RE.test(id) || !isObject(s) || typeof s.text !== 'string' || !SHA.test(s.digest || '') || !Number.isInteger(s.line) || !Number.isInteger(s.end) || !snap.requirements[s.requirement]) return `inventory.scenarios[${id}] is malformed`;
218
+ if (parse.sha256(`scenario ${id}\n${s.text}\n`) !== s.digest) return `scenario ${id} text does not hash to its recorded digest`;
219
+ if (!snap.requirements[s.requirement].scenarios.includes(id)) return `scenario ${id} is not listed by its requirement ${s.requirement}`;
220
+ }
221
+ const projection = projectionText({ prd, requirements: snap.requirements, scenarios: snap.scenarios });
222
+ if (projection !== snap.projection) return 'the inventory projection does not recompute from its definitions';
223
+ if (parse.sha256(projection) !== snap.digest) return 'the inventory digest does not match its projection';
224
+ return null;
225
+ }
226
+
227
+ // Structural difference between two inventories (snapshots or computed): what
228
+ // impact and deletion detection read. Never a semantic judgment.
229
+ function difference(from, to) {
230
+ const reqBefore = Object.keys(from.requirements), reqAfter = Object.keys(to.requirements);
231
+ const scBefore = Object.keys(from.scenarios), scAfter = Object.keys(to.scenarios);
232
+ const requirements = { added: sortIds(reqAfter.filter(id => !from.requirements[id])), removed: sortIds(reqBefore.filter(id => !to.requirements[id])), changed: [], unchanged: [] };
233
+ for (const id of sortIds(reqAfter.filter(id => from.requirements[id]))) {
234
+ const a = from.requirements[id], b = to.requirements[id];
235
+ const parts = [];
236
+ if (a.title !== b.title) parts.push('title');
237
+ if (a.digest !== b.digest && a.text !== b.text) parts.push('text');
238
+ if (JSON.stringify(sortIds(a.scenarios)) !== JSON.stringify(sortIds(b.scenarios))) parts.push('scenarios');
239
+ if (parts.length) requirements.changed.push({ id, parts }); else requirements.unchanged.push(id);
240
+ }
241
+ const scenarios = { added: sortIds(scAfter.filter(id => !from.scenarios[id])), removed: sortIds(scBefore.filter(id => !to.scenarios[id])), changed: [], unchanged: [] };
242
+ for (const id of sortIds(scAfter.filter(id => from.scenarios[id]))) {
243
+ const a = from.scenarios[id], b = to.scenarios[id];
244
+ const parts = [];
245
+ if (a.digest !== b.digest) parts.push('text');
246
+ if (a.requirement !== b.requirement) parts.push('requirement');
247
+ if (parts.length) scenarios.changed.push({ id, parts }); else scenarios.unchanged.push(id);
248
+ }
249
+ return { same: from.digest === to.digest, requirements, scenarios };
250
+ }
251
+
252
+ module.exports = { PROJECTION_VERSION, ID, ID_RE, compareIds, sortIds, parseInventory, projectionText, projectionOf, readInventory, snapshotOf, validateSnapshot, difference, normalizeBlock };
253
+
254
+ // Convenience for callers that hold a file path rather than a repository.
255
+ module.exports.parseFile = (file, prd) => parseInventory(fs.readFileSync(file, 'utf8'), { prd: prd || path.basename(file) });
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+ // PINCER runtime — the resume report (docs/runtime-contracts.md, "Resume
3
+ // report"). Read-only inspection of the selected (or named) change for a fresh
4
+ // session: identity, lifecycle, repository view, agreement and authorization,
5
+ // decisions, references to the authored artifacts, tickets and attempts, the
6
+ // candidate, the authored handoff (labeled, never an input), the blockers in
7
+ // gate order and one next action chosen by the documented precedence. Distinct
8
+ // from `change resume`, the lifecycle operation. Launches nothing, writes nothing.
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const parse = require('./parse.cjs');
12
+ const changes = require('./changes.cjs');
13
+ const agreement = require('./agreement.cjs');
14
+ const status = require('./status.cjs');
15
+ const state = require('./state.cjs');
16
+ const transaction = require('./transaction.cjs');
17
+ const gates = require('./gates.cjs');
18
+
19
+ // Resume JSON schema 2 (PRD v6): schema 1 plus the strict coverage summary; the
20
+ // coverage codes join rule 4 of the next-action precedence.
21
+ const SCHEMA = 2;
22
+ const STATE_CODES = ['INPUT_INVALID', 'MALFORMED', 'UNSUPPORTED_SCHEMA', 'HISTORY_INVALID', 'STATE_INCOMPLETE'];
23
+ const COVERAGE_CODES = ['INVENTORY_INVALID', 'COVERAGE_INVALID', 'COVERAGE_INCOMPLETE', 'OBLIGATION_MISSING', 'SCOPE_UNAUTHORIZED'];
24
+ const CANDIDATE_COVERAGE_CODES = ['REVIEW_MISSING', 'ADEQUACY_REQUIRED'];
25
+ const phases = require('./phases.cjs');
26
+ const routing = require('./routing.cjs');
27
+ const SELECTION_CODES = ['SELECTION_REQUIRED', 'SELECTION_INVALID'];
28
+ const AGREEMENT_CODES = ['DECISION_REQUIRED', 'AUTHORIZATION_REQUIRED', 'AGREEMENT_CHANGED'];
29
+ const RUNTIME = 'node scripts/pincer-runtime.cjs';
30
+
31
+ const firstLine = (text, heading) => {
32
+ const rows = parse.lines(text);
33
+ const i = rows.findIndex(l => l.trim() === heading);
34
+ if (i === -1) return null;
35
+ const next = rows.slice(i + 1).find(l => l.trim() !== '');
36
+ return next ? next.trim() : null;
37
+ };
38
+ const title = text => { const m = parse.lines(text).find(l => /^# /.test(l)); return m ? m.replace(/^# /, '').trim() : null; };
39
+
40
+ // build(root, { change }) → { json, blockers, next, gathered } — the whole report.
41
+ function build(root, { change = null } = {}) {
42
+ const st = status.render(root, { change });
43
+ const j = st.json;
44
+ const generated = j.generated;
45
+ const report = { schema: SCHEMA, runtime: changes.RUNTIME_STRICT, generated, root, mode: j.mode, selection: null, change: null, agreement: null, references: null, tickets: [], attempts: [], candidate: null, coverage: j.coverage || { strict: false, label: 'unverified', reason: null, structure: null, implementation: null, candidate: null, next: null }, handoff: null, blockers: [], next: null };
46
+ const blockers = [];
47
+ const push = (code, detail) => { if (!blockers.some(b => b.code === code && b.detail === detail)) blockers.push({ code, detail }); };
48
+ if (j.mode !== 'changes') {
49
+ report.selection = { change: null, problem: { code: j.mode === 'invalid' ? 'INPUT_INVALID' : 'CHANGE_REQUIRED', detail: j.mode === 'legacy' ? 'this project keeps no change records; register with: node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md' : j.mode === 'migrated' ? 'this project keeps a v0.5.0 binding; migrate it with: node scripts/pincer-runtime.cjs migrate --preview --prd <prd>' : (j.reasons[0] ? j.reasons[0].detail : 'unreadable change records') } };
50
+ push(report.selection.problem.code, report.selection.problem.detail);
51
+ report.blockers = blockers;
52
+ report.next = { action: 'repair or migrate', command: report.selection.problem.detail.replace(/^.*?: /, ''), ticket: null, check: null, rule: 1 };
53
+ return { json: report, text: render(report), exit: st.exit };
54
+ }
55
+ report.selection = j.selection;
56
+ // Rule 1: invalid or missing state, or no usable selection.
57
+ for (const r of j.reasons) if (STATE_CODES.includes(r.code)) push(r.code, r.detail);
58
+ if (!j.change) {
59
+ const problem = j.selection.problem || (j.reasons[0] ? j.reasons[0] : { code: 'SELECTION_REQUIRED', detail: 'no change selected' });
60
+ push(problem.code, problem.detail);
61
+ report.blockers = blockers;
62
+ const first = blockers[0];
63
+ report.next = STATE_CODES.includes(first.code)
64
+ ? { action: first.code === 'STATE_INCOMPLETE' ? 'complete the interrupted transaction' : 'repair the change records by hand', command: first.code === 'STATE_INCOMPLETE' ? `${RUNTIME} recover` : `${RUNTIME} change list`, ticket: null, check: null, rule: 1 }
65
+ : { action: 'select the change to work on', command: `${RUNTIME} change select <id>`, ticket: null, check: null, rule: 1 };
66
+ return { json: report, text: render(report), exit: st.exit };
67
+ }
68
+ const id = j.change.id;
69
+ const resolved = changes.resolveSelected(root, { change: id });
70
+ const record = resolved.record;
71
+ report.change = { id, prd: j.change.prd, prd_revision: j.change.prd_revision, base: j.change.base, registered: record ? record.registered : null, sequence: j.change.sequence, lifecycle: j.change.lifecycle, view: j.change.view };
72
+ const ag = j.change.agreement;
73
+ report.agreement = {
74
+ current: ag.current,
75
+ authorized: ag.authorized ? { ...ag.authorized, reference: (record.authorizations.find(a => a.id === ag.authorized.id) || {}).reference || null, excerpt: (record.authorizations.find(a => a.id === ag.authorized.id) || {}).excerpt || null, constraints: (record.authorizations.find(a => a.id === ag.authorized.id) || {}).constraints || null } : null,
76
+ verdict: ag.verdict, difference: ag.difference,
77
+ decisions: { open: record.decisions.filter(d => d.status === 'open').map(d => ({ id: d.id, summary: d.summary, raised: d.raised })), resolved: record.decisions.filter(d => d.status === 'resolved').map(d => ({ id: d.id, summary: d.summary, reference: d.reference, resolved: d.resolved })) },
78
+ };
79
+ // References: the authored artifacts the agreement is read from (titles and first lines only).
80
+ let prdText = null;
81
+ try { prdText = fs.readFileSync(path.join(root, record.prd), 'utf8'); } catch { prdText = null; }
82
+ const authorizedEntry = ag.authorized ? record.agreements.find(g => g.id === ag.authorized.agreement) : null;
83
+ report.references = {
84
+ prd: { path: record.prd, title: prdText ? title(prdText) : null },
85
+ snapshot: authorizedEntry ? authorizedEntry.snapshot : null,
86
+ tickets: (st.gathered && st.gathered.tickets ? st.gathered.tickets : []).map(t => ({ id: t.fields.ticket, file: t.file, objective: firstLine(t.text, '## Objective') })),
87
+ };
88
+ report.tickets = j.tickets;
89
+ const idx = state.exists(root) ? state.readIndex(root) : { index: null };
90
+ if (idx.index) {
91
+ for (const t of j.tickets) {
92
+ const key = state.contextKey({ kind: 'ticket', change: id, ticket: t.id });
93
+ for (const a of state.listAttempts(root, key)) report.attempts.push({ ticket: t.id, check: null, id: a.id, outcome: a.outcome, started: a.started, finished: a.finished, current: idx.index.current[key] === a.id });
94
+ }
95
+ if (j.candidate && j.candidate.evaluation) {
96
+ for (const a of state.listAttempts(root)) {
97
+ if (a.context && a.context.kind === 'candidate' && a.context.change === id && a.context.candidate === j.candidate.evaluation.candidate) {
98
+ const key = state.contextKey({ ...a.context, mode: 'changes' });
99
+ report.attempts.push({ ticket: null, check: a.context.check, id: a.id, outcome: a.outcome, started: a.started, finished: a.finished, current: idx.index.current[key] === a.id });
100
+ }
101
+ }
102
+ }
103
+ }
104
+ report.candidate = j.candidate;
105
+ const lc = record.lifecycle;
106
+ report.handoff = lc.reason || lc.note ? { kind: record.events.filter(e => ['pause', 'reopen'].includes(e.kind)).at(-1)?.kind || null, reason: lc.reason, note: lc.note, since: lc.since, authored: true } : null;
107
+ // Blockers in gate order, then the readiness and candidate reasons.
108
+ const running = transaction.runningAttempts(root, id);
109
+ const viewProblems = j.reasons.filter(r => r.code === 'BASE_MISMATCH');
110
+ for (const r of viewProblems) push(r.code, r.detail);
111
+ if (running.length) push('ATTEMPT_RUNNING', `attempt ${running[0].id} of change ${id} is running${running[0].alive === false ? ' (its owner is no longer running)' : ''}`);
112
+ if (changes.TERMINAL.includes(lc.state)) push('LIFECYCLE_BLOCKED', `change ${id} is ${lc.state}${lc.superseded_by ? ` by ${lc.superseded_by}` : ''}; it cannot execute`);
113
+ if (ag.verdict && ag.verdict !== 'current') push(ag.verdict, ag.verdict_detail || ag.verdict);
114
+ // Rule 4 also covers the structural coverage gaps of a strict change.
115
+ const selected = !resolved.selection || resolved.selection.change === id;
116
+ const cov = changes.isStrict(record) ? phases.report(root, record, { gathered: st.gathered, generated, selected }) : null;
117
+ if (cov) for (const p of cov.structure.problems) push(p.code, p.detail);
118
+ for (const t of j.tickets) for (const r of t.readiness.reasons) if (t.status === 'done' || t.status === 'in_progress') push(r.code, `${t.id}: ${r.detail}`);
119
+ // A missing or stale evaluation blocks only a completed change: before completion no candidate is expected.
120
+ if (j.candidate && lc.state === 'completed') for (const r of j.candidate.reasons) push(r.code, r.detail);
121
+ if (cov && lc.state === 'completed') for (const p of cov.candidate.problems) if (p.code !== 'EVIDENCE_MISSING' || !cov.candidate.evaluated) push(p.code, p.detail);
122
+ report.blockers = blockers;
123
+ report.next = routing.qualify(decide({ id, lc, ag, j, running, viewProblems, gathered: st.gathered, record, cov, selected }), { id, selected });
124
+ return { json: report, text: render(report), exit: st.exit };
125
+ }
126
+
127
+ // The documented next-action precedence (rules 1..8); rule 1 is handled by the caller.
128
+ function decide({ id, lc, ag, j, running, viewProblems, gathered, record, cov = null, selected = true }) {
129
+ const cmd = (action, command, extra = {}) => ({ action, command, ticket: null, check: null, ...extra });
130
+ const change = sub => `${RUNTIME} change ${sub} ${id}`;
131
+ // Rules 2 and 3 are shared with the coverage report (routing.cjs) so the two
132
+ // cannot drift apart again.
133
+ const ahead = routing.preface({ id, lifecycle: lc, base: record.base, running, viewProblems });
134
+ if (ahead) return ahead;
135
+ const gap = ag.verdict && ag.verdict !== 'current';
136
+ if (gap) {
137
+ if (ag.verdict === 'DECISION_REQUIRED') return cmd('record the user\'s decision', `${change('decide')} --resolve ${ag.open_decisions[0]} --reference <text> --excerpt <text>`, { rule: 4 });
138
+ if (ag.verdict === 'AUTHORIZATION_REQUIRED') return cmd('record the user\'s authorization', `${change('authorize')} --agreement ${ag.current} --reference <text> --excerpt <text>`, { rule: 4 });
139
+ if (ag.verdict === 'AGREEMENT_CHANGED') return cmd('record the disposition of the changed agreement', `${change('authorize')} --agreement ${ag.current} --reference <text> --excerpt <text> (user) or --delegated --basis ${ag.authorized ? ag.authorized.id : (record.authorizations.at(-1) || {}).id || 'A-NN'} --explanation <text>`, { rule: 4 });
140
+ return cmd('repair the agreement inputs', `${ag.verdict}: ${ag.verdict_detail}`, { rule: 4 });
141
+ }
142
+ // Rule 4 (strict coverage): a structural coverage gap precedes any verification work.
143
+ if (cov && cov.structure.problems.length) return { ...cov.next, rule: 4 };
144
+ const staged = routing.lifecycleAction({ id, lifecycle: lc });
145
+ if (staged) return staged;
146
+ const tickets = j.tickets;
147
+ const inProgress = tickets.find(t => t.status === 'in_progress');
148
+ // A fresh clone without local attempt history relies on the saved candidate record (as `ready` does): a done
149
+ // ticket whose only reason is EVIDENCE_MISSING is that stated limit, not stale work.
150
+ const localUnavailable = Boolean(j.candidate && j.candidate.local_attempts === 'unavailable');
151
+ const stale = tickets.find(t => t.status === 'done' && !t.readiness.ready && !(localUnavailable && t.readiness.reasons.every(r => r.code === 'EVIDENCE_MISSING')));
152
+ const open = tickets.filter(t => t.status === 'open');
153
+ const blocked = t => t.readiness.reasons.some(x => x.code === 'DEPENDENCY_BLOCKED');
154
+ const nextOpen = open.find(t => !blocked(t)) || null;
155
+ if (lc.state === 'active') {
156
+ if (inProgress) return cmd(`finish ${inProgress.id}`, `scripts/pincer-ticket.sh verify ${inProgress.id} → done ${inProgress.id}`, { ticket: inProgress.id, rule: 5 });
157
+ if (stale) return cmd(`re-verify ${stale.id} (${stale.readiness.reasons[0].code})`, `scripts/pincer-ticket.sh verify ${stale.id}`, { ticket: stale.id, rule: 5 });
158
+ if (nextOpen) return cmd(`start ${nextOpen.id}`, `scripts/pincer-ticket.sh start ${nextOpen.id}`, { ticket: nextOpen.id, rule: 5 });
159
+ if (open.length) return cmd('unblock the remaining tickets', `${open.map(t => `${t.id}: ${t.readiness.reasons[0] ? t.readiness.reasons[0].detail : 'blocked'}`).join('; ')}`, { ticket: open[0].id, rule: 5 });
160
+ if (!tickets.length) return cmd('write the breakdown', '/pincer-narrow — the change has no tickets', { rule: 5 });
161
+ return cmd('complete the change', change('complete'), { rule: 6 });
162
+ }
163
+ // completed
164
+ if (stale) return cmd(`re-verify ${stale.id} (${stale.readiness.reasons[0].code})`, `${change('reopen')} --reason <text>, then scripts/pincer-ticket.sh verify ${stale.id}`, { ticket: stale.id, rule: 5 });
165
+ const cand = j.candidate || {};
166
+ const newer = (cand.reasons || []).find(r => ['CHECK_FAILED', 'ATTEMPT_RUNNING', 'ATTEMPT_TIMED_OUT', 'ATTEMPT_INTERRUPTED', 'ATTEMPT_ERROR'].includes(r.code));
167
+ if (newer) return cmd(`re-run ${newer.detail.split(':')[0]} and re-evaluate`, `${RUNTIME} check ${newer.detail.split(':')[0]} --candidate ${cand.candidate}${cov ? '' : ' -- <command>'}, then /pincer-evaluate`, { check: newer.detail.split(':')[0], rule: 5 });
168
+ // Rules 7 and 8 (strict coverage): the candidate coverage decides what the evaluation still lacks.
169
+ if (cov && cov.candidate.problems.some(p => CANDIDATE_COVERAGE_CODES.includes(p.code) || (cov.candidate.evaluated && ['CHECK_FAILED', 'ATTEMPT_ERROR'].includes(p.code)))) return { ...cov.next, rule: 7 };
170
+ if (cand.notes !== 'current' || (j.prd && j.prd.status !== 'built')) return cmd('evaluate the candidate', `/pincer-evaluate${j.prd && j.prd.status !== 'built' ? ` (PRD status is '${j.prd.status}', expected 'built')` : cand.reason ? ` (${cand.reason})` : ''}`, { rule: 7 });
171
+ return cmd('read-only release audit', '/pincer-release', { rule: 8 });
172
+ }
173
+
174
+ function render(r) {
175
+ const lines = [];
176
+ const short = s => (typeof s === 'string' ? s.slice(0, 12) : '—');
177
+ lines.push(`PINCER resume · ${r.generated} · ${r.root}`);
178
+ if (!r.change) {
179
+ lines.push(`Selection ${r.selection && r.selection.change ? r.selection.change : 'none'}${r.selection && r.selection.problem ? ` · ${r.selection.problem.code}: ${r.selection.problem.detail}` : ''}`);
180
+ } else {
181
+ const c = r.change, v = c.view;
182
+ lines.push(`Change ${c.id} · ${c.prd} · revision ${short(c.prd_revision)} · base ${c.base.slice(0, 7)} · registered ${c.registered} · sequence ${c.sequence}`);
183
+ lines.push(`Lifecycle ${c.lifecycle.state} since ${c.lifecycle.since}${c.lifecycle.superseded_by ? ` · superseded by ${c.lifecycle.superseded_by}` : ''}`);
184
+ lines.push(`View HEAD ${v.head ? v.head.slice(0, 7) : 'none'} · branch ${v.branch || 'detached'} · base ${v.base_is_ancestor ? 'is an ancestor' : 'is NOT an ancestor'} · dirty ${v.dirty.length} path(s)${v.dirty.length ? `: ${v.dirty.slice(0, 5).join(', ')}${v.dirty.length > 5 ? ` (+${v.dirty.length - 5})` : ''}` : ''}`);
185
+ const a = r.agreement;
186
+ lines.push(`Agreement ${a.current ? short(a.current) : 'unavailable'}${a.authorized ? ` · authorized as ${a.authorized.agreement} by ${a.authorized.id} (${a.authorized.disposition}, ${a.authorized.recorded})` : ' · not authorized'}${a.difference && !a.difference.same ? ` · differs from the authorized agreement: ${agreement.renderDifference(a.difference)}` : ''}`);
187
+ lines.push(`Authorization ${a.verdict}${a.authorized && a.authorized.excerpt ? ` — "${a.authorized.excerpt}" (${a.authorized.reference})${a.authorized.constraints ? ` · constraints: ${a.authorized.constraints}` : ''}` : ''}`);
188
+ lines.push(`Decisions ${a.decisions.open.length || a.decisions.resolved.length ? [...a.decisions.open.map(d => `${d.id} open: ${d.summary}`), ...a.decisions.resolved.map(d => `${d.id} resolved: ${d.summary}`)].join('; ') : 'none'}`);
189
+ lines.push(`References PRD ${r.references.prd.path}${r.references.prd.title ? ` ("${r.references.prd.title}")` : ''}${r.references.snapshot ? ` · authorized snapshot ${r.references.snapshot}` : ''}${r.references.tickets.length ? ` · tickets ${r.references.tickets.map(t => `${t.id} (${t.file})`).join(', ')}` : ' · no tickets'}`);
190
+ for (const t of r.references.tickets) if (t.objective) lines.push(` ${t.id} ${t.objective}`);
191
+ const ticketWord = t => (t.status === 'open' ? (t.readiness.reasons.some(x => x.code === 'DEPENDENCY_BLOCKED') ? ` ${t.readiness.reasons[0].detail}` : '') : t.readiness.ready ? ' ready' : t.readiness.reasons[0] ? ` ${t.readiness.reasons[0].code}` : '');
192
+ lines.push(`Tickets ${r.tickets.length ? r.tickets.map(t => `${t.id} ${t.status}${ticketWord(t)}`).join(' · ') : 'none'}`);
193
+ lines.push(`Attempts ${r.attempts.length ? r.attempts.map(x => `${x.ticket || x.check} ${x.id} ${x.outcome}${x.current ? ' (current)' : ''}`).join('; ') : 'none'}`);
194
+ const cand = r.candidate;
195
+ lines.push(`Candidate ${cand ? `${cand.locator || 'NOTES.md'}: ${cand.notes === 'current' ? `current (${cand.candidate})` : cand.reason}${cand.evidence ? ` · evidence ${cand.evidence.verdict}` : ''}` : 'none'}`);
196
+ const cv = r.coverage;
197
+ lines.push(`Coverage ${cv.label}${cv.strict ? ` · structure ${cv.structure.complete ? 'complete' : `incomplete (${cv.structure.problems[0].code})`} · implementation ${cv.implementation.scenarios.complete}/${cv.implementation.scenarios.total - cv.implementation.scenarios.dispositioned} scenarios · candidate ${cv.candidate.evaluated ? `delivery original ${cv.candidate.delivery.original}, agreed ${cv.candidate.delivery.agreed}, adequacy ${cv.candidate.adequacy}` : 'not evaluated'}` : cv.reason ? ` · ${cv.reason}` : ''}`);
198
+ lines.push(`Handoff (authored) ${r.handoff ? `${r.handoff.kind || 'note'} since ${r.handoff.since}${r.handoff.reason ? ` · reason: ${r.handoff.reason}` : ''}${r.handoff.note ? ` · note: ${r.handoff.note}` : ''}` : 'none'}`);
199
+ }
200
+ lines.push(`Blockers ${r.blockers.length ? r.blockers.map(b => `${b.code} ${b.detail}`).join('\n ') : 'none'}`);
201
+ lines.push(`Next ${r.next.action}: ${r.next.command}`);
202
+ return `${lines.join('\n')}\n`;
203
+ }
204
+
205
+ // --- The brief projection (docs/runtime-contracts.md, "Brief resume") ----------------
206
+ // A fresh session reads the full report to answer one question — what do I do next —
207
+ // and pays for every ticket row, attempt row and repeated blocker detail to get it.
208
+ // The brief keeps the answer and the counts and drops the repetition.
209
+ //
210
+ // It is a projection, not a second report: `next` is the full report's own object,
211
+ // copied, and every distinct blocker code survives with its exact count. Grouping may
212
+ // collapse repetition; it may never collapse a category, because a category is the
213
+ // reason a gate will refuse. Nothing here recomputes a verdict, caches readiness or
214
+ // decides an action, and `detail` names the command that prints every omitted row.
215
+ const BRIEF = 1;
216
+ const BRIEF_KIND = 'resume-brief';
217
+
218
+ function brief(r) {
219
+ const counts = {};
220
+ for (const b of r.blockers) counts[b.code] = (counts[b.code] || 0) + 1;
221
+ const byStatus = { open: 0, in_progress: 0, done: 0 };
222
+ let notReady = 0;
223
+ for (const t of r.tickets) {
224
+ if (t.status in byStatus) byStatus[t.status]++;
225
+ if (!t.readiness.ready) notReady++;
226
+ }
227
+ const running = r.attempts.filter(a => a.outcome === 'running').length;
228
+ const currentFailed = r.attempts.filter(a => a.current && a.outcome !== 'passed').length;
229
+ const cov = r.coverage;
230
+ return {
231
+ brief: BRIEF, kind: BRIEF_KIND, of: SCHEMA,
232
+ generated: r.generated, root: r.root, mode: r.mode,
233
+ selection: r.selection,
234
+ change: r.change ? { id: r.change.id, prd: r.change.prd, base: r.change.base, lifecycle: r.change.lifecycle.state } : null,
235
+ agreement: r.agreement ? { current: r.agreement.current, verdict: r.agreement.verdict, authorized: r.agreement.authorized ? { id: r.agreement.authorized.id, disposition: r.agreement.authorized.disposition } : null } : null,
236
+ coverage: cov ? { label: cov.label, strict: cov.strict, structure: cov.strict && cov.structure ? cov.structure.complete : null, implementation: cov.strict && cov.implementation ? cov.implementation.scenarios : null } : null,
237
+ tickets: { total: r.tickets.length, by_status: byStatus, not_ready: notReady },
238
+ attempts: { total: r.attempts.length, running, current_failed: currentFailed },
239
+ candidate: r.candidate ? { notes: r.candidate.notes, candidate: r.candidate.candidate, evidence: r.candidate.evidence ? r.candidate.evidence.verdict : null } : null,
240
+ blockers: { total: r.blockers.length, categories: Object.keys(counts).map(code => ({ code, count: counts[code] })) },
241
+ next: r.next,
242
+ detail: {
243
+ command: `${RUNTIME} resume${r.change ? ` --change ${r.change.id}` : ''}`,
244
+ prd: r.references ? r.references.prd.path : null,
245
+ tickets: r.references ? r.references.tickets.map(t => t.file) : [],
246
+ omitted: r.tickets.length + r.attempts.length + Math.max(0, r.blockers.length - Object.keys(counts).length),
247
+ },
248
+ };
249
+ }
250
+
251
+ function renderBrief(b) {
252
+ const lines = [];
253
+ const short = s => (typeof s === 'string' ? s.slice(0, 12) : '—');
254
+ lines.push(`PINCER resume --brief · ${b.generated} · ${b.root}`);
255
+ if (!b.change) {
256
+ lines.push(`Selection ${b.selection && b.selection.change ? b.selection.change : 'none'}${b.selection && b.selection.problem ? ` · ${b.selection.problem.code}: ${b.selection.problem.detail}` : ''}`);
257
+ } else {
258
+ const c = b.change, a = b.agreement;
259
+ lines.push(`Change ${c.id} · ${c.prd} · base ${c.base.slice(0, 7)} · ${c.lifecycle}`);
260
+ lines.push(`Agreement ${a.current ? short(a.current) : 'unavailable'} · ${a.verdict}${a.authorized ? ` · authorized ${a.authorized.id} (${a.authorized.disposition})` : ' · not authorized'}`);
261
+ if (b.coverage) lines.push(`Coverage ${b.coverage.label}${b.coverage.strict ? ` · structure ${b.coverage.structure ? 'complete' : 'incomplete'} · implementation ${b.coverage.implementation.complete}/${b.coverage.implementation.total - b.coverage.implementation.dispositioned} scenarios` : ''}`);
262
+ const t = b.tickets;
263
+ lines.push(`Tickets ${t.total} · ${t.by_status.done} done · ${t.by_status.in_progress} in progress · ${t.by_status.open} open · ${t.not_ready} not ready`);
264
+ lines.push(`Attempts ${b.attempts.total}${b.attempts.running ? ` · ${b.attempts.running} running` : ''}${b.attempts.current_failed ? ` · ${b.attempts.current_failed} current not passed` : ''}`);
265
+ if (b.candidate) lines.push(`Candidate ${b.candidate.notes === 'current' ? `current (${String(b.candidate.candidate).slice(0, 7)})` : b.candidate.notes}${b.candidate.evidence ? ` · evidence ${b.candidate.evidence}` : ''}`);
266
+ }
267
+ lines.push(`Blockers ${b.blockers.total ? b.blockers.categories.map(x => `${x.code}${x.count > 1 ? ` ×${x.count}` : ''}`).join(' · ') : 'none'}`);
268
+ lines.push(`Next ${b.next.action}: ${b.next.command}`);
269
+ lines.push(`Detail ${b.detail.command}${b.detail.omitted ? ` (${b.detail.omitted} row(s) not shown)` : ''}${b.detail.prd ? ` · ${b.detail.prd}` : ''}`);
270
+ return `${lines.join('\n')}\n`;
271
+ }
272
+
273
+ module.exports = { SCHEMA, BRIEF, BRIEF_KIND, build, decide, render, brief, renderBrief };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ // The head of the next-action precedence (docs/runtime-contracts.md, rules 1-8),
3
+ // in one place. `resume` and `coverage` both render a next action; when each
4
+ // carried its own copy they drifted, and `coverage` ended up recommending
5
+ // commands the gates refuse — `start T-01` on a paused change, execution on a
6
+ // cancelled one. Rules 2 and 3 live here so the two reports cannot disagree
7
+ // again, and so neither can disagree with the gate that will run next.
8
+ const changes = require('./changes.cjs');
9
+
10
+ const RUNTIME = 'node scripts/pincer-runtime.cjs';
11
+ const action = (what, command, extra = {}) => ({ action: what, command, ticket: null, check: null, ...extra });
12
+
13
+ // Rules 2 and 3: what outranks the agreement gap. Returns null when none applies
14
+ // and the caller should go on to rule 4.
15
+ // running — transaction.runningAttempts(root, id)
16
+ // viewProblems — the BASE_MISMATCH reasons for this change
17
+ function preface({ id, lifecycle, base = null, running = [], viewProblems = [] }) {
18
+ if (running.length) {
19
+ const first = running[0];
20
+ return first.alive === false
21
+ ? action('recover the interrupted attempt', `${RUNTIME} recover`, { rule: 2 })
22
+ : action('wait for the running attempt', `wait for attempt ${first.id}, or ${RUNTIME} recover if its owner died`, { rule: 2 });
23
+ }
24
+ if (changes.TERMINAL.includes(lifecycle.state)) {
25
+ return action('inspect the historical change; execution needs a new change',
26
+ `${RUNTIME} change show ${id} — then register a replacement: ${RUNTIME} register --prd <prd> --change <id>`, { rule: 3 });
27
+ }
28
+ if (viewProblems.length) {
29
+ return action('check out the branch that carries the change',
30
+ `git checkout <branch with base ${String(base || '').slice(0, 7)}> — ${viewProblems[0].detail}`, { rule: 3 });
31
+ }
32
+ return null;
33
+ }
34
+
35
+ // Rule 3 again, after rule 4 is satisfied: a planned or paused change has to be
36
+ // brought to active before any ticket command will run.
37
+ function lifecycleAction({ id, lifecycle }) {
38
+ if (lifecycle.state === 'planned') return action('activate the change', `${RUNTIME} change activate ${id}`, { rule: 3 });
39
+ if (lifecycle.state === 'paused') return action('resume the change', `${RUNTIME} change resume ${id}`, { rule: 3 });
40
+ return null;
41
+ }
42
+
43
+ // A ticket command resolves its ticket against the *selected* change, so when a
44
+ // report describes a change this worktree has not selected, the command it prints
45
+ // is refused with WRONG_CHANGE unless the selection is changed first. Commands that
46
+ // name their change explicitly (`change authorize <id>`, `change show <id>`) run
47
+ // as written and are left alone.
48
+ const TICKET_COMMAND = /(^|[^\w])(scripts\/pincer-ticket\.sh|pincer-runtime\.cjs)\s+(start|verify|done)\s+T-/;
49
+ function qualify(next, { id, selected = true }) {
50
+ if (selected || !next || typeof next.command !== 'string' || !TICKET_COMMAND.test(next.command)) return next;
51
+ return { ...next, command: `${RUNTIME} change select ${id}, then ${next.command}` };
52
+ }
53
+
54
+ module.exports = { preface, lifecycleAction, qualify, RUNTIME };