pincer-workflow 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +7 -5
  2. package/bin/pincer.js +42 -5
  3. package/package.json +2 -2
  4. package/template/.agents/skills/pincer-code/SKILL.md +51 -4
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +29 -2
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +11 -2
  7. package/template/.agents/skills/pincer-plan/SKILL.md +9 -1
  8. package/template/.agents/skills/pincer-release/SKILL.md +14 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +17 -2
  10. package/template/.claude/commands/pincer-code.md +51 -4
  11. package/template/.claude/commands/pincer-evaluate.md +29 -2
  12. package/template/.claude/commands/pincer-narrow.md +11 -2
  13. package/template/.claude/commands/pincer-plan.md +9 -1
  14. package/template/.claude/commands/pincer-release.md +14 -3
  15. package/template/.claude/commands/pincer-status.md +17 -2
  16. package/template/.claude/hooks/hook-policy.cjs +17 -3
  17. package/template/.claude/references/ticket-template.md +4 -0
  18. package/template/.codex/README.md +3 -2
  19. package/template/.github/prompts/pincer-code.prompt.md +51 -4
  20. package/template/.github/prompts/pincer-evaluate.prompt.md +29 -2
  21. package/template/.github/prompts/pincer-narrow.prompt.md +11 -2
  22. package/template/.github/prompts/pincer-plan.prompt.md +9 -1
  23. package/template/.github/prompts/pincer-release.prompt.md +14 -3
  24. package/template/.github/prompts/pincer-status.prompt.md +17 -2
  25. package/template/AGENTS.md +6 -0
  26. package/template/docs/dry-run-checklist.md +46 -3
  27. package/template/docs/release-checklist.md +2 -1
  28. package/template/docs/runtime-contracts.md +437 -0
  29. package/template/scripts/pincer-evidence.cjs +5 -223
  30. package/template/scripts/pincer-runtime/evidence.cjs +391 -0
  31. package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
  32. package/template/scripts/pincer-runtime/identity.cjs +146 -0
  33. package/template/scripts/pincer-runtime/lifecycle.cjs +289 -0
  34. package/template/scripts/pincer-runtime/migrate.cjs +127 -0
  35. package/template/scripts/pincer-runtime/parse.cjs +297 -0
  36. package/template/scripts/pincer-runtime/readiness.cjs +89 -0
  37. package/template/scripts/pincer-runtime/runner.cjs +224 -0
  38. package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
  39. package/template/scripts/pincer-runtime/source.cjs +129 -0
  40. package/template/scripts/pincer-runtime/state.cjs +292 -0
  41. package/template/scripts/pincer-runtime/status.cjs +358 -0
  42. package/template/scripts/pincer-runtime.cjs +350 -0
  43. package/template/scripts/pincer-status.sh +11 -162
  44. package/template/scripts/pincer-ticket.sh +19 -139
  45. package/template/scripts/pincer-ticket-lib.sh +0 -321
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ // Small filesystem helpers shared by the runtime modules.
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const { execFileSync } = require('node:child_process');
7
+
8
+ const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
9
+
10
+ // Write via a temporary file in the same directory (or the journal directory on
11
+ // the same filesystem) and rename into place, so a reader never sees a partial
12
+ // file and a crash leaves either the old file or the new one.
13
+ function atomicWrite(file, content, { journalDir } = {}) {
14
+ const dir = journalDir || path.dirname(file);
15
+ fs.mkdirSync(dir, { recursive: true });
16
+ fs.mkdirSync(path.dirname(file), { recursive: true });
17
+ const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
18
+ fs.writeFileSync(temp, content);
19
+ fs.renameSync(temp, file);
20
+ }
21
+
22
+ function readJson(file) {
23
+ let raw;
24
+ try { raw = fs.readFileSync(file, 'utf8'); } catch (error) { return { error: error.code === 'ENOENT' ? 'missing' : error.message }; }
25
+ try { return { data: JSON.parse(raw) }; } catch (error) { return { error: `malformed JSON (${error.message})` }; }
26
+ }
27
+
28
+ function git(root, args, options = {}) {
29
+ return execFileSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], {
30
+ encoding: options.buffer ? 'buffer' : 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 256 * 1024 * 1024, ...options,
31
+ });
32
+ }
33
+ function tryGit(root, args, options) {
34
+ try { return { out: git(root, args, options) }; } catch (error) { return { error: (error.stderr && error.stderr.toString().trim()) || error.message }; }
35
+ }
36
+
37
+ module.exports = { nowIso, atomicWrite, readJson, git, tryGit };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+ // PINCER runtime — change identity (docs/runtime-contracts.md, "Change binding").
3
+ // A change binding under .prd/changes/<id>.json ties runtime verification to one
4
+ // explicitly selected PRD and its content revision. Written only here and by
5
+ // migration; never inferred from the highest PRD number.
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const parse = require('./parse.cjs');
9
+ const { nowIso, atomicWrite, readJson, tryGit } = require('./fsutil.cjs');
10
+
11
+ const CHANGE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
12
+ const SHA256 = /^[0-9a-f]{64}$/;
13
+ const RUNTIME = 1;
14
+ const BINDING_KEYS = ['schema', 'change', 'prd', 'prd_revision', 'base', 'registered', 'authorization', 'runtime', 'legacy_receipts'];
15
+
16
+ const bindingsDir = root => path.join(root, '.prd', 'changes');
17
+ function listBindings(root) {
18
+ const dir = bindingsDir(root);
19
+ if (!fs.existsSync(dir)) return [];
20
+ return fs.readdirSync(dir).filter(name => name.endsWith('.json')).sort().map(name => `.prd/changes/${name}`);
21
+ }
22
+
23
+ function validateBinding(doc) {
24
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return 'binding must be a JSON object';
25
+ if (doc.schema !== 1) return `unsupported binding schema ${JSON.stringify(doc.schema)} (this runtime reads schema 1)`;
26
+ for (const key of Object.keys(doc)) if (!BINDING_KEYS.includes(key)) return `unknown binding key "${key}"`;
27
+ for (const key of BINDING_KEYS) if (!(key in doc)) return `missing binding key "${key}"`;
28
+ if (typeof doc.change !== 'string' || !CHANGE_ID.test(doc.change)) return 'change must match [a-z0-9][a-z0-9-]{0,63}';
29
+ if (typeof doc.prd !== 'string' || !parse.PRD_REF.test(doc.prd)) return 'prd must be of the form .prd/prd-vN.md';
30
+ if (typeof doc.prd_revision !== 'string' || !SHA256.test(doc.prd_revision)) return 'prd_revision must be a 64-hex SHA-256 digest';
31
+ if (typeof doc.base !== 'string' || !parse.HEX40.test(doc.base)) return 'base must be a full 40-hex commit ID';
32
+ if (typeof doc.registered !== 'string' || !parse.TIMESTAMP.test(doc.registered)) return 'registered must be an ISO UTC timestamp';
33
+ if (doc.authorization !== null && (typeof doc.authorization !== 'string' || doc.authorization.length > 2000)) return 'authorization must be null or a short string';
34
+ if (doc.runtime !== RUNTIME) return `unsupported runtime contract ${JSON.stringify(doc.runtime)}`;
35
+ if (!doc.legacy_receipts || typeof doc.legacy_receipts !== 'object' || Array.isArray(doc.legacy_receipts)) return 'legacy_receipts must be an object';
36
+ return null;
37
+ }
38
+
39
+ // Resolve the binding for this worktree. Returns { binding, file, prd } or
40
+ // { code, problem } with the contracted codes; `prd`, when given, is the PRD the
41
+ // caller needs — a binding for another PRD is CHANGE_REQUIRED with `other` set so
42
+ // callers can treat that PRD as legacy.
43
+ function loadBinding(root, { prd } = {}) {
44
+ const files = listBindings(root);
45
+ if (files.length === 0) {
46
+ const hint = prd ? `register it with: node scripts/pincer-runtime.cjs register --prd ${prd}` : 'run register or migrate';
47
+ return { code: 'CHANGE_REQUIRED', problem: `no change binding under .prd/changes/ — ${hint}` };
48
+ }
49
+ if (files.length > 1) return { code: 'AMBIGUOUS', problem: `several change bindings under .prd/changes/ (${files.map(f => path.basename(f)).join(', ')}) — keep exactly one` };
50
+ const file = files[0];
51
+ const read = readJson(path.join(root, file));
52
+ if (read.error) return { code: 'MALFORMED', problem: `${file}: ${read.error}`, file };
53
+ const invalid = validateBinding(read.data);
54
+ if (invalid) return { code: /schema|runtime contract/.test(invalid) ? 'UNSUPPORTED_SCHEMA' : 'MALFORMED', problem: `${file}: ${invalid}`, file };
55
+ const binding = read.data;
56
+ if (path.basename(file, '.json') !== binding.change) return { code: 'MALFORMED', problem: `${file}: filename does not match change "${binding.change}"`, file };
57
+ if (prd && binding.prd !== prd) return { code: 'CHANGE_REQUIRED', other: true, binding, file, problem: `${file} binds ${binding.prd}, not ${prd}` };
58
+ const prdResult = parse.validatePrd(root, binding.prd);
59
+ if (!prdResult.ok) return { code: 'INPUT_INVALID', problem: `${binding.prd}: ${prdResult.problems[0]}`, binding, file };
60
+ const revision = parse.prdDigest(prdResult.text);
61
+ if (revision !== binding.prd_revision) {
62
+ return { code: 'REVISION_CHANGED', binding, file, revision, problem: `${binding.prd} content changed since registration (revision ${binding.prd_revision.slice(0, 12)} → ${revision.slice(0, 12)}) — rebind explicitly with: node scripts/pincer-runtime.cjs register --prd ${binding.prd} --rebind` };
63
+ }
64
+ return { binding, file, prd: prdResult };
65
+ }
66
+
67
+ function head(root) {
68
+ const result = tryGit(root, ['rev-parse', '--verify', 'HEAD^{commit}']);
69
+ if (result.error || !parse.HEX40.test(result.out.trim())) return null;
70
+ return result.out.trim();
71
+ }
72
+
73
+ const IGNORE_LINE = '.pincer/';
74
+ function gitignoreHas(root) {
75
+ const file = path.join(root, '.gitignore');
76
+ if (!fs.existsSync(file)) return false;
77
+ return fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).some(l => l === IGNORE_LINE || l === '/.pincer/' || l === '.pincer');
78
+ }
79
+ // Local runtime state must never become an untracked change: registration and
80
+ // migration both make sure .pincer/ is ignored before the binding is written.
81
+ function ensureIgnored(root) {
82
+ if (gitignoreHas(root)) return false;
83
+ const file = path.join(root, '.gitignore');
84
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
85
+ const lead = existing && !existing.endsWith('\n') ? '\n' : '';
86
+ fs.appendFileSync(file, `${lead}${existing ? '\n' : ''}# pincer runtime state (added by the runtime)\n${IGNORE_LINE}\n`);
87
+ return true;
88
+ }
89
+
90
+ function writeBinding(root, binding) {
91
+ ensureIgnored(root);
92
+ const file = path.join(bindingsDir(root), `${binding.change}.json`);
93
+ atomicWrite(file, `${JSON.stringify(binding, null, 2)}\n`);
94
+ return `.prd/changes/${binding.change}.json`;
95
+ }
96
+
97
+ // Register (or rebind / replace) the selected PRD. Returns { binding, file,
98
+ // action: 'registered' | 'unchanged' | 'updated' | 'rebound' | 'replaced', notes }
99
+ // or { code, problem }.
100
+ function register(root, { prd, change, authorization = null, replace = false, rebind = false } = {}) {
101
+ const prdResult = parse.validatePrd(root, prd);
102
+ if (!prdResult.ok) return { code: 'INPUT_INVALID', problem: `${prdResult.file || prd}: ${prdResult.problems[0]}` };
103
+ const version = prd.match(parse.PRD_REF)[1];
104
+ const id = change || `prd-v${version}`;
105
+ if (!CHANGE_ID.test(id)) return { code: 'INPUT_INVALID', problem: `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}` };
106
+ const base = head(root);
107
+ if (!base) return { code: 'UNSUPPORTED_INPUT', problem: 'registration needs a git repository with at least one commit (base = HEAD)' };
108
+ const revision = parse.prdDigest(prdResult.text);
109
+ const notes = [];
110
+ if (authorization === null) notes.push('no --authorization recorded; running registration does not prove human approval');
111
+ const files = listBindings(root);
112
+ if (files.length > 1) return { code: 'AMBIGUOUS', problem: `several change bindings under .prd/changes/ (${files.map(f => path.basename(f)).join(', ')}) — keep exactly one before registering` };
113
+ let existing = null;
114
+ if (files.length === 1) {
115
+ const read = readJson(path.join(root, files[0]));
116
+ const invalid = read.error || validateBinding(read.data);
117
+ if (invalid) return { code: 'MALFORMED', problem: `${files[0]}: ${invalid} — repair or remove it before registering` };
118
+ existing = { file: files[0], binding: read.data };
119
+ }
120
+ if (existing && (existing.binding.prd !== prd || existing.binding.change !== id)) {
121
+ if (!replace) return { code: 'AMBIGUOUS', problem: `${existing.file} already binds ${existing.binding.prd} as change "${existing.binding.change}"; one change per worktree — pass --replace to replace it (its attempts stay in local history)` };
122
+ fs.unlinkSync(path.join(root, existing.file));
123
+ const binding = { schema: 1, change: id, prd, prd_revision: revision, base, registered: nowIso(), authorization, runtime: RUNTIME, legacy_receipts: {} };
124
+ return { binding, file: writeBinding(root, binding), action: 'replaced', replaced: existing.file, notes };
125
+ }
126
+ if (existing) {
127
+ const current = existing.binding;
128
+ if (current.prd_revision === revision) {
129
+ if (authorization !== null && authorization !== current.authorization) {
130
+ const binding = { ...current, authorization };
131
+ return { binding, file: writeBinding(root, binding), action: 'updated', notes };
132
+ }
133
+ return { binding: current, file: existing.file, action: 'unchanged', notes };
134
+ }
135
+ if (!rebind) {
136
+ return { code: 'REVISION_CHANGED', problem: `${prd} content changed since registration (revision ${current.prd_revision.slice(0, 12)} → ${revision.slice(0, 12)}); pass --rebind to bind the new revision — readiness recorded for the old revision no longer applies` };
137
+ }
138
+ const binding = { ...current, prd_revision: revision, registered: nowIso(), authorization: authorization ?? current.authorization };
139
+ notes.push(`rebound to revision ${revision.slice(0, 12)}; attempts recorded for ${current.prd_revision.slice(0, 12)} no longer establish readiness`);
140
+ return { binding, file: writeBinding(root, binding), action: 'rebound', notes };
141
+ }
142
+ const binding = { schema: 1, change: id, prd, prd_revision: revision, base, registered: nowIso(), authorization, runtime: RUNTIME, legacy_receipts: {} };
143
+ return { binding, file: writeBinding(root, binding), action: 'registered', notes };
144
+ }
145
+
146
+ module.exports = { CHANGE_ID, RUNTIME, BINDING_KEYS, IGNORE_LINE, listBindings, validateBinding, loadBinding, register, writeBinding, head, gitignoreHas, ensureIgnored };
@@ -0,0 +1,289 @@
1
+ 'use strict';
2
+ // PINCER runtime — ticket lifecycle (docs/runtime-contracts.md, "Modes"). The
3
+ // only writer of ticket lifecycle fields. Legacy mode reproduces the v0.4.1
4
+ // contract word for word (receipts in the ticket, `done` re-runs the check);
5
+ // migrated mode records attempts under .pincer/runtime/ and `done` consumes the
6
+ // current passing attempt without rewriting receipts.
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const { spawn } = require('node:child_process');
10
+ const parse = require('./parse.cjs');
11
+ const identity = require('./identity.cjs');
12
+ const source = require('./source.cjs');
13
+ const state = require('./state.cjs');
14
+ const readiness = require('./readiness.cjs');
15
+ const runner = require('./runner.cjs');
16
+ const { sanitizeText, inlineSecretLine } = require('./sanitize.cjs');
17
+ const { nowIso } = require('./fsutil.cjs');
18
+ const statusModule = require('./status.cjs');
19
+
20
+ const EXIT = { OK: 0, FAILED: 1, INVALID: 4 };
21
+ class Refusal extends Error {
22
+ constructor(message, { prefix = 'pincer-ticket', exit = EXIT.FAILED, code } = {}) { super(message); this.prefix = prefix; this.exit = exit; this.reasonCode = code; }
23
+ }
24
+ const die = (message, options) => { throw new Refusal(message, options); };
25
+
26
+ // --- Frontmatter writes (the fm_set / fm_unset contract) --------------------
27
+ // Replace a field inside the frontmatter keeping an inline comment, or add it
28
+ // before the closing ---. Values are written as `key: value`.
29
+ function fmSet(text, key, value) {
30
+ const rows = parse.lines(text);
31
+ let done = false, closed = false;
32
+ const out = rows.map((line, i) => {
33
+ if (i === 0 || closed) return line;
34
+ if (!done && line.startsWith(`${key}:`)) {
35
+ done = true;
36
+ const hash = line.indexOf('#');
37
+ return hash === -1 ? `${key}: ${value}` : `${key}: ${value} ${line.slice(hash)}`;
38
+ }
39
+ if (line === '---') { closed = true; return done ? line : `${key}: ${value}\n---`; }
40
+ return line;
41
+ });
42
+ if (!done && !closed) out.push(`${key}: ${value}`);
43
+ return `${out.join('\n')}\n`;
44
+ }
45
+ function fmUnset(text, key) {
46
+ const rows = parse.lines(text);
47
+ let closed = false;
48
+ const out = rows.filter((line, i) => {
49
+ if (i === 0) return true;
50
+ if (closed) return true;
51
+ if (line === '---') { closed = true; return true; }
52
+ return !line.startsWith(`${key}:`);
53
+ });
54
+ return `${out.join('\n')}\n`;
55
+ }
56
+ const readTicket = (root, file) => fs.readFileSync(path.join(root, file), 'utf8');
57
+ const writeTicket = (root, file, text) => fs.writeFileSync(path.join(root, file), text);
58
+
59
+ // --- Resolution --------------------------------------------------------------
60
+ function resolve(root, input) {
61
+ const set = parse.validateTicketSet(root);
62
+ if (!set.ok) die(set.problems.map(p => (set.file ? `${set.file}: ${p}` : p)).join('\n'), { exit: EXIT.INVALID, code: 'INPUT_INVALID' });
63
+ const tf = parse.ticketFile(root, input);
64
+ if (tf.problem) die(tf.problem, { exit: EXIT.INVALID, code: 'INPUT_INVALID' });
65
+ return load(root, tf.file, tf.id);
66
+ }
67
+ function load(root, file, id) {
68
+ const text = readTicket(root, file);
69
+ const v = parse.validateTicket(file, text);
70
+ if (!v.ok) die(v.problems.map(p => `${file}: ${p}`).join('\n'), { exit: EXIT.INVALID, code: 'INPUT_INVALID' });
71
+ return { id: id || v.fields.ticket, file, text, fields: v.fields, timeout: v.timeout };
72
+ }
73
+ // usable_ticket_prd: association plus a ticketed/built PRD.
74
+ function usablePrd(root, t) {
75
+ const assoc = statusModule.ticketPrd(root, t.file, t.fields);
76
+ if (assoc.problem) die(assoc.problem, { prefix: 'pincer' });
77
+ if (!statusModule.usablePrd(assoc.prdResult)) die(`${assoc.prd}: PRD is draft; complete the authorized breakdown before starting (expected ticketed or built)`, { prefix: 'pincer' });
78
+ return assoc;
79
+ }
80
+ function modeFor(root, prd) {
81
+ const bind = identity.loadBinding(root, { prd });
82
+ if (bind.binding && !bind.code) return { mode: 'migrated', binding: bind.binding };
83
+ if (bind.code === 'CHANGE_REQUIRED') return { mode: 'legacy' };
84
+ die(`${bind.code}: ${bind.problem}`, { prefix: 'pincer', exit: EXIT.INVALID, code: bind.code });
85
+ return null;
86
+ }
87
+
88
+ // --- Readiness of a dependency or of the ticket itself ------------------------
89
+ function currentInputs(root, binding) {
90
+ const manifest = source.snapshot(root);
91
+ const indexRead = state.exists(root) ? state.readIndex(root) : { index: null };
92
+ if (indexRead.error) die(indexRead.error, { prefix: 'pincer', exit: EXIT.INVALID, code: 'INPUT_INVALID' });
93
+ return { manifest, index: indexRead.index, current: { prdRevision: binding.prd_revision, sourceDigest: manifest.digest } };
94
+ }
95
+ function migratedReadiness(root, t, binding, inputs) {
96
+ const key = state.contextKey({ kind: 'ticket', change: binding.change, ticket: t.id });
97
+ const attempt = inputs.index ? state.inspectArtifacts(root, state.latestAttempt(root, key, inputs.index)) : null;
98
+ let changedPaths = [];
99
+ if (attempt && attempt.source && inputs.manifest.digest && attempt.source.after !== inputs.manifest.digest) {
100
+ const before = source.readManifest(root, attempt.source.after);
101
+ if (before) changedPaths = source.diffManifests(before, inputs.manifest);
102
+ }
103
+ const legacyReceipt = (binding.legacy_receipts && binding.legacy_receipts[t.id]) || (t.fields.verified || t.fields.last_check ? { verified: t.fields.verified, last_check: t.fields.last_check } : null);
104
+ const r = readiness.migratedTicketReadiness({ text: t.text, fields: t.fields, timeout: t.timeout, attempt, legacyReceipt, current: inputs.current, sourceProblems: inputs.manifest.problems, changedPaths, contextKey: key, pointedId: inputs.index ? inputs.index.current[key] || null : null });
105
+ r.attempt = attempt;
106
+ return r;
107
+ }
108
+ function ticketReady(root, t, mode, binding, inputs) {
109
+ if (mode === 'legacy') { const r = readiness.legacyTicketReadiness(t.text, t.fields); return r.ready ? null : r.legacyMessage; }
110
+ const r = migratedReadiness(root, t, binding, inputs);
111
+ return r.ready ? null : `${r.reasons[0].code}: ${r.reasons[0].detail} — ${r.reasons[0].next}`;
112
+ }
113
+
114
+ // --- Commands ----------------------------------------------------------------
115
+ function bind(root, input, ref) {
116
+ const t = resolve(root, input);
117
+ const v = parse.validatePrd(root, ref);
118
+ if (!v.ok) die(`${v.file ? `${v.file}: ` : ''}${v.problems[0]}`, { prefix: 'pincer' });
119
+ const existing = t.fields.prd || '';
120
+ if (existing && existing !== ref) die(`${t.id} already references ${existing}; refusing to rebind it to ${ref}`);
121
+ if (existing !== ref) writeTicket(root, t.file, fmSet(t.text, 'prd', ref));
122
+ return { out: `${t.id} bound to PRD ${ref}\n` };
123
+ }
124
+
125
+ function start(root, input, { quiet = false } = {}) {
126
+ const t = resolve(root, input);
127
+ const assoc = usablePrd(root, t);
128
+ const { mode, binding } = modeFor(root, assoc.prd);
129
+ const st = t.fields.status;
130
+ if (st === 'in_progress') {
131
+ if (!t.fields.prd) writeTicket(root, t.file, fmSet(t.text, 'prd', assoc.prd));
132
+ return { out: quiet ? '' : `${t.id} already in progress (started ${t.fields.started || ''})\n`, mode, binding };
133
+ }
134
+ if (st === 'done') die(`${t.id} is already done`);
135
+ const inputs = mode === 'migrated' ? currentInputs(root, binding) : null;
136
+ for (const dep of parse.dependencies(t.fields)) {
137
+ const df = parse.ticketFile(root, dep);
138
+ if (df.problem) die(df.problem);
139
+ const d = load(root, df.file, dep);
140
+ if (d.fields.status !== 'done') die(`${t.id} depends on ${dep}, which is '${d.fields.status}' — finish ${dep} first (or fix depends_on in ${t.file})`);
141
+ const depAssoc = usablePrd(root, d);
142
+ if (depAssoc.prd !== assoc.prd) die(`${t.id} references ${assoc.prd} but dependency ${dep} references ${depAssoc.prd}`);
143
+ const problem = ticketReady(root, d, mode, binding, inputs);
144
+ if (problem) die(`${t.id} depends on ${dep}: ${problem}`);
145
+ }
146
+ let text = t.text;
147
+ if (!t.fields.prd) text = fmSet(text, 'prd', assoc.prd);
148
+ text = fmSet(text, 'status', 'in_progress');
149
+ if (!t.fields.started) text = fmSet(text, 'started', nowIso());
150
+ writeTicket(root, t.file, text);
151
+ const started = parse.frontmatterField(text, 'started');
152
+ return { out: `▶ ${t.id} started ${started} — ${t.file}\n`, mode, binding };
153
+ }
154
+
155
+ // Legacy execution: the block runs with inherited stdio in its own process group;
156
+ // SIGINT/SIGTERM to the runtime terminate it and are reported as interrupted.
157
+ function executeLegacy(root, block) {
158
+ return new Promise(resolve => {
159
+ const child = spawn(runner.runnerInfo().shell, ['-eo', 'pipefail', '-c', block], { cwd: root, detached: true, stdio: 'inherit', env: process.env });
160
+ let interrupted = null;
161
+ const onSignal = signal => { interrupted = signal; try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* gone */ } } setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, runner.GRACE_MS).unref(); };
162
+ process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal);
163
+ child.on('error', error => { process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); resolve({ code: 127, signal: null, interrupted, error: error.message }); });
164
+ child.on('exit', (code, signal) => { process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); resolve({ code, signal, interrupted }); });
165
+ });
166
+ }
167
+
168
+ async function verify(root, input, { write = process.stdout, error = process.stderr } = {}) {
169
+ const t0 = resolve(root, input);
170
+ const assoc = usablePrd(root, t0);
171
+ const { mode, binding } = modeFor(root, assoc.prd);
172
+ if (t0.fields.status === 'open') { const s = start(root, t0.id); write.write(s.out); }
173
+ const t = load(root, t0.file, t0.id);
174
+ if (mode === 'legacy') return verifyLegacy(root, t, write, error);
175
+ return verifyMigrated(root, t, binding, write, error);
176
+ }
177
+
178
+ async function verifyLegacy(root, t, write, error) {
179
+ const id = t.id, file = t.file;
180
+ if (t.fields.status === 'done') write.write(`${id} is done — re-running its check and updating the latest outcome\n`);
181
+ const commands = parse.verificationCommands(t.text);
182
+ const block = parse.blockText(t.text);
183
+ let text = fmUnset(t.text, 'verified');
184
+ const hash = parse.legacyBlockHash(t.text);
185
+ text = fmSet(text, 'last_check', `${nowIso()} running ${hash}`);
186
+ writeTicket(root, file, text);
187
+ if (!commands.some(c => !/^[ \t]*(#.*)?$/.test(c))) die(`no runnable command in the Verification block of ${file}`);
188
+ write.write(`── ${id} verification ──\n`);
189
+ for (const c of commands) write.write(` $ ${c}\n`);
190
+ const result = await executeLegacy(root, block);
191
+ const stamp = outcome => writeTicket(root, file, fmSet(readTicket(root, file), 'last_check', `${nowIso()} ${outcome} ${hash}`));
192
+ if (result.interrupted) { stamp('interrupted'); return { exit: 130 }; }
193
+ const rc = result.code === null ? 1 : result.code;
194
+ if (rc !== 0) {
195
+ stamp('failed');
196
+ error.write(`✗ ${id} verification FAILED (exit ${rc}) — failure recorded in last_check of ${file}; any prior successful receipt was revoked. Fix, then re-run verify.\n`);
197
+ return { exit: rc };
198
+ }
199
+ const after = readTicket(root, file);
200
+ if (parse.legacyBlockHash(after) !== hash) { stamp('failed'); die('Verification block changed during execution — re-run verify'); }
201
+ const v = parse.validateTicket(file, after);
202
+ let usable = v.ok;
203
+ if (usable) { try { usablePrd(root, { file, fields: v.fields }); } catch { usable = false; } }
204
+ if (!usable) { stamp('failed'); die('ticket became invalid during verification — fix it and re-run verify'); }
205
+ let done = fmSet(after, 'last_check', `${nowIso()} passed ${hash}`);
206
+ done = fmSet(done, 'verified', `${nowIso()} ${hash}`);
207
+ writeTicket(root, file, done);
208
+ write.write(`✓ ${id} verified — receipt: ${parse.frontmatterField(done, 'verified')}\n`);
209
+ return { exit: 0 };
210
+ }
211
+
212
+ async function verifyMigrated(root, t, binding, write, error) {
213
+ const id = t.id;
214
+ if (t.fields.status === 'done') write.write(`${id} is done — re-running its check and recording the latest outcome\n`);
215
+ const commands = parse.verificationCommands(t.text);
216
+ const secretLine = inlineSecretLine(commands);
217
+ if (secretLine) die(`${t.file}: Verification block line ${secretLine} assigns a secret-like literal; reference it from the environment instead (the block is recorded as display text)`, { exit: EXIT.INVALID, code: 'INPUT_INVALID' });
218
+ write.write(`── ${id} verification ──\n`);
219
+ for (const c of commands) write.write(` $ ${sanitizeText(c).text}\n`);
220
+ const context = { kind: 'ticket', change: binding.change, prd: binding.prd, prd_revision: binding.prd_revision, base: binding.base, ticket: id, ticket_digest: parse.ticketDigest(t.text) };
221
+ const result = await runner.runAttempt({ root, context, commands, timeoutSeconds: t.timeout, command: `verify ${id}` });
222
+ if (result.code) die(`${result.code}: ${result.problem}`, { prefix: 'pincer', exit: result.code === 'STATE_BUSY' ? 3 : EXIT.INVALID, code: result.code });
223
+ const a = result.attempt;
224
+ const logs = `${state.RUNTIME_DIR}/attempts/${a.id}/`;
225
+ if (a.outcome === 'passed') {
226
+ write.write(`✓ ${id} verified — attempt ${a.id} passed (source ${a.source.after.slice(0, 12)}, logs ${logs})\n`);
227
+ return { exit: 0, attempt: a };
228
+ }
229
+ const why = a.outcome === 'failed' ? `FAILED (exit ${a.exit_code ?? a.signal})` : a.outcome === 'timed_out' ? `TIMED OUT after ${a.check.timeout_seconds} s` : a.outcome === 'interrupted' ? 'INTERRUPTED' : `ERROR: ${a.error}`;
230
+ error.write(`✗ ${id} verification ${why} — recorded as attempt ${a.id} (logs ${logs}); any prior passing attempt is superseded. Fix, then re-run verify.\n`);
231
+ return { exit: runner.exitFor(a), attempt: a };
232
+ }
233
+
234
+ const slugWords = file => path.basename(file, '.md').replace(/^T-[0-9]+-/, '').replace(/-/g, ' ');
235
+ const closeHint = (id, file) => `✓ ${id} done. Inspect staged work, stage only this ticket's paths, review git diff --cached, then commit: ${id}: ${slugWords(file)}\n`;
236
+
237
+ async function done(root, input, io = {}) {
238
+ const write = io.write || process.stdout, error = io.error || process.stderr;
239
+ const t = resolve(root, input);
240
+ const assoc = usablePrd(root, t);
241
+ const { mode, binding } = modeFor(root, assoc.prd);
242
+ const id = t.id, st = t.fields.status;
243
+ if (st !== 'in_progress' && st !== 'done') die(`${id} is '${st}' — run 'scripts/pincer-ticket.sh verify ${id}' first`);
244
+ if (mode === 'legacy') return doneLegacy(root, t, write, error);
245
+ return doneMigrated(root, t, binding, write);
246
+ }
247
+
248
+ async function doneLegacy(root, t, write, error) {
249
+ const id = t.id, file = t.file, st = t.fields.status;
250
+ const rec = t.fields.verified || '';
251
+ if (!rec) die(`no verification receipt on ${id} — run 'scripts/pincer-ticket.sh verify ${id}' and get a green check first`);
252
+ const cur = parse.legacyBlockHash(t.text);
253
+ const recHash = rec.split(/\s+/).pop();
254
+ if (recHash !== cur) die(`receipt hash ${recHash} does not match the current Verification block (${cur}): the check changed after it passed — run 'scripts/pincer-ticket.sh verify ${id}' again`);
255
+ const u = parse.unticked(t.text);
256
+ if (u.length) die(`unticked acceptance criteria on ${id}:\n${u.join('\n')}\nTick each verified criterion; a criterion that was cut is a scope change to record in the PRD, not a box to skip.`);
257
+ const result = await verifyLegacy(root, t, write, error);
258
+ if (result.exit !== 0) return result;
259
+ const after = load(root, file, id);
260
+ const u2 = parse.unticked(after.text);
261
+ if (u2.length) die(`unticked acceptance criteria on ${id} after verification:\n${u2.join('\n')}`);
262
+ if (st === 'done') { write.write(`${id} already done — current check passed\n`); return { exit: 0 }; }
263
+ let text = fmSet(after.text, 'status', 'done');
264
+ text = fmSet(text, 'finished', nowIso());
265
+ writeTicket(root, file, text);
266
+ write.write(closeHint(id, file));
267
+ return { exit: 0 };
268
+ }
269
+
270
+ // Migrated closure consumes the latest passing attempt against current inputs
271
+ // and checked criteria; it never launches the check and writes the ticket once.
272
+ async function doneMigrated(root, t, binding, write) {
273
+ const id = t.id, file = t.file, st = t.fields.status;
274
+ const inputs = currentInputs(root, binding);
275
+ const r = migratedReadiness(root, t, binding, inputs);
276
+ if (!r.ready) {
277
+ const first = r.reasons[0];
278
+ const lines = r.reasons.map(x => `${x.code}: ${x.detail}`);
279
+ die(`${id} cannot close — ${lines.join('; ')}\nnext: ${first.next}`, { code: first.code });
280
+ }
281
+ if (st === 'done') { write.write(`${id} already done — current attempt ${r.attempt.id} passed\n`); return { exit: 0 }; }
282
+ let text = fmSet(t.text, 'status', 'done');
283
+ text = fmSet(text, 'finished', nowIso());
284
+ writeTicket(root, file, text);
285
+ write.write(closeHint(id, file));
286
+ return { exit: 0 };
287
+ }
288
+
289
+ module.exports = { Refusal, fmSet, fmUnset, bind, start, verify, done, migratedReadiness, currentInputs };
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+ // PINCER runtime — migration (docs/runtime-contracts.md, "Migration and
3
+ // rollback"). Preview is read-only; apply backs up every authored file it
4
+ // changes, strips legacy receipts into the binding's legacy_receipts (history,
5
+ // never runtime evidence), ignores .pincer/, and writes the binding last.
6
+ // Conflicts fail closed before the first write; repeated apply is a no-op.
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const parse = require('./parse.cjs');
10
+ const identity = require('./identity.cjs');
11
+ const statusModule = require('./status.cjs');
12
+ const { fmUnset } = require('./lifecycle.cjs');
13
+ const { nowIso, atomicWrite } = require('./fsutil.cjs');
14
+
15
+ const IGNORE_LINE = '.pincer/';
16
+
17
+ function gitignoreHas(root) {
18
+ const file = path.join(root, '.gitignore');
19
+ if (!fs.existsSync(file)) return false;
20
+ return fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).some(l => l === IGNORE_LINE || l === '/.pincer/' || l === '.pincer');
21
+ }
22
+
23
+ // Compute the plan. Returns { prd, change, conflicts: [{code, detail}], binding:
24
+ // { file, exists, action }, tickets: [{file, id, verified, last_check}],
25
+ // gitignore: boolean (needs the line), alreadyMigrated }.
26
+ function plan(root, { prd, change, authorization = null } = {}) {
27
+ const conflicts = [];
28
+ const conflict = (code, detail) => conflicts.push({ code, detail });
29
+ const prdResult = parse.validatePrd(root, prd);
30
+ if (!prdResult.ok) { conflict('INPUT_INVALID', `${prdResult.file || prd}: ${prdResult.problems[0]}`); return { prd, conflicts }; }
31
+ const id = change || `prd-v${prd.match(parse.PRD_REF)[1]}`;
32
+ if (!identity.CHANGE_ID.test(id)) conflict('INPUT_INVALID', `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}`);
33
+ if (!identity.head(root)) conflict('UNSUPPORTED_INPUT', 'migration needs a git repository with at least one commit');
34
+ const bindings = identity.listBindings(root);
35
+ let existing = null;
36
+ if (bindings.length > 1) conflict('AMBIGUOUS', `several change bindings under .prd/changes/ (${bindings.map(b => path.basename(b)).join(', ')}); keep exactly one`);
37
+ else if (bindings.length === 1) {
38
+ const loaded = identity.loadBinding(root, { prd });
39
+ if (loaded.code && loaded.code !== 'REVISION_CHANGED' && !loaded.other) conflict(loaded.code === 'UNSUPPORTED_SCHEMA' ? 'UNSUPPORTED_SCHEMA' : 'INPUT_INVALID', loaded.problem);
40
+ else if (loaded.other) conflict('AMBIGUOUS', `${loaded.file} binds ${loaded.binding.prd}, not ${prd}; one change per worktree — register --replace or remove it first`);
41
+ else if (loaded.binding && loaded.binding.change !== id) conflict('AMBIGUOUS', `${loaded.file} already binds ${prd} as change "${loaded.binding.change}"; pass --change ${loaded.binding.change}`);
42
+ else existing = loaded.binding || null;
43
+ }
44
+ const set = parse.validateTicketSet(root);
45
+ if (!set.ok) conflict('INPUT_INVALID', `pincer-ticket: ${set.file ? `${set.file}: ` : ''}${set.problems[0]}`);
46
+ const tickets = [];
47
+ if (set.ok) {
48
+ for (const file of set.files) {
49
+ const text = fs.readFileSync(path.join(root, file), 'utf8');
50
+ const fields = parse.validateTicket(file, text).fields;
51
+ const assoc = statusModule.ticketPrd(root, file, fields);
52
+ if (assoc.problem) { conflict('INPUT_INVALID', `pincer: ${assoc.problem}`); continue; }
53
+ if (assoc.prd !== prd) continue;
54
+ if (fields.verified || fields.last_check) tickets.push({ file, id: fields.ticket, verified: fields.verified || null, last_check: fields.last_check || null, text });
55
+ }
56
+ }
57
+ const gitignore = !gitignoreHas(root);
58
+ const alreadyMigrated = Boolean(existing) && tickets.length === 0 && !gitignore;
59
+ const partial = Boolean(existing) && tickets.length > 0;
60
+ return { prd, change: id, authorization, conflicts, binding: { file: `.prd/changes/${id}.json`, exists: Boolean(existing), existing, action: existing ? (partial ? 'complete' : 'keep') : 'register' }, tickets, gitignore, alreadyMigrated, partial };
61
+ }
62
+
63
+ function renderPlan(p) {
64
+ const lines = [];
65
+ if (p.conflicts.length) {
66
+ for (const c of p.conflicts) lines.push(`conflict ${c.code}: ${c.detail}`);
67
+ lines.push('migration refused: resolve the conflicts above; nothing was written');
68
+ return lines.join('\n') + '\n';
69
+ }
70
+ lines.push(`migration plan for ${p.prd} (change ${p.change})`);
71
+ if (p.alreadyMigrated) { lines.push(' already migrated: binding present, no legacy receipts remain, .pincer/ ignored'); return lines.join('\n') + '\n'; }
72
+ if (p.partial) lines.push(' note an earlier migration was partially applied (binding present, receipts remain); apply completes it');
73
+ lines.push(` binding ${p.binding.file} (${p.binding.exists ? 'present, legacy_receipts extended' : 'new; base = HEAD'})`);
74
+ for (const t of p.tickets) lines.push(` ticket ${t.file}: remove ${[t.verified ? 'verified' : null, t.last_check ? 'last_check' : null].filter(Boolean).join(', ')} → legacy_receipts[${t.id}] (history, not runtime evidence)`);
75
+ if (!p.tickets.length) lines.push(' tickets no legacy receipts to import');
76
+ lines.push(p.gitignore ? ` gitignore add \`${IGNORE_LINE}\`` : ' gitignore already ignores .pincer/');
77
+ lines.push(` backups .pincer/backups/<timestamp>/ for every changed authored file`);
78
+ if (!p.authorization) lines.push(' note no --authorization given; registration does not prove human approval');
79
+ lines.push(`apply with: node scripts/pincer-runtime.cjs migrate --apply --prd ${p.prd}${p.change !== `prd-v${p.prd.match(parse.PRD_REF)[1]}` ? ` --change ${p.change}` : ''}`);
80
+ return lines.join('\n') + '\n';
81
+ }
82
+
83
+ // Apply the plan: backups, tickets, .gitignore, then the binding last.
84
+ function apply(root, options) {
85
+ const p = plan(root, options);
86
+ if (p.conflicts.length) return { plan: p, applied: false };
87
+ if (p.alreadyMigrated) return { plan: p, applied: false, already: true };
88
+ const stamp = nowIso().replace(/[-:]/g, '');
89
+ const backupDir = path.join(root, '.pincer', 'backups', stamp);
90
+ const backups = [];
91
+ const backup = rel => {
92
+ const src = path.join(root, rel);
93
+ if (!fs.existsSync(src)) return;
94
+ const dest = path.join(backupDir, rel);
95
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
96
+ fs.copyFileSync(src, dest);
97
+ backups.push(`.pincer/backups/${stamp}/${rel}`);
98
+ };
99
+ const receipts = {};
100
+ for (const t of p.tickets) {
101
+ backup(t.file);
102
+ receipts[t.id] = { verified: t.verified, last_check: t.last_check };
103
+ let text = fmUnset(t.text, 'verified');
104
+ text = fmUnset(text, 'last_check');
105
+ atomicWrite(path.join(root, t.file), text);
106
+ }
107
+ if (p.gitignore) {
108
+ backup('.gitignore');
109
+ const file = path.join(root, '.gitignore');
110
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
111
+ const lead = existing && !existing.endsWith('\n') ? '\n' : '';
112
+ fs.appendFileSync(file, `${lead}${existing ? '\n' : ''}# pincer runtime state (added by migrate)\n${IGNORE_LINE}\n`);
113
+ }
114
+ let binding;
115
+ if (p.binding.existing) {
116
+ binding = { ...p.binding.existing, legacy_receipts: { ...p.binding.existing.legacy_receipts, ...receipts } };
117
+ if (options.authorization) binding.authorization = options.authorization;
118
+ } else {
119
+ const registered = identity.register(root, { prd: p.prd, change: p.change, authorization: options.authorization || null });
120
+ if (registered.code) return { plan: p, applied: false, error: registered.problem, backups };
121
+ binding = { ...registered.binding, legacy_receipts: receipts };
122
+ }
123
+ identity.writeBinding(root, binding);
124
+ return { plan: p, applied: true, binding, backups, backupDir: backups.length ? `.pincer/backups/${stamp}/` : null };
125
+ }
126
+
127
+ module.exports = { plan, renderPlan, apply, IGNORE_LINE, gitignoreHas };