pincer-workflow 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +9 -7
  2. package/bin/pincer.js +58 -5
  3. package/package.json +3 -3
  4. package/template/.agents/skills/pincer-code/SKILL.md +117 -12
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +57 -10
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +48 -8
  7. package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
  8. package/template/.agents/skills/pincer-release/SKILL.md +32 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +25 -2
  10. package/template/.claude/commands/pincer-code.md +117 -12
  11. package/template/.claude/commands/pincer-evaluate.md +57 -10
  12. package/template/.claude/commands/pincer-narrow.md +48 -8
  13. package/template/.claude/commands/pincer-plan.md +12 -4
  14. package/template/.claude/commands/pincer-release.md +32 -3
  15. package/template/.claude/commands/pincer-status.md +25 -2
  16. package/template/.claude/hooks/hook-policy.cjs +24 -3
  17. package/template/.claude/references/prd-template.md +11 -4
  18. package/template/.claude/references/ticket-template.md +4 -0
  19. package/template/.codex/README.md +3 -2
  20. package/template/.github/prompts/pincer-code.prompt.md +117 -12
  21. package/template/.github/prompts/pincer-evaluate.prompt.md +57 -10
  22. package/template/.github/prompts/pincer-narrow.prompt.md +48 -8
  23. package/template/.github/prompts/pincer-plan.prompt.md +12 -4
  24. package/template/.github/prompts/pincer-release.prompt.md +32 -3
  25. package/template/.github/prompts/pincer-status.prompt.md +25 -2
  26. package/template/AGENTS.md +22 -0
  27. package/template/docs/dry-run-checklist.md +70 -6
  28. package/template/docs/release-checklist.md +5 -2
  29. package/template/docs/runtime-contracts.md +1683 -0
  30. package/template/scripts/pincer-evidence.cjs +13 -229
  31. package/template/scripts/pincer-runtime/adopt.cjs +132 -0
  32. package/template/scripts/pincer-runtime/agreement.cjs +240 -0
  33. package/template/scripts/pincer-runtime/authorization.cjs +167 -0
  34. package/template/scripts/pincer-runtime/changes.cjs +517 -0
  35. package/template/scripts/pincer-runtime/checks.cjs +48 -0
  36. package/template/scripts/pincer-runtime/coverage.cjs +361 -0
  37. package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
  38. package/template/scripts/pincer-runtime/evidence.cjs +676 -0
  39. package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
  40. package/template/scripts/pincer-runtime/gates.cjs +73 -0
  41. package/template/scripts/pincer-runtime/identity.cjs +163 -0
  42. package/template/scripts/pincer-runtime/impact.cjs +177 -0
  43. package/template/scripts/pincer-runtime/io.cjs +41 -0
  44. package/template/scripts/pincer-runtime/lifecycle.cjs +311 -0
  45. package/template/scripts/pincer-runtime/locator.cjs +158 -0
  46. package/template/scripts/pincer-runtime/migrate.cjs +204 -0
  47. package/template/scripts/pincer-runtime/parse.cjs +316 -0
  48. package/template/scripts/pincer-runtime/phases.cjs +245 -0
  49. package/template/scripts/pincer-runtime/readiness.cjs +97 -0
  50. package/template/scripts/pincer-runtime/requirements.cjs +255 -0
  51. package/template/scripts/pincer-runtime/resume.cjs +205 -0
  52. package/template/scripts/pincer-runtime/routing.cjs +54 -0
  53. package/template/scripts/pincer-runtime/runner.cjs +242 -0
  54. package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
  55. package/template/scripts/pincer-runtime/source.cjs +129 -0
  56. package/template/scripts/pincer-runtime/state.cjs +314 -0
  57. package/template/scripts/pincer-runtime/status.cjs +514 -0
  58. package/template/scripts/pincer-runtime/transaction.cjs +200 -0
  59. package/template/scripts/pincer-runtime/transitions.cjs +134 -0
  60. package/template/scripts/pincer-runtime.cjs +661 -0
  61. package/template/scripts/pincer-status.sh +11 -162
  62. package/template/scripts/pincer-ticket.sh +19 -139
  63. package/template/scripts/pincer-ticket-lib.sh +0 -321
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- // PINCER evidence — read-only validator for candidate evidence manifests
4
- // (evidence schema 1). Dependency-free; runs under `node` on any platform.
3
+ // PINCER evidence — read-only validator for candidate evidence manifests.
4
+ // Dependency-free; runs under `node` on any platform. The implementation lives
5
+ // in scripts/pincer-runtime/evidence.cjs (shared with status and release).
5
6
  //
