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.
- package/README.md +9 -7
- package/bin/pincer.js +58 -5
- package/package.json +3 -3
- package/template/.agents/skills/pincer-code/SKILL.md +117 -12
- package/template/.agents/skills/pincer-evaluate/SKILL.md +57 -10
- package/template/.agents/skills/pincer-narrow/SKILL.md +48 -8
- package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
- package/template/.agents/skills/pincer-release/SKILL.md +32 -3
- package/template/.agents/skills/pincer-status/SKILL.md +25 -2
- package/template/.claude/commands/pincer-code.md +117 -12
- package/template/.claude/commands/pincer-evaluate.md +57 -10
- package/template/.claude/commands/pincer-narrow.md +48 -8
- package/template/.claude/commands/pincer-plan.md +12 -4
- package/template/.claude/commands/pincer-release.md +32 -3
- package/template/.claude/commands/pincer-status.md +25 -2
- package/template/.claude/hooks/hook-policy.cjs +24 -3
- package/template/.claude/references/prd-template.md +11 -4
- package/template/.claude/references/ticket-template.md +4 -0
- package/template/.codex/README.md +3 -2
- package/template/.github/prompts/pincer-code.prompt.md +117 -12
- package/template/.github/prompts/pincer-evaluate.prompt.md +57 -10
- package/template/.github/prompts/pincer-narrow.prompt.md +48 -8
- package/template/.github/prompts/pincer-plan.prompt.md +12 -4
- package/template/.github/prompts/pincer-release.prompt.md +32 -3
- package/template/.github/prompts/pincer-status.prompt.md +25 -2
- package/template/AGENTS.md +22 -0
- package/template/docs/dry-run-checklist.md +70 -6
- package/template/docs/release-checklist.md +5 -2
- package/template/docs/runtime-contracts.md +1683 -0
- package/template/scripts/pincer-evidence.cjs +13 -229
- package/template/scripts/pincer-runtime/adopt.cjs +132 -0
- package/template/scripts/pincer-runtime/agreement.cjs +240 -0
- package/template/scripts/pincer-runtime/authorization.cjs +167 -0
- package/template/scripts/pincer-runtime/changes.cjs +517 -0
- package/template/scripts/pincer-runtime/checks.cjs +48 -0
- package/template/scripts/pincer-runtime/coverage.cjs +361 -0
- package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
- package/template/scripts/pincer-runtime/evidence.cjs +676 -0
- package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
- package/template/scripts/pincer-runtime/gates.cjs +73 -0
- package/template/scripts/pincer-runtime/identity.cjs +163 -0
- package/template/scripts/pincer-runtime/impact.cjs +177 -0
- package/template/scripts/pincer-runtime/io.cjs +41 -0
- package/template/scripts/pincer-runtime/lifecycle.cjs +311 -0
- package/template/scripts/pincer-runtime/locator.cjs +158 -0
- package/template/scripts/pincer-runtime/migrate.cjs +204 -0
- package/template/scripts/pincer-runtime/parse.cjs +316 -0
- package/template/scripts/pincer-runtime/phases.cjs +245 -0
- package/template/scripts/pincer-runtime/readiness.cjs +97 -0
- package/template/scripts/pincer-runtime/requirements.cjs +255 -0
- package/template/scripts/pincer-runtime/resume.cjs +205 -0
- package/template/scripts/pincer-runtime/routing.cjs +54 -0
- package/template/scripts/pincer-runtime/runner.cjs +242 -0
- package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
- package/template/scripts/pincer-runtime/source.cjs +129 -0
- package/template/scripts/pincer-runtime/state.cjs +314 -0
- package/template/scripts/pincer-runtime/status.cjs +514 -0
- package/template/scripts/pincer-runtime/transaction.cjs +200 -0
- package/template/scripts/pincer-runtime/transitions.cjs +134 -0
- package/template/scripts/pincer-runtime.cjs +661 -0
- package/template/scripts/pincer-status.sh +11 -162
- package/template/scripts/pincer-ticket.sh +19 -139
- package/template/scripts/pincer-ticket-lib.sh +0 -321
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — transactions (docs/runtime-contracts.md, "Transactions and
|
|
3
|
+
// recovery"). Every write of a change record, agreement snapshot, selection,
|
|
4
|
+
// evaluation locator or migration goes through `run`: the worktree lock is held
|
|
5
|
+
// for the whole validate-and-write operation, the caller's function computes the
|
|
6
|
+
// outcome in memory, the new files are staged under the journal, a manifest is
|
|
7
|
+
// written last as the commit point, and the staged files are renamed onto their
|
|
8
|
+
// targets. A process killed before the manifest leaves the old state; killed
|
|
9
|
+
// after it, the next transaction or `recover` completes the renames. A
|
|
10
|
+
// projection is therefore never observable without its event.
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const crypto = require('node:crypto');
|
|
15
|
+
const state = require('./state.cjs');
|
|
16
|
+
const { nowIso, readJson } = require('./fsutil.cjs');
|
|
17
|
+
|
|
18
|
+
const MANIFEST_SCHEMA = 1;
|
|
19
|
+
const TXN_PREFIX = 'txn-';
|
|
20
|
+
const MAX_TEXT = 2000;
|
|
21
|
+
const SAFE_RELATIVE = /^(?!\/)(?!.*(^|\/)\.\.(\/|$))[^\0]+$/;
|
|
22
|
+
|
|
23
|
+
class Refusal extends Error {
|
|
24
|
+
constructor(code, message, extra = {}) { super(message); this.code = code; this.refusal = true; Object.assign(this, extra); }
|
|
25
|
+
}
|
|
26
|
+
const refuse = (code, message, extra) => { throw new Refusal(code, message, extra); };
|
|
27
|
+
|
|
28
|
+
const journalDir = root => state.paths(root).journal;
|
|
29
|
+
const compact = () => nowIso().replace(/[-:]/g, '');
|
|
30
|
+
const serialize = content => (typeof content === 'string' || Buffer.isBuffer(content) ? content : `${JSON.stringify(content, null, 2)}\n`);
|
|
31
|
+
|
|
32
|
+
// --- Pending transactions (read-only) -------------------------------------------
|
|
33
|
+
// A staging directory with a manifest is a committed transaction that was not
|
|
34
|
+
// fully applied; one without a manifest is uncommitted staging. Never writes.
|
|
35
|
+
function pending(root) {
|
|
36
|
+
const dir = journalDir(root);
|
|
37
|
+
if (!fs.existsSync(dir)) return { committed: [], uncommitted: [] };
|
|
38
|
+
const committed = [], uncommitted = [];
|
|
39
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
40
|
+
if (!name.startsWith(TXN_PREFIX)) continue;
|
|
41
|
+
const staging = path.join(dir, name);
|
|
42
|
+
let stat = null;
|
|
43
|
+
try { stat = fs.statSync(staging); } catch { continue; }
|
|
44
|
+
if (!stat.isDirectory()) continue;
|
|
45
|
+
const read = readJson(path.join(staging, 'manifest.json'));
|
|
46
|
+
if (read.error === 'missing') { uncommitted.push({ id: name, dir: `${state.RUNTIME_DIR}/journal/${name}` }); continue; }
|
|
47
|
+
if (read.error || !validManifest(read.data)) { committed.push({ id: name, dir: `${state.RUNTIME_DIR}/journal/${name}`, manifest: null, problem: read.error || 'malformed manifest' }); continue; }
|
|
48
|
+
committed.push({ id: name, dir: `${state.RUNTIME_DIR}/journal/${name}`, manifest: read.data, command: read.data.command, started: read.data.started });
|
|
49
|
+
}
|
|
50
|
+
return { committed, uncommitted };
|
|
51
|
+
}
|
|
52
|
+
function validManifest(m) {
|
|
53
|
+
return m && typeof m === 'object' && !Array.isArray(m) && m.schema === MANIFEST_SCHEMA && typeof m.id === 'string' && typeof m.command === 'string'
|
|
54
|
+
&& typeof m.started === 'string' && Array.isArray(m.writes)
|
|
55
|
+
&& m.writes.every(w => w && typeof w === 'object' && typeof w.target === 'string' && SAFE_RELATIVE.test(w.target) && typeof w.staged === 'string' && /^[0-9]{2,}-[^/\0]+$/.test(w.staged));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Recovery (caller holds the lock) -------------------------------------------
|
|
59
|
+
// Complete every committed transaction (each rename is idempotent: a staged file
|
|
60
|
+
// that is already gone was renamed before the crash) and discard uncommitted
|
|
61
|
+
// staging. Returns what was done. A manifest that cannot be read is left in place
|
|
62
|
+
// and reported: completing it would mean guessing its targets.
|
|
63
|
+
function recoverPending(root) {
|
|
64
|
+
const report = { completed: [], discarded: [], unreadable: [] };
|
|
65
|
+
const found = pending(root);
|
|
66
|
+
for (const t of found.committed) {
|
|
67
|
+
if (!t.manifest) { report.unreadable.push({ id: t.id, problem: t.problem }); continue; }
|
|
68
|
+
applyManifest(root, path.join(root, t.dir), t.manifest);
|
|
69
|
+
report.completed.push({ id: t.id, command: t.manifest.command, targets: t.manifest.writes.map(w => w.target) });
|
|
70
|
+
}
|
|
71
|
+
for (const t of found.uncommitted) {
|
|
72
|
+
fs.rmSync(path.join(root, t.dir), { recursive: true, force: true });
|
|
73
|
+
report.discarded.push({ id: t.id });
|
|
74
|
+
}
|
|
75
|
+
return report;
|
|
76
|
+
}
|
|
77
|
+
function applyManifest(root, staging, manifest, hooks = null) {
|
|
78
|
+
manifest.writes.forEach((w, i) => {
|
|
79
|
+
const staged = path.join(staging, w.staged);
|
|
80
|
+
const target = path.join(root, w.target);
|
|
81
|
+
if (fs.existsSync(staged)) {
|
|
82
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
83
|
+
fs.renameSync(staged, target);
|
|
84
|
+
}
|
|
85
|
+
if (hooks) hooks(`rename:${i}`);
|
|
86
|
+
});
|
|
87
|
+
fs.rmSync(path.join(staging, 'manifest.json'), { force: true });
|
|
88
|
+
if (hooks) hooks('cleanup');
|
|
89
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Running attempts of a change ---------------------------------------------
|
|
93
|
+
// Attempts listed as running in the index whose context names the change. The
|
|
94
|
+
// owner's liveness is reported so callers can name `recover` as the next step
|
|
95
|
+
// for a dead owner; the transaction never terminates an attempt itself.
|
|
96
|
+
function runningAttempts(root, changeId) {
|
|
97
|
+
if (!state.exists(root)) return [];
|
|
98
|
+
const read = state.readIndex(root);
|
|
99
|
+
if (read.error) refuse('INPUT_INVALID', read.error);
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const id of read.index.running) {
|
|
102
|
+
const attempt = state.readAttempt(root, id).attempt;
|
|
103
|
+
if (!attempt || attempt.outcome !== 'running') continue;
|
|
104
|
+
if (!attempt.context || attempt.context.change !== changeId) continue;
|
|
105
|
+
const owner = attempt.owner || {};
|
|
106
|
+
out.push({ id, context: attempt.context, owner, alive: owner.host === os.hostname() ? state.isAlive(owner.pid) : null });
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
function requireIdle(root, changeId) {
|
|
111
|
+
const running = runningAttempts(root, changeId);
|
|
112
|
+
if (!running.length) return;
|
|
113
|
+
const a = running[0];
|
|
114
|
+
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'})`;
|
|
115
|
+
refuse('ATTEMPT_RUNNING', `attempt ${a.id} of change ${changeId} is running${hint}; the transition is refused and the attempt is not terminated`, { attempt: a.id });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- The transaction --------------------------------------------------------------
|
|
119
|
+
// run(root, { command, waitMs, hooks }, fn): fn receives a context and returns
|
|
120
|
+
// the result to hand back. Inside fn, ctx.read(rel) reads a repository-relative
|
|
121
|
+
// JSON file (null when missing), ctx.text(rel) a text file, ctx.write(rel,
|
|
122
|
+
// content) stages a write, ctx.expect(rel, key, value) refuses STATE_CHANGED when
|
|
123
|
+
// the file's field differs from what the caller prepared against, ctx.idle(id)
|
|
124
|
+
// refuses ATTEMPT_RUNNING for a change with a running attempt, ctx.refuse(code,
|
|
125
|
+
// message) aborts with nothing written. `hooks(point)` is a test seam invoked at
|
|
126
|
+
// 'validated', 'staged', 'manifest', 'rename:<i>' and 'cleanup'.
|
|
127
|
+
function run(root, { command = 'transaction', waitMs, hooks = null, log } = {}, fn) {
|
|
128
|
+
return state.withLock(root, () => {
|
|
129
|
+
// Before anything else: finish what a killed writer committed and drop what
|
|
130
|
+
// it only staged, so every reader inside the lock sees consistent state.
|
|
131
|
+
recoverPending(root);
|
|
132
|
+
const writes = [];
|
|
133
|
+
const targets = new Set();
|
|
134
|
+
const ctx = {
|
|
135
|
+
root,
|
|
136
|
+
read(rel) {
|
|
137
|
+
assertRelative(rel);
|
|
138
|
+
const read = readJson(path.join(root, rel));
|
|
139
|
+
if (read.error === 'missing') return null;
|
|
140
|
+
if (read.error) refuse('MALFORMED', `${rel}: ${read.error}`);
|
|
141
|
+
return read.data;
|
|
142
|
+
},
|
|
143
|
+
text(rel) {
|
|
144
|
+
assertRelative(rel);
|
|
145
|
+
try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch (error) { return error.code === 'ENOENT' ? null : refuse('INPUT_INVALID', `${rel}: ${error.message}`); }
|
|
146
|
+
},
|
|
147
|
+
exists(rel) { assertRelative(rel); return fs.existsSync(path.join(root, rel)); },
|
|
148
|
+
expect(rel, key, value) {
|
|
149
|
+
const doc = ctx.read(rel);
|
|
150
|
+
const actual = doc ? doc[key] : null;
|
|
151
|
+
if (actual !== value) refuse('STATE_CHANGED', `${rel}: ${key} is ${JSON.stringify(actual)}, expected ${JSON.stringify(value)}; the record changed since the operation was prepared — inspect it and repeat the command against the current state`);
|
|
152
|
+
return doc;
|
|
153
|
+
},
|
|
154
|
+
idle(changeId) { requireIdle(root, changeId); },
|
|
155
|
+
running(changeId) { return runningAttempts(root, changeId); },
|
|
156
|
+
refuse,
|
|
157
|
+
write(rel, content) {
|
|
158
|
+
assertRelative(rel);
|
|
159
|
+
if (rel.startsWith(`${state.RUNTIME_DIR}/journal/`) || rel.startsWith(`${state.RUNTIME_DIR}/lock`)) refuse('INPUT_INVALID', `${rel}: the journal and the lock are not transaction targets`);
|
|
160
|
+
if (targets.has(rel)) refuse('INPUT_INVALID', `${rel}: written twice in one transaction`);
|
|
161
|
+
targets.add(rel);
|
|
162
|
+
writes.push({ target: rel, content: serialize(content) });
|
|
163
|
+
},
|
|
164
|
+
now: nowIso(),
|
|
165
|
+
};
|
|
166
|
+
const result = fn(ctx);
|
|
167
|
+
if (hooks) hooks('validated');
|
|
168
|
+
if (!writes.length) return { result, writes: [], id: null };
|
|
169
|
+
const id = `${TXN_PREFIX}${compact()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
170
|
+
const staging = path.join(journalDir(root), id);
|
|
171
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
172
|
+
const manifest = { schema: MANIFEST_SCHEMA, id, command, started: ctx.now, writes: [] };
|
|
173
|
+
writes.forEach((w, i) => {
|
|
174
|
+
const staged = `${String(i + 1).padStart(2, '0')}-${path.basename(w.target)}`;
|
|
175
|
+
fs.writeFileSync(path.join(staging, staged), w.content);
|
|
176
|
+
manifest.writes.push({ target: w.target, staged });
|
|
177
|
+
});
|
|
178
|
+
if (hooks) hooks('staged');
|
|
179
|
+
// The manifest is the commit point: written to a temporary name and renamed.
|
|
180
|
+
const temp = path.join(staging, `.manifest.${process.pid}.tmp`);
|
|
181
|
+
fs.writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
182
|
+
fs.renameSync(temp, path.join(staging, 'manifest.json'));
|
|
183
|
+
if (hooks) hooks('manifest');
|
|
184
|
+
applyManifest(root, staging, manifest, hooks);
|
|
185
|
+
return { result, writes: manifest.writes.map(w => w.target), id };
|
|
186
|
+
}, { command, waitMs, log });
|
|
187
|
+
}
|
|
188
|
+
function assertRelative(rel) {
|
|
189
|
+
if (typeof rel !== 'string' || !SAFE_RELATIVE.test(rel) || path.isAbsolute(rel)) refuse('INPUT_INVALID', `${rel}: paths must be repository-relative without ".."`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Bounded text arguments stored verbatim in records (reasons, notes, references…).
|
|
193
|
+
function boundedText(value, name, { required = true } = {}) {
|
|
194
|
+
if (value === undefined || value === null) { if (required) refuse('INPUT_INVALID', `--${name} is required`); return null; }
|
|
195
|
+
if (typeof value !== 'string' || (required && !value.trim())) refuse('INPUT_INVALID', `--${name} must be a nonempty string`);
|
|
196
|
+
if (value.length > MAX_TEXT) refuse('INPUT_INVALID', `--${name} is longer than ${MAX_TEXT} characters; store a reference, not a transcript`);
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { Refusal, refuse, run, pending, recoverPending, runningAttempts, requireIdle, boundedText, MANIFEST_SCHEMA, MAX_TEXT, TXN_PREFIX };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — change lifecycle transitions (docs/runtime-contracts.md,
|
|
3
|
+
// "Lifecycle"). planned → active → paused/completed → …, cancelled and
|
|
4
|
+
// superseded as terminal states, each transition one transaction that checks the
|
|
5
|
+
// whole precondition set under the lock, appends exactly one event and rewrites
|
|
6
|
+
// the projection; an invalid transition writes nothing; requesting the state a
|
|
7
|
+
// record already has writes no event. Running attempts are never terminated
|
|
8
|
+
// here: pause, complete, cancel and supersede refuse while one runs.
|
|
9
|
+
const transaction = require('./transaction.cjs');
|
|
10
|
+
const agreement = require('./agreement.cjs');
|
|
11
|
+
const { inlineSecretLine } = require('./sanitize.cjs');
|
|
12
|
+
|
|
13
|
+
const OPS = ['activate', 'pause', 'resume', 'complete', 'reopen', 'cancel', 'supersede'];
|
|
14
|
+
const NEEDS_SELECTION = ['activate', 'pause', 'resume', 'complete', 'reopen'];
|
|
15
|
+
const NEEDS_IDLE = ['pause', 'complete', 'cancel', 'supersede'];
|
|
16
|
+
const ACTIVATION = ['activate', 'resume', 'reopen'];
|
|
17
|
+
const DECISION_ID = /^D-[0-9]{2,6}$/;
|
|
18
|
+
|
|
19
|
+
function text(value, name, options) {
|
|
20
|
+
const v = transaction.boundedText(value, name, options);
|
|
21
|
+
if (v !== null && inlineSecretLine([v])) transaction.refuse('INPUT_INVALID', `--${name} assigns a secret-like literal; reference secrets by name, never by value`);
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
24
|
+
// The operations the table permits from a state (for refusal messages).
|
|
25
|
+
function permitted(changes, state) {
|
|
26
|
+
return OPS.filter(op => changes.LIFECYCLE_KINDS[op][0].includes(state));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// transition(root, id, op, opts): opts = { reason, note, decision, with, expect, hooks }.
|
|
30
|
+
// Returns { action: 'transitioned' | 'unchanged', record, from, to, event } or { code, problem }.
|
|
31
|
+
function transition(root, id, op, opts = {}) {
|
|
32
|
+
const changes = require('./changes.cjs');
|
|
33
|
+
const authorization = require('./authorization.cjs');
|
|
34
|
+
const statusModule = require('./status.cjs');
|
|
35
|
+
if (!OPS.includes(op)) return { code: 'INPUT_INVALID', problem: `unknown lifecycle operation ${op}` };
|
|
36
|
+
const [froms, to] = changes.LIFECYCLE_KINDS[op];
|
|
37
|
+
try {
|
|
38
|
+
const out = transaction.run(root, { command: `change ${op} ${id}`, hooks: opts.hooks || null }, ctx => {
|
|
39
|
+
const resolved = changes.resolveSelected(root, { change: id });
|
|
40
|
+
if (resolved.code) ctx.refuse(resolved.code, resolved.problem);
|
|
41
|
+
if (opts.expect !== undefined && opts.expect !== null) ctx.expect(resolved.file, 'sequence', opts.expect);
|
|
42
|
+
const record = resolved.record;
|
|
43
|
+
const from = record.lifecycle.state;
|
|
44
|
+
// Idempotence: the requested state is already the state (same replacement for supersede).
|
|
45
|
+
if (from === to && (op !== 'supersede' || record.lifecycle.superseded_by === opts.with)) return { action: 'unchanged', record, from, to };
|
|
46
|
+
if (changes.TERMINAL.includes(from)) ctx.refuse('LIFECYCLE_BLOCKED', `change ${id} is ${from}${record.lifecycle.superseded_by ? ` by ${record.lifecycle.superseded_by}` : ''}; its history is inspectable (change show ${id}) but it cannot be ${op}d — register a new change and reference this record`);
|
|
47
|
+
if (!froms.includes(from)) ctx.refuse('LIFECYCLE_BLOCKED', `change ${id} is ${from}; ${op} applies to ${froms.join(' or ')} changes — permitted now: ${permitted(changes, from).map(o => `change ${o}`).join(', ')}`);
|
|
48
|
+
if (NEEDS_SELECTION.includes(op)) {
|
|
49
|
+
if (!resolved.selection) ctx.refuse('SELECTION_REQUIRED', `${op} needs the change selected in this worktree: node scripts/pincer-runtime.cjs change select ${id}`);
|
|
50
|
+
if (resolved.selection.change !== id) ctx.refuse('WRONG_CHANGE', `the selected change is ${resolved.selection.change}, not ${id}; select it first: node scripts/pincer-runtime.cjs change select ${id}`);
|
|
51
|
+
}
|
|
52
|
+
const reason = ['pause', 'reopen', 'cancel'].includes(op) ? text(opts.reason, 'reason') : null;
|
|
53
|
+
const note = op === 'pause' ? text(opts.note, 'note', { required: false }) : null;
|
|
54
|
+
let decision = null;
|
|
55
|
+
if (['cancel', 'supersede'].includes(op)) {
|
|
56
|
+
if (typeof opts.decision !== 'string' || !DECISION_ID.test(opts.decision)) ctx.refuse('INPUT_INVALID', `${op} requires --decision D-NN, the user's recorded decision (change decide ${id} --summary … then --resolve)`);
|
|
57
|
+
const d = record.decisions.find(x => x.id === opts.decision);
|
|
58
|
+
if (!d) ctx.refuse('INPUT_INVALID', `--decision ${opts.decision} is not a decision of change ${id} (recorded: ${record.decisions.map(x => x.id).join(', ') || 'none'})`);
|
|
59
|
+
if (d.status !== 'resolved') ctx.refuse('DECISION_REQUIRED', `decision ${d.id} is still open; record the user's decision first: node scripts/pincer-runtime.cjs change decide ${id} --resolve ${d.id} --reference <text> --excerpt <text>`);
|
|
60
|
+
decision = d.id;
|
|
61
|
+
}
|
|
62
|
+
let replacement = null;
|
|
63
|
+
if (op === 'supersede') {
|
|
64
|
+
const w = opts.with;
|
|
65
|
+
if (typeof w !== 'string' || !changes.CHANGE_ID.test(w)) ctx.refuse('INPUT_INVALID', 'supersede requires --with <replacement change id>');
|
|
66
|
+
if (w === id) ctx.refuse('LIFECYCLE_BLOCKED', `change ${id} cannot supersede itself`);
|
|
67
|
+
if (!resolved.loaded.records.has(w)) ctx.refuse('INPUT_INVALID', `replacement change "${w}" is not a retained record (retained: ${[...resolved.loaded.records.keys()].join(', ')}); register it first`);
|
|
68
|
+
// The replacement must not be (transitively) superseded by this record.
|
|
69
|
+
let cursor = resolved.loaded.records.get(w).record.lifecycle.superseded_by;
|
|
70
|
+
const seen = new Set([w]);
|
|
71
|
+
while (cursor) {
|
|
72
|
+
if (cursor === id) ctx.refuse('LIFECYCLE_BLOCKED', `change "${w}" is already superseded by ${[...seen].join(' → ')} → ${id}; superseding ${id} with it would form a cycle`);
|
|
73
|
+
if (seen.has(cursor)) break;
|
|
74
|
+
seen.add(cursor);
|
|
75
|
+
const next = resolved.loaded.records.get(cursor);
|
|
76
|
+
cursor = next ? next.record.lifecycle.superseded_by : null;
|
|
77
|
+
}
|
|
78
|
+
replacement = w;
|
|
79
|
+
}
|
|
80
|
+
if (NEEDS_IDLE.includes(op)) ctx.idle(id);
|
|
81
|
+
let authorized = null, agreementId = null, verdict = null;
|
|
82
|
+
if (ACTIVATION.includes(op) || op === 'complete') {
|
|
83
|
+
const v = changes.view(root, record);
|
|
84
|
+
if (v.problems.length) ctx.refuse(v.problems[0].code, v.problems[0].detail);
|
|
85
|
+
const computed = agreement.compute(root, record);
|
|
86
|
+
if (computed.code) ctx.refuse(computed.code, computed.problem);
|
|
87
|
+
verdict = authorization.verdict(root, record, computed);
|
|
88
|
+
if (verdict.verdict !== 'current') ctx.refuse(verdict.verdict, verdict.detail);
|
|
89
|
+
authorized = verdict.authorized.id; agreementId = verdict.authorized.agreement;
|
|
90
|
+
if (ACTIVATION.includes(op)) {
|
|
91
|
+
for (const [otherId, e] of resolved.loaded.records) {
|
|
92
|
+
if (otherId !== id && e.record.lifecycle.state === 'active') ctx.refuse('LIFECYCLE_BLOCKED', `change ${otherId} is active in this tree; pause or complete it before activating ${id} (at most one active change)`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (op === 'complete') {
|
|
97
|
+
const st = statusModule.render(root, { change: id });
|
|
98
|
+
const g = st.gathered;
|
|
99
|
+
if (!g || !g.tickets) ctx.refuse('INPUT_INVALID', st.text.trim().split('\n').find(l => l.startsWith('WARN')) || 'the change cannot be inspected');
|
|
100
|
+
if (g.unresolved > 0) ctx.refuse('INPUT_INVALID', 'a ticket has an unresolved PRD association; repair it before completing');
|
|
101
|
+
if (!g.tickets.length) ctx.refuse('LIFECYCLE_BLOCKED', `change ${id} has no tickets; a change completes only with a verified breakdown`);
|
|
102
|
+
// Strict coverage (docs/runtime-contracts.md, "Phase-specific coverage"): structural
|
|
103
|
+
// completeness — every scenario linked or authorized as not delivered, nothing
|
|
104
|
+
// missing from the baseline — precedes the ticket readiness gate below; a
|
|
105
|
+
// candidate is never demanded here.
|
|
106
|
+
if (changes.isStrict(record)) {
|
|
107
|
+
const report = require('./phases.cjs').compute(root, record, { gathered: g, verdict });
|
|
108
|
+
const blocker = require('./phases.cjs').firstBlocker(report, 'structure');
|
|
109
|
+
if (blocker) ctx.refuse(blocker.code, `${blocker.detail} — complete needs structural coverage: every scenario linked or dispositioned, every ticket classified, every disposition authorized`);
|
|
110
|
+
}
|
|
111
|
+
for (const t of g.tickets) {
|
|
112
|
+
const tid = t.fields.ticket;
|
|
113
|
+
if (t.fields.status !== 'done') ctx.refuse('LIFECYCLE_BLOCKED', `${tid} is ${t.fields.status}, not done; finish every ticket before completing ${id}`);
|
|
114
|
+
const r = g.computeReadiness(t);
|
|
115
|
+
if (!r.ready) ctx.refuse(r.reasons[0].code, `${tid}: ${r.reasons[0].detail} — ${r.reasons[0].next}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const sequence = record.sequence + 1;
|
|
119
|
+
const event = { sequence, kind: op, from, to, at: ctx.now, reason, agreement: agreementId, authorization: authorized, decision, replacement, note };
|
|
120
|
+
record.events.push(event);
|
|
121
|
+
record.sequence = sequence;
|
|
122
|
+
record.lifecycle = { state: to, since: ctx.now, reason, note, superseded_by: replacement };
|
|
123
|
+
ctx.write(resolved.file, record);
|
|
124
|
+
return { action: 'transitioned', record, from, to, event };
|
|
125
|
+
});
|
|
126
|
+
return out.result;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error.refusal) return { code: error.code, problem: error.message };
|
|
129
|
+
if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { OPS, transition, permitted };
|