6
7
  // node scripts/pincer-evidence.cjs validate .prd/evidence/prd-vN/<candidate>/manifest.json \
7
8
  // [--candidate <sha>] [--base <sha>] [--prd .prd/prd-vN.md] [--files]
@@ -20,230 +21,12 @@
20
21
  // application; that judgment stays with the reviewer.
21
22
  const fs = require('node:fs');
22
23
  const path = require('node:path');
23
- const crypto = require('node:crypto');
24
- const { execFileSync } = require('node:child_process');
25
-
26
- const SCHEMA = 1;
27
- const HEX40 = /^[0-9a-f]{40}$/;
28
- const SHA256 = /^[0-9a-f]{64}$/;
29
- const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
30
- const PRD_REF = /^\.prd\/prd-v([1-9][0-9]{0,8})\.md$/;
31
- const MANIFEST_AT = /^\.prd\/evidence\/prd-v([1-9][0-9]{0,8})\/([0-9a-f]{40})\/manifest\.json$/;
32
- // Requirement IDs are the PRD's own: R-01 from the template, or a supplied PRD's
33
- // REQ-1 / AC-12 style, kept verbatim rather than renamed.
34
- const REQ_ID = /^[A-Z][A-Z0-9]{0,7}-[0-9]{1,6}$/;
35
- const TICKET_ID = /^T-[0-9]{2,6}$/;
36
- const CHECK_ID = /^C-[0-9]{2,6}$/;
37
- const IMAGE = /\.(png|jpe?g|webp)$/i;
38
- const MAX_TEXT = 2000; // guard against pasted environment dumps
39
- const TOP_KEYS = ['schema', 'prd', 'base', 'candidate', 'created', 'environment', 'coverage_review', 'requirements', 'checks', 'visual_review', 'artifacts'];
40
- const ENV_KEYS = ['os', 'node', 'tools', 'limitations'];
41
- const REQ_KEYS = ['id', 'disposition', 'tickets', 'checks', 'note', 'authorized_by'];
42
- const CHECK_KEYS = ['id', 'kind', 'required', 'result', 'command', 'timestamp', 'artifacts', 'scenario', 'viewport', 'observed', 'note'];
43
- const DISPOSITIONS = ['delivered', 'blocked', 'deferred'];
44
- const KINDS = ['command', 'visual', 'review'];
45
- const RESULTS = ['passed', 'failed', 'unverified'];
46
-
47
- const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
48
- const shortText = v => typeof v === 'string' && v.length <= MAX_TEXT;
49
- const nonempty = v => shortText(v) && v.trim() !== '';
50
- const toPosix = p => p.split(path.sep).join('/');
51
-
52
- function repoRoot() {
53
- if (process.env.CLAUDE_PROJECT_DIR) return path.resolve(process.env.CLAUDE_PROJECT_DIR);
54
- try {
55
- return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
56
- } catch {
57
- return process.cwd();
58
- }
59
- }
60
-
61
- // Resolve through symlinks above the repository (macOS /var -> /private/var)
62
- // without requiring the leaf to exist yet.
63
- function realpathDeep(p) {
64
- try { return fs.realpathSync(p); } catch {
65
- const parent = path.dirname(p);
66
- return parent === p ? p : path.join(realpathDeep(parent), path.basename(p));
67
- }
68
- }
69
-
70
- function digestFile(file) {
71
- return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
72
- }
73
-
74
- // Why a repository-relative path is unsafe, or null when it is acceptable.
75
- function unsafePath(p) {
76
- if (p.startsWith('/')) return 'absolute paths are not allowed';
77
- if (/^[A-Za-z]:/.test(p)) return 'drive-letter paths are not allowed';
78
- if (p.includes('\\')) return 'backslashes are not allowed; use repository-relative POSIX paths';
79
- if (p.split('/').some(s => s === '' || s === '.' || s === '..')) return 'path must be normalized and repository-relative (no "..", "." or empty segments)';
80
- return null;
81
- }
82
-
83
- function validate(manifestArg, opts) {
84
- const root = realpathDeep(repoRoot());
85
- const abs = realpathDeep(path.resolve(manifestArg));
86
- const rel = toPosix(path.relative(root, abs));
87
- const at = rel.match(MANIFEST_AT);
88
- if (!at) return [`manifest must live at .prd/evidence/prd-vN/<candidate>/manifest.json inside the repository (got ${rel})`];
89
- const [, dirVersion, dirCandidate] = at;
90
- const dirRel = path.posix.dirname(rel);
91
-
92
- let raw;
93
- try { raw = fs.readFileSync(abs, 'utf8'); } catch { return ['missing — run /pincer-evaluate to write evidence for this candidate']; }
94
- let doc;
95
- try { doc = JSON.parse(raw); } catch (error) { return [`malformed JSON (${error.message})`]; }
96
- if (!isObject(doc)) return ['malformed: the manifest must be a JSON object'];
97
- if (doc.schema !== SCHEMA) return [`unknown evidence schema ${JSON.stringify(doc.schema)} — this runtime validates schema ${SCHEMA}`];
98
-
99
- const problems = [];
100
- const problem = message => problems.push(message);
101
- for (const key of Object.keys(doc)) if (!TOP_KEYS.includes(key)) problem(`unknown top-level key "${key}"`);
102
- for (const key of TOP_KEYS) if (!(key in doc)) problem(`missing "${key}"`);
103
-
104
- const prd = typeof doc.prd === 'string' ? doc.prd.match(PRD_REF) : null;
105
- if (!prd) problem('prd must be a reference of the form .prd/prd-vN.md');
106
- else if (prd[1] !== dirVersion) problem(`wrong PRD: manifest names ${doc.prd} but lives under prd-v${dirVersion}`);
107
- if (opts.prd && doc.prd !== opts.prd) problem(`wrong PRD: manifest is for ${doc.prd}, expected ${opts.prd}`);
108
-
109
- for (const key of ['base', 'candidate']) {
110
- if (typeof doc[key] !== 'string' || !HEX40.test(doc[key])) problem(`${key} must be a full 40-hex commit ID`);
111
- }
112
- if (typeof doc.candidate === 'string' && HEX40.test(doc.candidate) && doc.candidate !== dirCandidate) {
113
- problem(`wrong candidate: manifest names ${doc.candidate} but lives under ${dirCandidate}`);
114
- }
115
- if (opts.candidate && doc.candidate !== opts.candidate) problem(`wrong candidate: manifest is for ${doc.candidate}, expected ${opts.candidate}`);
116
- if (opts.base && doc.base !== opts.base) problem(`wrong base: manifest records ${doc.base}, expected ${opts.base}`);
117
- if (typeof doc.created !== 'string' || !ISO_UTC.test(doc.created)) problem('created must be an ISO-8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ)');
118
-
119
- const env = doc.environment;
120
- if (!isObject(env)) problem('environment must be an object with os, node, tools and limitations');
121
- else {
122
- for (const key of ['os', 'node']) if (!nonempty(env[key])) problem(`environment.${key} must be a short nonempty string (redacted summary, not a dump)`);
123
- for (const key of ['tools', 'limitations']) {
124
- if (!Array.isArray(env[key]) || !env[key].every(nonempty)) problem(`environment.${key} must be an array of short strings`);
125
- }
126
- for (const key of Object.keys(env)) if (!ENV_KEYS.includes(key)) problem(`environment.${key} is not allowed — persist redacted summaries only`);
127
- }
128
- if (!nonempty(doc.coverage_review)) problem('coverage_review must be a nonempty string recording the reviewer judgment on requirement coverage');
129
-
130
- // Artifacts: repository-contained regular files with matching digests.
131
- const artifacts = new Map();
132
- if (!Array.isArray(doc.artifacts)) problem('artifacts must be an array of {path, sha256}');
133
- else doc.artifacts.forEach((entry, index) => {
134
- const label = `artifacts[${index}]`;
135
- if (!isObject(entry)) { problem(`${label} must be an object {path, sha256}`); return; }
136
- for (const key of Object.keys(entry)) if (!['path', 'sha256'].includes(key)) problem(`${label}.${key} is not allowed`);
137
- const p = entry.path;
138
- if (typeof p !== 'string' || p === '') { problem(`${label}.path must be a nonempty string`); return; }
139
- if (artifacts.has(p)) problem(`duplicate artifact path ${p}`);
140
- artifacts.set(p, false);
141
- const why = unsafePath(p);
142
- if (why) { problem(`artifact ${p}: ${why}`); return; }
143
- if (!p.startsWith(`${dirRel}/`)) { problem(`artifact ${p}: outside the evidence directory ${dirRel}/`); return; }
144
- const digestOk = typeof entry.sha256 === 'string' && SHA256.test(entry.sha256);
145
- if (!digestOk) problem(`artifact ${p}: sha256 must be a full 64-hex digest`);
146
- const segments = p.split('/');
147
- let current = root;
148
- for (let i = 0; i < segments.length; i++) {
149
- current = path.join(current, segments[i]);
150
- let stat;
151
- try { stat = fs.lstatSync(current); } catch { problem(`artifact ${p}: missing`); return; }
152
- const last = i === segments.length - 1;
153
- if (stat.isSymbolicLink()) {
154
- problem(last ? `artifact ${p}: is a symlink (only regular files inside the repository are accepted)` : `artifact ${p}: path component ${segments.slice(0, i + 1).join('/')} is a symlink`);
155
- return;
156
- }
157
- if (!last && !stat.isDirectory()) { problem(`artifact ${p}: ${segments.slice(0, i + 1).join('/')} is not a directory`); return; }
158
- if (last && !stat.isFile()) { problem(`artifact ${p}: not a regular file`); return; }
159
- }
160
- if (digestOk && digestFile(current) !== entry.sha256) problem(`artifact ${p}: digest mismatch — the file changed after the evidence was recorded`);
161
- });
162
-
163
- // Checks: what was run or judged, with results and artifact references.
164
- const checks = new Map();
165
- let visualChecks = 0;
166
- if (!Array.isArray(doc.checks)) problem('checks must be an array');
167
- else doc.checks.forEach((check, index) => {
168
- const label = `checks[${index}]`;
169
- if (!isObject(check)) { problem(`${label} must be an object`); return; }
170
- const id = typeof check.id === 'string' && CHECK_ID.test(check.id) ? check.id : null;
171
- if (!id) problem(`${label}.id must be a check ID such as C-01`);
172
- else if (checks.has(id)) problem(`duplicate check ID ${id}`);
173
- else checks.set(id, check);
174
- const name = id || label;
175
- for (const key of Object.keys(check)) if (!CHECK_KEYS.includes(key)) problem(`check ${name}: unknown key "${key}"`);
176
- if (!KINDS.includes(check.kind)) problem(`check ${name}: kind must be one of ${KINDS.join(', ')}`);
177
- if (typeof check.required !== 'boolean') problem(`check ${name}: required must be true or false`);
178
- if (!RESULTS.includes(check.result)) problem(`check ${name}: result must be one of ${RESULTS.join(', ')}`);
179
- if (typeof check.timestamp !== 'string' || !ISO_UTC.test(check.timestamp)) problem(`check ${name}: timestamp must be an ISO-8601 UTC timestamp`);
180
- if (check.kind === 'command' && !nonempty(check.command)) problem(`check ${name}: command kind requires the command that was run`);
181
- for (const key of ['command', 'scenario', 'viewport', 'observed', 'note']) {
182
- if (key in check && !shortText(check[key])) problem(`check ${name}: ${key} must be a short string`);
183
- }
184
- let images = 0;
185
- if (!Array.isArray(check.artifacts)) problem(`check ${name}: artifacts must be an array of repository-relative paths`);
186
- else for (const p of check.artifacts) {
187
- if (typeof p !== 'string') { problem(`check ${name}: artifact reference must be a string`); continue; }
188
- if (!artifacts.has(p)) problem(`check ${name}: references unlisted artifact ${p} (dangling reference)`);
189
- else artifacts.set(p, true);
190
- if (IMAGE.test(p)) images++;
191
- }
192
- if (check.kind === 'visual') {
193
- visualChecks++;
194
- for (const key of ['scenario', 'viewport', 'observed']) if (!nonempty(check[key])) problem(`check ${name}: visual check requires ${key}`);
195
- // A passed visual check must show its image; an unverified one (tool
196
- // unavailable) is recorded honestly without one and, when required, blocks.
197
- if (check.result === 'passed' && images === 0) problem(`check ${name}: a passed visual check requires a saved image artifact (.png, .jpg or .webp)`);
198
- }
199
- if (check.required === true && check.result !== 'passed') {
200
- problem(`required check ${name} is ${check.result} — readiness is blocked until it passes on a new candidate or the requirement is deferred with authorization`);
201
- }
202
- });
203
- for (const [p, referenced] of artifacts) if (!referenced) problem(`artifact ${p}: not referenced by any check`);
204
-
205
- // Requirements: every ID dispositioned; deferrals authorized; checks resolve.
206
- const requirements = new Set();
207
- if (!Array.isArray(doc.requirements) || doc.requirements.length === 0) problem('requirements must be a nonempty array — every PRD requirement needs a disposition');
208
- else doc.requirements.forEach((req, index) => {
209
- const label = `requirements[${index}]`;
210
- if (!isObject(req)) { problem(`${label} must be an object`); return; }
211
- const id = typeof req.id === 'string' && REQ_ID.test(req.id) ? req.id : null;
212
- if (!id) problem(`${label}.id must be a requirement ID such as R-01 or the PRD's own REQ-1 (uppercase prefix, dash, digits)`);
213
- else if (requirements.has(id)) problem(`duplicate requirement ID ${id}`);
214
- else requirements.add(id);
215
- const name = id || label;
216
- for (const key of Object.keys(req)) if (!REQ_KEYS.includes(key)) problem(`requirement ${name}: unknown key "${key}"`);
217
- if (!DISPOSITIONS.includes(req.disposition)) problem(`requirement ${name}: disposition must be one of ${DISPOSITIONS.join(', ')}`);
218
- if (!Array.isArray(req.tickets) || !req.tickets.every(t => typeof t === 'string' && TICKET_ID.test(t))) problem(`requirement ${name}: tickets must be an array of ticket IDs such as T-01`);
219
- if (!Array.isArray(req.checks)) problem(`requirement ${name}: checks must be an array of check IDs`);
220
- else for (const c of req.checks) {
221
- if (typeof c !== 'string' || !checks.has(c)) problem(`requirement ${name}: references unknown check ${JSON.stringify(c)} (dangling reference)`);
222
- }
223
- for (const key of ['note', 'authorized_by']) if (key in req && !shortText(req[key])) problem(`requirement ${name}: ${key} must be a short string`);
224
- if (req.disposition === 'deferred' && !nonempty(req.authorized_by)) problem(`requirement ${name}: deferred requires authorized_by naming the explicit user authorization`);
225
- if (req.disposition === 'delivered' && Array.isArray(req.checks) && req.checks.length === 0) problem(`requirement ${name}: delivered requires at least one check`);
226
- if (req.disposition === 'blocked') problem(`requirement ${name} is blocked — readiness is blocked until it is delivered on a new candidate or deferred with authorization`);
227
- });
228
-
229
- const visual = doc.visual_review;
230
- if (!isObject(visual) || typeof visual.applicable !== 'boolean') problem('visual_review must be {applicable: boolean, reason?: string}');
231
- else {
232
- for (const key of Object.keys(visual)) if (!['applicable', 'reason'].includes(key)) problem(`visual_review.${key} is not allowed`);
233
- if (visual.applicable === false && !nonempty(visual.reason)) problem('visual_review.reason is required when visual review is not applicable (say why)');
234
- if (visual.applicable === true && visualChecks === 0) problem('visual_review.applicable is true but no visual check is recorded');
235
- if ('reason' in visual && !shortText(visual.reason)) problem('visual_review.reason must be a short string');
236
- }
237
-
238
- if (problems.length === 0 && opts.files) {
239
- opts.list = [rel, ...doc.artifacts.map(a => a.path)];
240
- }
241
- return problems;
242
- }
24
+ const { HEX40, PRD_REF, validate, digestFile } = require('./pincer-runtime/evidence.cjs');
25
+ const io = require('./pincer-runtime/io.cjs');
243
26
 
244
27
  function usage(message) {
245
- if (message) process.stderr.write(`pincer-evidence: ${message}\n`);
246
- process.stderr.write('usage: pincer-evidence.cjs validate <manifest> [--candidate <sha>] [--base <sha>] [--prd .prd/prd-vN.md] [--files]\n pincer-evidence.cjs digest <file>...\n');
28
+ if (message) io.err(`pincer-evidence: ${message}\n`);
29
+ io.err('usage: pincer-evidence.cjs validate <manifest> [--candidate <sha>] [--base <sha>] [--prd .prd/prd-vN.md] [--files]\n pincer-evidence.cjs digest <file>...\n');
247
30
  process.exit(2);
248
31
  }
249
32
 
@@ -269,20 +52,21 @@ function main(argv) {
269
52
  if (opts.prd !== undefined && !PRD_REF.test(opts.prd)) usage('--prd must be of the form .prd/prd-vN.md');
270
53
  const problems = validate(manifest, opts);
271
54
  if (problems.length > 0) {
272
- for (const p of problems) process.stderr.write(`evidence: ${manifest}: ${p}\n`);
55
+ for (const p of problems) io.err(`evidence: ${manifest}: ${p}\n`);
273
56
  process.exit(1);
274
57
  }
275
58
  const doc = JSON.parse(fs.readFileSync(path.resolve(manifest), 'utf8'));
276
- process.stdout.write(`ok ${doc.candidate}\n`);
277
- if (opts.files) for (const p of opts.list) process.stdout.write(`${p}\n`);
59
+ io.out(`ok ${doc.candidate}${doc.schema >= 2 ? ` schema ${doc.schema}` : ''}\n`);
60
+ for (const l of opts.limitations || []) io.err(`evidence: limitation: ${l}\n`);
61
+ if (opts.files) for (const p of opts.list) io.out(`${p}\n`);
278
62
  return;
279
63
  }
280
64
  if (command === 'digest') {
281
65
  if (rest.length === 0) usage('digest requires at least one file');
282
66
  for (const file of rest) {
283
67
  let digest;
284
- try { digest = digestFile(file); } catch (error) { process.stderr.write(`pincer-evidence: ${file}: ${error.code === 'ENOENT' ? 'missing' : error.message}\n`); process.exit(1); }
285
- process.stdout.write(`${digest} ${file}\n`);
68
+ try { digest = digestFile(file); } catch (error) { io.err(`pincer-evidence: ${file}: ${error.code === 'ENOENT' ? 'missing' : error.message}\n`); process.exit(1); }
69
+ io.out(`${digest} ${file}\n`);
286
70
  }
287
71
  return;
288
72
  }
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+ // PINCER runtime — strict coverage adoption (docs/runtime-contracts.md, "Strict
3
+ // coverage" → "Adoption and rollback"). `coverage adopt --preview` computes the plan
4
+ // and writes nothing; `--apply` is one transaction that backs up the schema 2
5
+ // record, records the adoption agreement (projection 2, snapshot schema 2) and
6
+ // rewrites the record as schema 3 with the retained capability and one `adopt`
7
+ // event. It validates the authored inputs (inventory, map, graph), refuses on a
8
+ // running attempt, a terminal state, an incomplete transaction or changed inputs,
9
+ // grants no authorization, and is idempotent. Migration never adopts.
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const changes = require('./changes.cjs');
13
+ const agreement = require('./agreement.cjs');
14
+ const authorization = require('./authorization.cjs');
15
+ const coverage = require('./coverage.cjs');
16
+ const dispositions = require('./dispositions.cjs');
17
+ const transaction = require('./transaction.cjs');
18
+ const state = require('./state.cjs');
19
+ const { nowIso } = require('./fsutil.cjs');
20
+
21
+ // Compute the plan. Returns { change, conflicts: [{ code, detail }], already, record,
22
+ // file, agreement: { id, digest }, inventory: { requirements, scenarios, digest },
23
+ // map: { path, digest }, historical, scope: [...], backup }.
24
+ function plan(root, { change } = {}) {
25
+ const conflicts = [];
26
+ const conflict = (code, detail) => conflicts.push({ code, detail });
27
+ const out = { change, conflicts, already: false, record: null, file: null };
28
+ if (typeof change !== 'string' || !changes.CHANGE_ID.test(change)) { conflict('INPUT_INVALID', `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${change}`); return out; }
29
+ const loaded = changes.loadRecords(root);
30
+ if (loaded.mode === 'legacy') { conflict('CHANGE_REQUIRED', `no change record under ${changes.CHANGES_DIR}/ — register the change first (node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md); migration and registration never adopt strict coverage`); return out; }
31
+ if (loaded.mode === 'migrated') { conflict('MIGRATION_REQUIRED', `${changes.CHANGES_DIR}/ holds a v0.5.0 binding; migrate to change records first (migrate --preview --prd <prd>); migration never adopts strict coverage`); return out; }
32
+ for (const p of loaded.problems) conflict(p.code, p.detail);
33
+ if (conflicts.length) return out;
34
+ const entry = loaded.records.get(change);
35
+ if (!entry) { conflict('INPUT_INVALID', `no change record ${changes.recordFile(change)} (retained: ${[...loaded.records.keys()].join(', ') || 'none'})`); return out; }
36
+ out.record = entry.record; out.file = entry.file;
37
+ if (changes.isStrict(entry.record)) { out.already = true; return out; }
38
+ const record = entry.record;
39
+ if (changes.TERMINAL.includes(record.lifecycle.state)) conflict('LIFECYCLE_BLOCKED', `change ${change} is ${record.lifecycle.state}; its history cannot adopt strict coverage — register a new change`);
40
+ const running = transaction.runningAttempts(root, change);
41
+ if (running.length) { const a = running[0]; const hint = a.alive === false ? ' (its owner is no longer running: run recover first)' : a.alive === true ? ` (pid ${a.owner.pid} is still running)` : ` (owned by ${a.owner.host || 'another host'})`; conflict('ATTEMPT_RUNNING', `attempt ${a.id} of change ${change} is running${hint}; adoption waits for it`); }
42
+ const cov = coverage.load(root, record);
43
+ if (cov.code && cov.code !== 'COVERAGE_INCOMPLETE') { for (const p of cov.problems) conflict(cov.code, p); return out; }
44
+ if (cov.code === 'COVERAGE_INCOMPLETE') for (const p of cov.problems) conflict('COVERAGE_INCOMPLETE', p);
45
+ if (conflicts.length) return out;
46
+ const computed = agreement.compute(root, { ...record, schema: changes.SCHEMA_STRICT });
47
+ if (computed.code) { conflict(computed.code, computed.problem); return out; }
48
+ const gid = `G-${String(record.agreements.length + 1).padStart(2, '0')}`;
49
+ out.agreement = { id: gid, digest: computed.digest };
50
+ out.computed = computed;
51
+ out.inventory = { requirements: Object.keys(cov.inventory.requirements).length, scenarios: Object.keys(cov.inventory.scenarios).length, digest: cov.inventory.digest };
52
+ out.map = { path: cov.map.file, digest: cov.map.digest };
53
+ out.historical = state.exists(root) ? state.listAttempts(root).filter(a => a.context && a.context.change === change && a.schema !== 3).length : 0;
54
+ // Scope dispositions are reported for information: their authorization is recorded after adoption.
55
+ const verdict = authorization.verdict(root, record);
56
+ out.scope = Object.values(cov.graph.scope).map(s => ({ id: s.id, disposition: s.disposition, decision: s.decision }));
57
+ out.scopeProblems = dispositions.scopeProblems(record, cov.graph, verdict).problems.map(p => p.detail);
58
+ out.backup = `.pincer/backups/<UTC timestamp>/${entry.file}`;
59
+ return out;
60
+ }
61
+
62
+ function renderPlan(p) {
63
+ const lines = [];
64
+ if (p.conflicts.length) {
65
+ for (const c of p.conflicts) lines.push(`conflict ${c.code}: ${c.detail}`);
66
+ lines.push('adoption refused: resolve the conflicts above; nothing was written');
67
+ return `${lines.join('\n')}\n`;
68
+ }
69
+ if (p.already) {
70
+ lines.push(`already adopted: change ${p.change} is strict since ${p.record.coverage.adopted} (map ${p.record.coverage.map}, adoption agreement ${p.record.coverage.agreement}); nothing to do`);
71
+ return `${lines.join('\n')}\n`;
72
+ }
73
+ lines.push(`adoption plan for change ${p.change} (${p.file}, ${p.record.lifecycle.state})`);
74
+ lines.push(` inventory ${p.inventory.requirements} requirement(s), ${p.inventory.scenarios} scenario(s) · digest ${p.inventory.digest.slice(0, 12)}`);
75
+ lines.push(` map ${p.map.path} · digest ${p.map.digest.slice(0, 12)} · structure complete`);
76
+ for (const s of p.scope) lines.push(` scope ${s.id} ${s.disposition} by decision ${s.decision}${p.scopeProblems.find(d => d.startsWith(`${s.id} (`)) ? ` — not yet authorized: ${p.scopeProblems.find(d => d.startsWith(`${s.id} (`)).replace(/^[^:]*: /, '')}` : ''}`);
77
+ lines.push(` agreement ${p.agreement.id} ${p.agreement.digest.slice(0, 12)} (projection 2: PRD revision, inventory, coverage map, tickets, resolved decisions) recorded with its snapshot`);
78
+ lines.push(` record ${p.file} → schema 3 with coverage { map, adopted, agreement ${p.agreement.id} } and one adopt event; ${p.record.agreements.length} earlier agreement(s) kept (inventory history unavailable before ${p.agreement.id})`);
79
+ lines.push(` history ${p.historical} existing attempt(s) of this change become HISTORICAL_EVIDENCE until verified again`);
80
+ lines.push(` backup ${p.backup}`);
81
+ lines.push(' note adoption grants no authorization: the agreement above needs `change authorize` (user, or --delegated --basis A-NN) before execution');
82
+ lines.push(`apply with: node scripts/pincer-runtime.cjs coverage adopt --apply --change ${p.change} --agreement ${p.agreement.digest}`);
83
+ return `${lines.join('\n')}\n`;
84
+ }
85
+
86
+ // Apply as one transaction. Returns { plan, applied, record, backup } | { plan, already } |
87
+ // { plan, applied: false } (conflicts) | { plan, error, code }.
88
+ function apply(root, { change, agreement: expected = null, hooks = null } = {}) {
89
+ const first = plan(root, { change });
90
+ if (first.conflicts.length) return { plan: first, applied: false };
91
+ if (first.already) return { plan: first, applied: false, already: true };
92
+ const stamp = nowIso().replace(/[-:]/g, '');
93
+ let backupRel = null;
94
+ try {
95
+ const out = transaction.run(root, { command: `coverage adopt ${change}`, hooks }, ctx => {
96
+ const p = plan(root, { change });
97
+ if (p.conflicts.length) ctx.refuse(p.conflicts[0].code, p.conflicts[0].detail);
98
+ if (p.already) return { plan: p, already: true };
99
+ if (expected !== null && expected !== p.agreement.digest) ctx.refuse('AGREEMENT_CHANGED', `--agreement ${expected.slice(0, 12)} is not the agreement adoption would record now (${p.agreement.digest.slice(0, 12)}); the inventory, the map, a ticket or a decision changed since the preview — preview again and adopt the current digest`);
100
+ ctx.idle(change);
101
+ const old = p.record;
102
+ // Backup before anything is staged (the copy is outside the transaction's targets).
103
+ const src = path.join(root, p.file);
104
+ const dest = path.join(root, '.pincer', 'backups', stamp, p.file);
105
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
106
+ fs.copyFileSync(src, dest);
107
+ backupRel = `.pincer/backups/${stamp}/${p.file}`;
108
+ const record = {};
109
+ for (const k of changes.RECORD_KEYS_STRICT) record[k] = k === 'coverage' ? null : JSON.parse(JSON.stringify(old[k]));
110
+ record.schema = changes.SCHEMA_STRICT; record.runtime = changes.RUNTIME_STRICT;
111
+ record.agreements = old.agreements.map(g => { const e = {}; for (const k of changes.AGREEMENT_KEYS_STRICT) e[k] = k === 'inventory' || k === 'coverage' ? null : g[k]; return e; });
112
+ record.coverage = { map: coverage.file(change), adopted: ctx.now, agreement: p.agreement.id };
113
+ const appended = agreement.appendAgreement(ctx, changes, record, p.file, p.computed, { stage: false, event: false });
114
+ if (appended.agreement.id !== p.agreement.id) ctx.refuse('STATE_CHANGED', `the record changed since the operation was prepared (agreement ${appended.agreement.id} instead of ${p.agreement.id})`);
115
+ const sequence = record.sequence + 1;
116
+ record.events.push({ sequence, kind: 'adopt', from: record.lifecycle.state, to: record.lifecycle.state, at: ctx.now, reason: null, agreement: p.agreement.id, authorization: null, decision: null, replacement: null, note: null });
117
+ record.sequence = sequence;
118
+ const invalid = changes.validateRecord(record, p.file);
119
+ if (invalid) ctx.refuse(invalid.code, invalid.problem);
120
+ ctx.write(p.file, record);
121
+ return { plan: p, record };
122
+ });
123
+ if (out.result.already) return { plan: out.result.plan, applied: false, already: true };
124
+ return { plan: out.result.plan, applied: true, record: out.result.record, backup: backupRel };
125
+ } catch (error) {
126
+ if (error.refusal) return { plan: first, applied: false, error: error.message, code: error.code };
127
+ if (error.code === 'STATE_BUSY') return { plan: first, applied: false, error: error.message, code: 'STATE_BUSY' };
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ module.exports = { plan, renderPlan, apply };