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,661 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// PINCER runtime — the local command entry point (docs/runtime-contracts.md).
|
|
4
|
+
//
|
|
5
|
+
// node scripts/pincer-runtime.cjs validate <file>... [--digests]
|
|
6
|
+
// node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md [--change <id>] [--authorization <text>] [--replace] [--rebind]
|
|
7
|
+
// node scripts/pincer-runtime.cjs snapshot [--json] [--store]
|
|
8
|
+
// node scripts/pincer-runtime.cjs recover
|
|
9
|
+
// node scripts/pincer-runtime.cjs status [--json] [--change <id>]
|
|
10
|
+
// node scripts/pincer-runtime.cjs ready [T-NN]
|
|
11
|
+
// node scripts/pincer-runtime.cjs start|verify|done T-NN · bind T-NN .prd/prd-vN.md
|
|
12
|
+
// node scripts/pincer-runtime.cjs migrate --preview|--apply --prd .prd/prd-vN.md [--change <id>] [--authorization <text>]
|
|
13
|
+
// node scripts/pincer-runtime.cjs check C-NN --candidate <sha> [--timeout <seconds>] -- <command...>
|
|
14
|
+
// node scripts/pincer-runtime.cjs evidence export --candidate <sha> --base <sha> --prd .prd/prd-vN.md --draft <file>
|
|
15
|
+
// node scripts/pincer-runtime.cjs change list [--json] · change show <id> [--json] · change select <id> · change revise <id>
|
|
16
|
+
// node scripts/pincer-runtime.cjs change authorize <id> --agreement <digest> (--reference <text> --excerpt <text> | --delegated --basis A-NN --explanation <text>) [--decision D-NN]...
|
|
17
|
+
// node scripts/pincer-runtime.cjs change decide <id> --summary <text> [--id D-NN] | --resolve D-NN --reference <text> --excerpt <text>
|
|
18
|
+
// node scripts/pincer-runtime.cjs change activate|pause|resume|complete|reopen|cancel|supersede <id> [--reason <text>] [--note <text>] [--decision D-NN] [--with <id>]
|
|
19
|
+
// node scripts/pincer-runtime.cjs resume [--change <id>] [--json]
|
|
20
|
+
// node scripts/pincer-runtime.cjs coverage [--change <id>] [--json] · coverage adopt --preview|--apply --change <id> [--agreement <digest>]
|
|
21
|
+
// node scripts/pincer-runtime.cjs impact [--change <id>] [--from G-NN|A-NN] [--json]
|
|
22
|
+
//
|
|
23
|
+
// Exit codes: 0 ok · 1 failed/not ready/refused · 2 usage · 3 state busy ·
|
|
24
|
+
// 4 invalid input or state · 124 timed out · 130 interrupted.
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { execFileSync } = require('node:child_process');
|
|
28
|
+
|
|
29
|
+
const io = require('./pincer-runtime/io.cjs');
|
|
30
|
+
const parse = require('./pincer-runtime/parse.cjs');
|
|
31
|
+
const identity = require('./pincer-runtime/identity.cjs');
|
|
32
|
+
const source = require('./pincer-runtime/source.cjs');
|
|
33
|
+
const state = require('./pincer-runtime/state.cjs');
|
|
34
|
+
const status = require('./pincer-runtime/status.cjs');
|
|
35
|
+
const runner = require('./pincer-runtime/runner.cjs');
|
|
36
|
+
const sanitize = require('./pincer-runtime/sanitize.cjs');
|
|
37
|
+
const lifecycle = require('./pincer-runtime/lifecycle.cjs');
|
|
38
|
+
const migrate = require('./pincer-runtime/migrate.cjs');
|
|
39
|
+
const evidence = require('./pincer-runtime/evidence.cjs');
|
|
40
|
+
const changes = require('./pincer-runtime/changes.cjs');
|
|
41
|
+
const agreement = require('./pincer-runtime/agreement.cjs');
|
|
42
|
+
const authorization = require('./pincer-runtime/authorization.cjs');
|
|
43
|
+
const transitions = require('./pincer-runtime/transitions.cjs');
|
|
44
|
+
const gates = require('./pincer-runtime/gates.cjs');
|
|
45
|
+
const locator = require('./pincer-runtime/locator.cjs');
|
|
46
|
+
const resume = require('./pincer-runtime/resume.cjs');
|
|
47
|
+
const adopt = require('./pincer-runtime/adopt.cjs');
|
|
48
|
+
const impact = require('./pincer-runtime/impact.cjs');
|
|
49
|
+
const checks = require('./pincer-runtime/checks.cjs');
|
|
50
|
+
const { atomicWrite, nowIso, tryGit } = require('./pincer-runtime/fsutil.cjs');
|
|
51
|
+
const EXIT = { OK: 0, FAILED: 1, USAGE: 2, BUSY: 3, INVALID: 4, TIMED_OUT: 124, INTERRUPTED: 130 };
|
|
52
|
+
|
|
53
|
+
function repoRoot() {
|
|
54
|
+
if (process.env.CLAUDE_PROJECT_DIR) return path.resolve(process.env.CLAUDE_PROJECT_DIR);
|
|
55
|
+
try {
|
|
56
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
57
|
+
} catch {
|
|
58
|
+
return process.cwd();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function usage(message) {
|
|
63
|
+
if (message) io.err(`pincer: ${message}\n`);
|
|
64
|
+
io.err('usage: pincer-runtime.cjs validate <file>... [--digests]\n' +
|
|
65
|
+
' pincer-runtime.cjs register --prd .prd/prd-vN.md [--change <id>] [--authorization <text>] [--replace] [--rebind]\n' +
|
|
66
|
+
' pincer-runtime.cjs snapshot [--json] [--store]\n' +
|
|
67
|
+
' pincer-runtime.cjs recover\n' +
|
|
68
|
+
' pincer-runtime.cjs status [--json] [--change <id>]\n' +
|
|
69
|
+
' pincer-runtime.cjs ready [T-NN]\n' +
|
|
70
|
+
' pincer-runtime.cjs start|verify|done T-NN\n' +
|
|
71
|
+
' pincer-runtime.cjs bind T-NN .prd/prd-vN.md\n' +
|
|
72
|
+
' pincer-runtime.cjs migrate --preview|--apply --prd .prd/prd-vN.md [--change <id>] [--authorization <text>]\n' +
|
|
73
|
+
' pincer-runtime.cjs check C-NN --candidate <sha> [--timeout <seconds>] -- <command...>\n' +
|
|
74
|
+
' pincer-runtime.cjs evidence export --candidate <sha> --base <sha> --prd .prd/prd-vN.md --draft <file>\n' +
|
|
75
|
+
' pincer-runtime.cjs change list [--json] · change show <id> [--json] · change select <id> · change revise <id>\n' +
|
|
76
|
+
' pincer-runtime.cjs change authorize <id> --agreement <digest> (--reference <text> --excerpt <text> [--constraints <text>] | --delegated --basis A-NN --explanation <text>) [--decision D-NN]...\n' +
|
|
77
|
+
' pincer-runtime.cjs change decide <id> --summary <text> [--id D-NN] | --resolve D-NN --reference <text> --excerpt <text>\n' +
|
|
78
|
+
' pincer-runtime.cjs change activate|resume|complete <id> · change pause <id> --reason <text> [--note <text>] · change reopen <id> --reason <text>\n' +
|
|
79
|
+
' pincer-runtime.cjs change cancel <id> --decision D-NN --reason <text> · change supersede <id> --with <id> --decision D-NN\n' +
|
|
80
|
+
' pincer-runtime.cjs resume [--change <id>] [--json] (the read-only report; `change resume` is the lifecycle operation)\n' +
|
|
81
|
+
' pincer-runtime.cjs coverage [--change <id>] [--json] · coverage adopt --preview|--apply --change <id> [--agreement <digest>]\n' +
|
|
82
|
+
' pincer-runtime.cjs impact [--change <id>] [--from G-NN|A-NN] [--json]\n');
|
|
83
|
+
process.exit(EXIT.USAGE);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The change context of a candidate command: the v0.5.0 binding in migrated
|
|
87
|
+
// mode, or the selected change's guarded context in changes mode (selected,
|
|
88
|
+
// owning the PRD, completed, compatible view, authorization current).
|
|
89
|
+
function candidateBinding(root, command, prd) {
|
|
90
|
+
const bind = identity.loadBinding(root, prd ? { prd } : {});
|
|
91
|
+
if (bind.code === 'CHANGES_MODE') {
|
|
92
|
+
try { return gates.guard(root, { command, prd }).binding; } catch (error) {
|
|
93
|
+
if (error && error.refusal) fail('pincer', `${error.code}: ${error.message}`, exitForCode(error.code));
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (bind.code) fail('pincer', `${bind.code}: ${bind.problem}`, EXIT.INVALID);
|
|
98
|
+
return bind.binding;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The clean-view precondition for candidate checks and exports: HEAD is the
|
|
102
|
+
// candidate and nothing is dirty outside NOTES.md and the candidate's evidence
|
|
103
|
+
// directory. Never stashes, resets or commits.
|
|
104
|
+
function requireCandidateView(root, candidate, prd) {
|
|
105
|
+
const head = identity.head(root);
|
|
106
|
+
if (!head) fail('pincer', 'not a git repository with commits', EXIT.INVALID);
|
|
107
|
+
const version = prd.match(parse.PRD_REF)[1];
|
|
108
|
+
// Only this PRD's evidence directory for the candidate (being assembled by
|
|
109
|
+
// this evaluation) and the candidate's validated followers (NOTES.md, valid
|
|
110
|
+
// locators, listed artifacts of validated manifests for the same candidate —
|
|
111
|
+
// two changes may share a candidate) may differ. Never another whole directory.
|
|
112
|
+
const followers = locator.followers(root, candidate);
|
|
113
|
+
const allowed = p => p.startsWith(`.prd/evidence/prd-v${version}/${candidate}/`) || followers.has(p);
|
|
114
|
+
if (head !== candidate) {
|
|
115
|
+
// A descendant that only adds NOTES.md and the candidate's evidence (the
|
|
116
|
+
// evaluate commit) is still a clean view of the candidate's source.
|
|
117
|
+
const ancestor = tryGit(root, ['merge-base', '--is-ancestor', candidate, 'HEAD']);
|
|
118
|
+
const diff = ancestor.error ? { error: 'not an ancestor' } : tryGit(root, ['diff', '--name-only', candidate, 'HEAD']);
|
|
119
|
+
const differing = diff.error ? null : diff.out.split('\n').filter(Boolean).filter(p => !allowed(p));
|
|
120
|
+
if (!differing || differing.length) {
|
|
121
|
+
fail('pincer', `HEAD is ${head.slice(0, 7)}, not the candidate ${candidate.slice(0, 7)}${differing ? ` and differs from it in: ${differing.slice(0, 5).join(', ')}` : ' (the candidate is not an ancestor of HEAD)'}; check out the committed candidate first (no stash, reset or commit is made for you)`, EXIT.FAILED);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const dirty = tryGit(root, ['status', '--porcelain', '--untracked-files=all']);
|
|
125
|
+
if (dirty.error) fail('pincer', `git status failed: ${dirty.error}`, EXIT.INVALID);
|
|
126
|
+
const offending = dirty.out.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^"(.*)"$/, '$1')).filter(p => !allowed(p));
|
|
127
|
+
if (offending.length) fail('pincer', `the working tree is not a clean view of the candidate: ${offending.slice(0, 5).join(', ')}${offending.length > 5 ? ` (+${offending.length - 5})` : ''}; only NOTES.md and .prd/evidence/prd-v${version}/${candidate}/ may differ`, EXIT.FAILED);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function cmdCheck(root, args) {
|
|
131
|
+
const o = parseOptions(args, { valued: ['--candidate', '--timeout'] });
|
|
132
|
+
const [checkId, ...command] = o.positional;
|
|
133
|
+
if (!checkId || !evidence.CHECK_ID.test(checkId)) usage('check requires a check ID such as C-01');
|
|
134
|
+
if (!o.candidate || !parse.HEX40.test(o.candidate)) usage('check requires --candidate <full 40-hex commit ID>');
|
|
135
|
+
const b = candidateBinding(root, 'check', null);
|
|
136
|
+
// Strict coverage (docs/runtime-contracts.md, "Declared candidate checks"): the map's
|
|
137
|
+
// declaration is the only source of the command, timeout and cwd; a supplied
|
|
138
|
+
// command or timeout cannot become evidence for a declared check by reusing its ID.
|
|
139
|
+
let commands, timeout, cwd = null, declared = null;
|
|
140
|
+
if (b.strict) {
|
|
141
|
+
if (o.timeout !== undefined || command.length) fail('pincer', `CHECK_UNDECLARED: ${checkId} runs its declaration in a strict change; --timeout and a command after -- are not accepted (edit .prd/coverage/${b.change}.json and authorize the agreement instead)`, EXIT.INVALID);
|
|
142
|
+
declared = checks.declaration(root, b, checkId);
|
|
143
|
+
if (declared.code) fail('pincer', `${declared.code}: ${declared.problem}`, exitForCode(declared.code));
|
|
144
|
+
commands = declared.commands; timeout = declared.timeout; cwd = declared.cwd;
|
|
145
|
+
} else {
|
|
146
|
+
if (!command.length) usage('check requires the command after --');
|
|
147
|
+
timeout = o.timeout === undefined ? parse.DEFAULT_TIMEOUT : Number(o.timeout);
|
|
148
|
+
if (!Number.isInteger(timeout) || timeout <= 0) usage('--timeout must be a positive integer number of seconds');
|
|
149
|
+
commands = [command.join(' ')];
|
|
150
|
+
}
|
|
151
|
+
requireCandidateView(root, o.candidate, b.prd);
|
|
152
|
+
const line = commands.join('\n');
|
|
153
|
+
const secretLine = sanitize.inlineSecretLine(commands);
|
|
154
|
+
if (secretLine) fail('pincer', 'the check command assigns a secret-like literal; reference it from the environment instead', EXIT.INVALID);
|
|
155
|
+
const announce = () => io.out(`── ${checkId} candidate ${o.candidate.slice(0, 7)}${declared ? ` (declared in ${declared.file}${cwd ? `, cwd ${cwd}` : ''})` : ''} ──\n${commands.map(c => ` $ ${sanitize.sanitizeText(c).text}`).join('\n')}\n`);
|
|
156
|
+
const contextFor = c => ({ kind: 'candidate', change: c.change, prd: c.prd, prd_revision: c.prd_revision, base: c.base, candidate: o.candidate, check: checkId, ...(c.mode === 'changes' ? { mode: 'changes', agreement: c.agreement } : {}), ...(c.strict ? { inventory: c.inventory, coverage: c.coverage } : {}) });
|
|
157
|
+
// In changes mode the guard runs again under the runner's lock (docs/runtime-contracts.md,
|
|
158
|
+
// "Command gates"); a lifecycle or agreement change committed since the pre-launch
|
|
159
|
+
// evaluation refuses the attempt before anything is recorded.
|
|
160
|
+
// In a strict change the declaration is recomputed under the lock too: a changed
|
|
161
|
+
// declaration whose agreement was re-authorized meanwhile refuses (CHECK_UNDECLARED).
|
|
162
|
+
const revalidate = b.mode === 'changes' ? () => {
|
|
163
|
+
const g = gates.guard(root, { command: 'check', prd: b.prd });
|
|
164
|
+
if (g.binding.strict) {
|
|
165
|
+
const again = checks.declaration(root, g.binding, checkId);
|
|
166
|
+
if (again.code) require('./pincer-runtime/transaction.cjs').refuse(again.code, again.problem);
|
|
167
|
+
if (!declared || again.digest !== declared.digest) require('./pincer-runtime/transaction.cjs').refuse('CHECK_UNDECLARED', `the declaration of ${checkId} changed since the command was prepared (${declared ? declared.digest.slice(0, 12) : 'none'} → ${again.digest.slice(0, 12)}); run the check again`);
|
|
168
|
+
} else if (declared) require('./pincer-runtime/transaction.cjs').refuse('CHECK_UNDECLARED', `change ${b.change} is no longer strict; run the check again`);
|
|
169
|
+
return contextFor(g.binding);
|
|
170
|
+
} : null;
|
|
171
|
+
const result = await runner.runAttempt({ root, context: contextFor(b), commands, timeoutSeconds: timeout, command: `check ${checkId}`, revalidate, announce, cwd });
|
|
172
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, result.refused ? exitForCode(result.code) : problemExit(result.code));
|
|
173
|
+
const a = result.attempt;
|
|
174
|
+
const logs = `${state.RUNTIME_DIR}/attempts/${a.id}/`;
|
|
175
|
+
if (a.outcome === 'passed') io.out(`✓ ${checkId} passed — attempt ${a.id} (source ${a.source.after.slice(0, 12)}, logs ${logs})\n`);
|
|
176
|
+
else io.err(`✗ ${checkId} ${a.outcome}${a.exit_code !== null ? ` (exit ${a.exit_code})` : ''}${a.error ? `: ${a.error}` : ''} — attempt ${a.id} (logs ${logs})\n`);
|
|
177
|
+
process.exit(runner.exitFor(a));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function cmdEvidence(root, args) {
|
|
181
|
+
const [sub, ...rest] = args;
|
|
182
|
+
if (sub !== 'export') usage('evidence supports: export');
|
|
183
|
+
const o = parseOptions(rest, { valued: ['--candidate', '--base', '--prd', '--draft'] });
|
|
184
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
185
|
+
for (const key of ['candidate', 'base']) if (!o[key] || !parse.HEX40.test(o[key])) usage(`evidence export requires --${key} <full 40-hex commit ID>`);
|
|
186
|
+
if (!o.prd || !parse.PRD_REF.test(o.prd)) usage('evidence export requires --prd .prd/prd-vN.md');
|
|
187
|
+
if (!o.draft) usage('evidence export requires --draft <file>');
|
|
188
|
+
const b = candidateBinding(root, 'export', o.prd);
|
|
189
|
+
const bind = { binding: b };
|
|
190
|
+
requireCandidateView(root, o.candidate, o.prd);
|
|
191
|
+
let draft;
|
|
192
|
+
try { draft = JSON.parse(fs.readFileSync(path.resolve(root, o.draft), 'utf8')); } catch (error) { fail('pincer', `cannot read draft ${o.draft}: ${error.message}`, EXIT.INVALID); }
|
|
193
|
+
const indexRead = state.exists(root) ? state.readIndex(root) : { index: null };
|
|
194
|
+
if (indexRead.error) fail('pincer', indexRead.error, EXIT.INVALID);
|
|
195
|
+
const attemptsFor = checkId => {
|
|
196
|
+
if (!indexRead.index) return { attempt: null, pointed: null };
|
|
197
|
+
const key = state.contextKey({ kind: 'candidate', change: b.change, candidate: o.candidate, check: checkId, mode: b.mode });
|
|
198
|
+
return { attempt: state.latestAttempt(root, key, indexRead.index), pointed: indexRead.index.current[key] || null };
|
|
199
|
+
};
|
|
200
|
+
const os = require('node:os');
|
|
201
|
+
// Strict coverage: export needs structural coverage and derives every row from the
|
|
202
|
+
// map, the inventory and the outcomes (docs/runtime-contracts.md, "Evidence schema 3").
|
|
203
|
+
let strict = null;
|
|
204
|
+
if (b.strict) {
|
|
205
|
+
const phases = require('./pincer-runtime/phases.cjs');
|
|
206
|
+
const coverageModule = require('./pincer-runtime/coverage.cjs');
|
|
207
|
+
const resolved = changes.resolveSelected(root, {});
|
|
208
|
+
if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
|
|
209
|
+
const report = phases.compute(root, resolved.record);
|
|
210
|
+
const blocker = phases.firstBlocker(report, 'structure');
|
|
211
|
+
if (blocker) fail('pincer', `${blocker.code}: evidence export refused: ${blocker.detail} — export needs structural coverage`, exitForCode(blocker.code));
|
|
212
|
+
const cov = coverageModule.load(root, resolved.record, { priorInventory: gid => agreement.inventoryOf(root, resolved.record, resolved.record.agreements.find(g => g.id === gid) || null) });
|
|
213
|
+
const v = authorization.verdict(root, resolved.record);
|
|
214
|
+
strict = { inventory: cov.inventory, map: cov.map.map, mapDigest: cov.map.digest, graph: cov.graph, scope: report.scope, agreement: b.agreement, authorization: v.authorized ? v.authorized.id : null };
|
|
215
|
+
}
|
|
216
|
+
const result = evidence.exportEvidence(root, {
|
|
217
|
+
candidate: o.candidate, base: o.base, prd: o.prd, draft, binding: bind.binding, attemptsFor,
|
|
218
|
+
environment: { os: `${os.platform()} ${os.release()}`, node: process.version }, now: nowIso(), atomicWrite, strict,
|
|
219
|
+
});
|
|
220
|
+
if (result.problems.length) {
|
|
221
|
+
for (const p of result.problems) io.err(`evidence: ${result.manifest || o.draft}: ${p}\n`);
|
|
222
|
+
process.exit(EXIT.FAILED);
|
|
223
|
+
}
|
|
224
|
+
io.out(`exported ${result.manifest} (schema ${result.schema})${result.delivery ? ` — delivery: original ${result.delivery.original}, agreed ${result.delivery.agreed}` : ''} — validate: node scripts/pincer-evidence.cjs validate ${result.manifest} --candidate ${o.candidate} --prd ${o.prd}\n`);
|
|
225
|
+
for (const l of result.limitations || []) io.err(`pincer: limitation: ${l}\n`);
|
|
226
|
+
if (b.mode === 'changes') {
|
|
227
|
+
// The per-change evaluation locator is the identity of this evaluation;
|
|
228
|
+
// root NOTES.md stays the human summary (docs/runtime-contracts.md).
|
|
229
|
+
const entry = { candidate: o.candidate, base: o.base, prd: o.prd, prd_revision: b.prd_revision, agreement: b.agreement, manifest: result.manifest, recorded: nowIso() };
|
|
230
|
+
const appended = locator.append(root, b.change, entry);
|
|
231
|
+
if (appended.code) fail('pincer', `${appended.code}: the manifest was written but the evaluation locator could not be updated: ${appended.problem}`, exitForCode(appended.code));
|
|
232
|
+
io.out(`${appended.action === 'recorded' ? 'recorded' : 'already recorded'} evaluation of change ${b.change} in ${locator.file(b.change)} (candidate ${o.candidate.slice(0, 7)}); commit it with the evidence\n`);
|
|
233
|
+
}
|
|
234
|
+
process.exit(EXIT.OK);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function cmdMigrate(root, args) {
|
|
238
|
+
const o = parseOptions(args, { valued: ['--prd', '--change', '--authorization'], switches: ['--preview', '--apply'] });
|
|
239
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
240
|
+
if (!o.prd) usage('migrate requires --prd .prd/prd-vN.md');
|
|
241
|
+
if (Boolean(o.preview) === Boolean(o.apply)) usage('migrate requires exactly one of --preview or --apply');
|
|
242
|
+
const options = { prd: o.prd, change: o.change, authorization: o.authorization ?? null };
|
|
243
|
+
if (o.preview) {
|
|
244
|
+
const p = migrate.plan(root, options);
|
|
245
|
+
io.out(migrate.renderPlan(p));
|
|
246
|
+
process.exit(p.conflicts.length ? EXIT.FAILED : EXIT.OK);
|
|
247
|
+
}
|
|
248
|
+
const result = migrate.apply(root, options);
|
|
249
|
+
if (result.plan.conflicts.length) { io.out(migrate.renderPlan(result.plan)); process.exit(EXIT.FAILED); }
|
|
250
|
+
if (result.already) { io.out(`already migrated: ${o.prd} is change ${result.plan.change} (schema 2 record); nothing changed\n`); process.exit(EXIT.OK); }
|
|
251
|
+
if (result.error) { io.err(`pincer: ${result.code}: migration refused; nothing was written: ${result.error}\n`); process.exit(exitForCode(result.code)); }
|
|
252
|
+
const r = result.record, p = result.plan;
|
|
253
|
+
io.out(`migrated ${o.prd} → change ${r.change} (schema 2 record, ${r.lifecycle.state}${p.source === 'binding' ? ', converted from the v0.5.0 binding' : p.source === 'record' ? ', receipts imported' : ''}; base ${r.base.slice(0, 7)}; ${p.tickets.length} ticket(s) rewritten; ${Object.keys(r.legacy.receipts).length} legacy receipt(s) as history${p.index ? `; ${p.index.stale.length} candidate pointer(s) dropped` : ''})\n`);
|
|
254
|
+
if (result.backupDir) io.out(`backups: ${result.backupDir} (${result.backups.length} file(s)); rollback per docs/runtime-contracts.md\n`);
|
|
255
|
+
if (p.selection) io.out(`selected change ${r.change} in this worktree (${changes.SELECTION_FILE})\n`);
|
|
256
|
+
io.err(`pincer: note: the change is planned with no authorization${r.legacy.authorization_text ? ' (the v0.5.0 authorization text is history only)' : ''}; existing attempts and evaluations are history until verified again — next: node scripts/pincer-runtime.cjs change authorize ${r.change} --agreement <digest> --reference <text> --excerpt <text>, then change activate ${r.change}\n`);
|
|
257
|
+
process.exit(EXIT.OK);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const fail = (prefix, message, code = EXIT.INVALID) => { io.err(`${prefix}: ${message}\n`); process.exit(code); };
|
|
261
|
+
|
|
262
|
+
// start | verify | done | bind — both modes; refusals carry their prefix and exit code.
|
|
263
|
+
async function cmdLifecycle(root, command, args) {
|
|
264
|
+
const usageLine = { start: 'start T-NN', verify: 'verify T-NN', done: 'done T-NN', bind: 'bind T-NN .prd/prd-vN.md' }[command];
|
|
265
|
+
const expected = command === 'bind' ? 2 : 1;
|
|
266
|
+
const o = parseOptions(args, {});
|
|
267
|
+
if (o.positional.length !== expected) usage(`usage: pincer-runtime.cjs ${usageLine}`);
|
|
268
|
+
try {
|
|
269
|
+
const result = command === 'bind' ? lifecycle.bind(root, o.positional[0], o.positional[1])
|
|
270
|
+
: command === 'start' ? lifecycle.start(root, o.positional[0])
|
|
271
|
+
: command === 'verify' ? await lifecycle.verify(root, o.positional[0])
|
|
272
|
+
: await lifecycle.done(root, o.positional[0]);
|
|
273
|
+
if (result.out) io.out(result.out);
|
|
274
|
+
process.exit(result.exit ?? EXIT.OK);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (error instanceof lifecycle.Refusal) fail(error.prefix, error.message, error.exit);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function cmdStatus(root, args) {
|
|
282
|
+
const o = parseOptions(args, { switches: ['--json'], valued: ['--change'] });
|
|
283
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
284
|
+
const budget = process.env.PINCER_BUILD_BUDGET_MIN || '';
|
|
285
|
+
const result = status.render(root, { budget, change: o.change || null });
|
|
286
|
+
if (o.json) io.out(`${JSON.stringify(result.json, null, 2)}\n`);
|
|
287
|
+
else io.out(result.text);
|
|
288
|
+
process.exit(result.exit);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Read-only readiness gate: a ticket, or the candidate when no ticket is named.
|
|
292
|
+
function cmdReady(root, args) {
|
|
293
|
+
const o = parseOptions(args, { valued: ['--change'] });
|
|
294
|
+
if (o.positional.length > 1) usage('ready takes at most one ticket ID');
|
|
295
|
+
const result = status.render(root, { change: o.change || null });
|
|
296
|
+
if (result.exit !== 0) { io.err(result.text); process.exit(result.exit); }
|
|
297
|
+
const j = result.json;
|
|
298
|
+
// Changes mode: the selection, lifecycle, view and authorization gates block
|
|
299
|
+
// read-only readiness too (the same codes the execution guard would refuse with).
|
|
300
|
+
const changeBlockers = j.mode === 'changes' ? j.reasons.filter(r => gates.ORDER.includes(r.code)) : [];
|
|
301
|
+
if (j.mode === 'changes' && j.change && j.change.lifecycle.state !== 'active' && o.positional.length === 1) changeBlockers.push({ code: 'LIFECYCLE_BLOCKED', detail: `change ${j.change.id} is ${j.change.lifecycle.state}; ticket execution runs on an active change` });
|
|
302
|
+
if (o.positional.length === 1) {
|
|
303
|
+
const id = parse.normalizeId(o.positional[0]);
|
|
304
|
+
const ticket = id && j.tickets.find(t => t.id === id);
|
|
305
|
+
if (!ticket) { io.err(`pincer: no ticket ${o.positional[0]} is associated with the selected PRD${j.mode === 'changes' && !j.change ? ` (${j.selection.problem ? j.selection.problem.detail : 'no change selected'})` : ''}\n`); process.exit(EXIT.INVALID); }
|
|
306
|
+
if (ticket.readiness.ready && !changeBlockers.length) { io.out(`ready ${id}\n`); process.exit(EXIT.OK); }
|
|
307
|
+
for (const r of changeBlockers) io.out(`not ready ${id}: ${r.code} ${r.detail}\n`);
|
|
308
|
+
for (const r of ticket.readiness.reasons) io.out(`not ready ${id}: ${r.code} ${r.detail}\n`);
|
|
309
|
+
io.out(`next: ${changeBlockers.length ? `${changeBlockers[0].code}: ${changeBlockers[0].detail}` : ticket.readiness.next}\n`);
|
|
310
|
+
process.exit(EXIT.FAILED);
|
|
311
|
+
}
|
|
312
|
+
const blockers = [...changeBlockers];
|
|
313
|
+
if (j.mode === 'changes' && j.change && j.change.lifecycle.state !== 'completed') blockers.push({ code: 'LIFECYCLE_BLOCKED', detail: `change ${j.change.id} is ${j.change.lifecycle.state}, not completed; release audits completed changes only` });
|
|
314
|
+
const localUnavailable = j.candidate && j.candidate.local_attempts === 'unavailable';
|
|
315
|
+
for (const t of j.tickets) {
|
|
316
|
+
if (t.status !== 'done') blockers.push({ code: 'EVIDENCE_MISSING', detail: `${t.id} is ${t.status}, not done` });
|
|
317
|
+
else for (const r of t.readiness.reasons) {
|
|
318
|
+
// A fresh clone validates the saved candidate record only; missing local attempts are its stated limit, not a blocker.
|
|
319
|
+
if (localUnavailable && r.code === 'EVIDENCE_MISSING') continue;
|
|
320
|
+
blockers.push({ code: r.code, detail: `${t.id}: ${r.detail}` });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!j.prd) blockers.push({ code: 'INPUT_INVALID', detail: 'no PRD' });
|
|
324
|
+
else if (j.prd.status !== 'built') blockers.push({ code: 'CANDIDATE_STALE', detail: `PRD status is '${j.prd.status}', expected 'built'` });
|
|
325
|
+
if (j.candidate) blockers.push(...j.candidate.reasons);
|
|
326
|
+
if (j.mode === 'changes' && !j.change) blockers.push({ code: 'SELECTION_REQUIRED', detail: 'no change is selected' });
|
|
327
|
+
// Strict coverage (docs/runtime-contracts.md, "Phase-specific coverage"): release needs
|
|
328
|
+
// structural coverage and every in-scope scenario delivered by the reconciled evidence.
|
|
329
|
+
if (j.mode === 'changes' && j.change && result.gathered && result.gathered.record && changes.isStrict(result.gathered.record)) {
|
|
330
|
+
const report = require('./pincer-runtime/phases.cjs').compute(root, result.gathered.record, { gathered: result.gathered });
|
|
331
|
+
for (const p of [...report.structure.problems, ...report.candidate.problems]) if (!blockers.some(b => b.code === p.code && b.detail === p.detail) && !(p.code === 'CANDIDATE_STALE' && blockers.some(b => b.code === 'CANDIDATE_STALE'))) blockers.push({ code: p.code, detail: p.detail });
|
|
332
|
+
}
|
|
333
|
+
if (!blockers.length) { io.out(`ready candidate ${j.candidate.candidate}\n`); process.exit(EXIT.OK); }
|
|
334
|
+
for (const b of blockers) io.out(`not ready: ${b.code} ${b.detail}\n`);
|
|
335
|
+
io.out(`next: ${j.next}\n`);
|
|
336
|
+
process.exit(EXIT.FAILED);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Run a state operation, mapping the contracted failures to exit codes.
|
|
340
|
+
function guarded(fn) {
|
|
341
|
+
try { return fn(); } catch (error) {
|
|
342
|
+
if (error && error.code === 'STATE_BUSY') { io.err(`pincer: STATE_BUSY: ${error.message}\n`); process.exit(EXIT.BUSY); }
|
|
343
|
+
if (error && error.code === 'INVALID') { io.err(`pincer: ${error.message}\n`); process.exit(EXIT.INVALID); }
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function cmdRecover(root, args) {
|
|
349
|
+
const o = parseOptions(args, {});
|
|
350
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
351
|
+
if (!state.exists(root)) { io.out('nothing to recover: no local runtime state\n'); process.exit(EXIT.OK); }
|
|
352
|
+
const report = guarded(() => state.recover(root));
|
|
353
|
+
for (const t of report.transactions.completed) io.out(`completed transaction ${t.id} (${t.command}): ${t.targets.join(', ')}\n`);
|
|
354
|
+
for (const t of report.transactions.discarded) io.out(`discarded uncommitted staging ${t.id}\n`);
|
|
355
|
+
for (const t of report.transactions.unreadable) io.out(`left transaction ${t.id} in place: ${t.problem} (inspect ${state.RUNTIME_DIR}/journal/${t.id}/manifest.json by hand)\n`);
|
|
356
|
+
for (const id of report.finalized) io.out(`finalized ${id} as interrupted (owner no longer running)\n`);
|
|
357
|
+
for (const { id, owner } of report.live) io.out(`still running ${id} (pid ${owner.pid} is alive)\n`);
|
|
358
|
+
for (const { id, owner } of report.foreign) io.out(`still running ${id} (owned by ${owner.host}; not reclaimed from another host)\n`);
|
|
359
|
+
for (const id of report.missing) io.out(`dropped ${id} from running: record missing\n`);
|
|
360
|
+
for (const file of report.journal) io.out(`removed stray journal file ${file}\n`);
|
|
361
|
+
const { transactions, ...lists } = report;
|
|
362
|
+
if (!Object.values(lists).some(list => list.length) && !Object.values(transactions).some(list => list.length)) io.out('nothing to recover\n');
|
|
363
|
+
process.exit(EXIT.OK);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// `--flag value` and `--switch` options; positional arguments keep their order.
|
|
367
|
+
function parseOptions(args, { valued = [], switches = [], repeated = [] } = {}) {
|
|
368
|
+
const options = { positional: [] };
|
|
369
|
+
for (const arg of repeated) options[arg.slice(2)] = [];
|
|
370
|
+
for (let i = 0; i < args.length; i++) {
|
|
371
|
+
const arg = args[i];
|
|
372
|
+
if (arg === '--') { options.positional.push(...args.slice(i + 1)); break; }
|
|
373
|
+
if (repeated.includes(arg)) {
|
|
374
|
+
const value = args[++i];
|
|
375
|
+
if (value === undefined) usage(`${arg} requires a value`);
|
|
376
|
+
options[arg.slice(2)].push(value);
|
|
377
|
+
} else if (valued.includes(arg)) {
|
|
378
|
+
const value = args[++i];
|
|
379
|
+
if (value === undefined) usage(`${arg} requires a value`);
|
|
380
|
+
options[arg.slice(2)] = value;
|
|
381
|
+
} else if (switches.includes(arg)) options[arg.slice(2)] = true;
|
|
382
|
+
else if (arg.startsWith('--')) usage(`unknown option ${arg}`);
|
|
383
|
+
else options.positional.push(arg);
|
|
384
|
+
}
|
|
385
|
+
return options;
|
|
386
|
+
}
|
|
387
|
+
const problemExit = code => (code === 'STATE_BUSY' ? EXIT.BUSY : EXIT.INVALID);
|
|
388
|
+
// Contracted exit codes for the change commands: 3 busy, 4 invalid input or
|
|
389
|
+
// unreadable state, 1 for every refusal (docs/runtime-contracts.md, "Exit codes").
|
|
390
|
+
const INVALID_CODES = ['INPUT_INVALID', 'INVENTORY_INVALID', 'COVERAGE_INVALID', 'CHECK_UNDECLARED', 'MALFORMED', 'UNSUPPORTED_SCHEMA', 'HISTORY_INVALID', 'STATE_INCOMPLETE', 'UNSUPPORTED_INPUT', 'CHANGES_MODE', 'AMBIGUOUS', 'INVALID'];
|
|
391
|
+
const exitForCode = code => (code === 'STATE_BUSY' ? EXIT.BUSY : INVALID_CODES.includes(code) ? EXIT.INVALID : EXIT.FAILED);
|
|
392
|
+
|
|
393
|
+
function cmdRegister(root, args) {
|
|
394
|
+
const o = parseOptions(args, { valued: ['--prd', '--change', '--authorization'], switches: ['--replace', '--rebind'] });
|
|
395
|
+
if (!o.prd) usage('register requires --prd .prd/prd-vN.md');
|
|
396
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
397
|
+
const mode = changes.scan(root).mode;
|
|
398
|
+
if (mode === 'migrated') {
|
|
399
|
+
// v0.5.0 binding: the released semantics, except that a second PRD or
|
|
400
|
+
// --replace now needs the migration to change records.
|
|
401
|
+
const result = identity.register(root, { prd: o.prd, change: o.change, authorization: o.authorization ?? null, replace: Boolean(o.replace), rebind: Boolean(o.rebind) });
|
|
402
|
+
if (result.code) { io.err(`pincer: ${result.code === 'MIGRATION_REQUIRED' ? 'MIGRATION_REQUIRED: ' : ''}${result.problem}\n`); process.exit(result.code === 'MIGRATION_REQUIRED' ? EXIT.FAILED : problemExit(result.code)); }
|
|
403
|
+
for (const note of result.notes) io.err(`pincer: note: ${note}\n`);
|
|
404
|
+
const b = result.binding;
|
|
405
|
+
io.out(`${result.action} change ${b.change} → ${b.prd} revision ${b.prd_revision.slice(0, 12)} base ${b.base.slice(0, 7)} (${result.file})\n`);
|
|
406
|
+
process.exit(EXIT.OK);
|
|
407
|
+
}
|
|
408
|
+
// Legacy or changes mode: a schema 2 record. The v0.5.0 flags are refused
|
|
409
|
+
// with the command that replaces them; nothing is inferred from them.
|
|
410
|
+
if (o.replace) fail('pincer', 'LIFECYCLE_BLOCKED: --replace is not supported for change records (they are retained); work on another change with `change select <id>`, retire one with `change supersede <id> --with <replacement> --decision D-NN` or `change cancel <id> --decision D-NN --reason <text>`', EXIT.FAILED);
|
|
411
|
+
if (o.rebind) fail('pincer', 'AGREEMENT_CHANGED: --rebind is not supported for change records; record the revised agreement with `change revise <id>` and its disposition with `change authorize`', EXIT.FAILED);
|
|
412
|
+
if (o.authorization !== undefined) fail('pincer', 'AUTHORIZATION_REQUIRED: --authorization is not recorded on change records (free text cannot become approval); record the user\'s instruction with `change authorize <id> --agreement <digest> --reference <text> --excerpt <text>` after registration', EXIT.FAILED);
|
|
413
|
+
const result = changes.register(root, { prd: o.prd, change: o.change });
|
|
414
|
+
if (result.code) { io.err(`pincer: ${result.code}: ${result.problem}\n`); process.exit(exitForCode(result.code)); }
|
|
415
|
+
const r = result.record;
|
|
416
|
+
io.out(`${result.action} change ${r.change} → ${r.prd} base ${r.base.slice(0, 7)} · ${r.lifecycle.state} (${result.file})\n`);
|
|
417
|
+
if (result.action === 'registered') io.err('pincer: note: registration grants no authorization; record the user\'s instruction with `change authorize` and select the change with `change select`\n');
|
|
418
|
+
process.exit(EXIT.OK);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// coverage adopt --preview|--apply --change <id> [--agreement <digest>] — the explicit
|
|
422
|
+
// entry into strict coverage (docs/runtime-contracts.md, "Adoption and rollback").
|
|
423
|
+
function cmdCoverage(root, args) {
|
|
424
|
+
const [sub, ...rest] = args;
|
|
425
|
+
if (sub !== 'adopt') {
|
|
426
|
+
// The read-only coverage report (docs/runtime-contracts.md, "Coverage and impact commands").
|
|
427
|
+
const o = parseOptions(args, { switches: ['--json'], valued: ['--change'] });
|
|
428
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]} (coverage takes [--change <id>] [--json], or the adopt subcommand)`);
|
|
429
|
+
const phases = require('./pincer-runtime/phases.cjs');
|
|
430
|
+
const resolved = changes.resolveSelected(root, { change: o.change || null });
|
|
431
|
+
if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
|
|
432
|
+
const st = status.render(root, { change: o.change || null });
|
|
433
|
+
if (st.exit === 4) { io.err(st.text); process.exit(EXIT.INVALID); }
|
|
434
|
+
// A report asked for with --change may describe a change this worktree has not
|
|
435
|
+
// selected; no execution command it prints could run as written.
|
|
436
|
+
const selected = !resolved.selection || resolved.selection.change === resolved.record.change;
|
|
437
|
+
const result = phases.report(root, resolved.record, { gathered: st.gathered, generated: st.json.generated, selected });
|
|
438
|
+
if (o.json) io.out(`${JSON.stringify(result, null, 2)}\n`);
|
|
439
|
+
else io.out(phases.render(result));
|
|
440
|
+
process.exit(EXIT.OK);
|
|
441
|
+
}
|
|
442
|
+
const o = parseOptions(rest, { valued: ['--change', '--agreement'], switches: ['--preview', '--apply'] });
|
|
443
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
444
|
+
if (!o.change) usage('coverage adopt requires --change <id>');
|
|
445
|
+
if (Boolean(o.preview) === Boolean(o.apply)) usage('coverage adopt requires exactly one of --preview or --apply');
|
|
446
|
+
if (o.agreement !== undefined && !/^[0-9a-f]{64}$/.test(o.agreement)) usage('--agreement must be the 64-hex agreement digest the preview printed');
|
|
447
|
+
if (o.preview) {
|
|
448
|
+
const p = adopt.plan(root, { change: o.change });
|
|
449
|
+
io.out(adopt.renderPlan(p));
|
|
450
|
+
process.exit(p.conflicts.length ? EXIT.FAILED : EXIT.OK);
|
|
451
|
+
}
|
|
452
|
+
const result = adopt.apply(root, { change: o.change, agreement: o.agreement ?? null });
|
|
453
|
+
if (result.plan.conflicts.length) { io.out(adopt.renderPlan(result.plan)); process.exit(EXIT.FAILED); }
|
|
454
|
+
if (result.already) { io.out(`already adopted: change ${o.change} is strict since ${result.plan.record.coverage.adopted} (agreement ${result.plan.record.coverage.agreement}); nothing changed\n`); process.exit(EXIT.OK); }
|
|
455
|
+
if (result.error) { io.err(`pincer: ${result.code}: adoption refused; nothing was written: ${result.error}\n`); process.exit(exitForCode(result.code)); }
|
|
456
|
+
const r = result.record, p = result.plan;
|
|
457
|
+
io.out(`adopted strict coverage for change ${r.change} (schema 3 record; agreement ${p.agreement.id} ${p.agreement.digest.slice(0, 12)}; inventory ${p.inventory.requirements} requirement(s), ${p.inventory.scenarios} scenario(s); map ${p.map.digest.slice(0, 12)}; ${p.historical} earlier attempt(s) are history)\n`);
|
|
458
|
+
io.out(`backup: ${result.backup} (rollback per docs/runtime-contracts.md, "Adoption and rollback")\n`);
|
|
459
|
+
io.err(`pincer: note: adoption grants no authorization; record the user's instruction covering the strict agreement with: node scripts/pincer-runtime.cjs change authorize ${r.change} --agreement ${p.agreement.digest} --reference <text> --excerpt <text> (or --delegated --basis A-NN --explanation <text>)\n`);
|
|
460
|
+
process.exit(EXIT.OK);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// impact [--change <id>] [--from G-NN|A-NN] [--json] — read-only structural differences
|
|
464
|
+
// against a retained agreement (docs/runtime-contracts.md, "Coverage and impact commands").
|
|
465
|
+
function cmdImpact(root, args) {
|
|
466
|
+
const o = parseOptions(args, { switches: ['--json'], valued: ['--change', '--from'] });
|
|
467
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
468
|
+
const resolved = changes.resolveSelected(root, { change: o.change || null });
|
|
469
|
+
if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
|
|
470
|
+
const result = impact.compute(root, resolved.record, { from: o.from || null });
|
|
471
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
472
|
+
if (o.json) io.out(`${JSON.stringify(result, null, 2)}\n`);
|
|
473
|
+
else io.out(impact.render(result));
|
|
474
|
+
process.exit(EXIT.OK);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// resume [--change <id>] [--json] — the read-only resume report (never the lifecycle operation).
|
|
478
|
+
function cmdResume(root, args) {
|
|
479
|
+
const o = parseOptions(args, { switches: ['--json'], valued: ['--change'] });
|
|
480
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
481
|
+
const result = resume.build(root, { change: o.change || null });
|
|
482
|
+
if (o.json) io.out(`${JSON.stringify(result.json, null, 2)}\n`);
|
|
483
|
+
else io.out(result.text);
|
|
484
|
+
process.exit(result.exit);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// The current agreement of a record against its recorded entries (read-only).
|
|
488
|
+
function agreementNow(root, record) {
|
|
489
|
+
const now = agreement.compute(root, record);
|
|
490
|
+
if (now.code) return now;
|
|
491
|
+
const entry = agreement.entryFor(record, now.digest);
|
|
492
|
+
const latest = agreement.latestEntry(record);
|
|
493
|
+
let difference = null, rendered = null;
|
|
494
|
+
if (!entry && latest) {
|
|
495
|
+
const snap = agreement.readSnapshot(root, record, latest);
|
|
496
|
+
if (!snap.code) { difference = agreement.difference(snap.snapshot, now); rendered = agreement.renderDifference(difference); }
|
|
497
|
+
}
|
|
498
|
+
return { digest: now.digest, entry, latest, difference, rendered };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// change list | show | select | revise — inspection and local/authored records.
|
|
502
|
+
function cmdChange(root, args) {
|
|
503
|
+
const [sub, ...rest] = args;
|
|
504
|
+
if (sub === 'list') {
|
|
505
|
+
const o = parseOptions(rest, { switches: ['--json'] });
|
|
506
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
507
|
+
const result = changes.list(root);
|
|
508
|
+
const loaded = changes.loadRecords(root);
|
|
509
|
+
for (const c of result.changes) { const e = loaded.records.get(c.id); if (e) { const v = authorization.verdict(root, e.record); c.authorization = v.verdict; c.agreement = v.current; } }
|
|
510
|
+
const sel = changes.readSelection(root);
|
|
511
|
+
const selection = sel.code ? { change: null, problem: { code: sel.code, detail: sel.problem } } : { change: sel.change, problem: null };
|
|
512
|
+
result.changes.forEach(c => { c.selected = Boolean(sel.change) && sel.change === c.id; });
|
|
513
|
+
if (o.json) io.out(`${JSON.stringify({ schema: 1, runtime: changes.RUNTIME, generated: nowIso(), root, mode: result.mode, selection, changes: result.changes, problems: result.problems }, null, 2)}\n`);
|
|
514
|
+
else io.out(changes.renderList(result, { selection: sel.code ? null : sel }));
|
|
515
|
+
process.exit(result.problems.length ? EXIT.INVALID : EXIT.OK);
|
|
516
|
+
}
|
|
517
|
+
if (sub === 'select') {
|
|
518
|
+
const o = parseOptions(rest, {});
|
|
519
|
+
if (o.positional.length !== 1) usage('change select requires exactly one change ID');
|
|
520
|
+
const result = changes.select(root, o.positional[0]);
|
|
521
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
522
|
+
io.out(`${result.action === 'unchanged' ? 'already selected' : 'selected'} change ${o.positional[0]} → ${result.record.prd} · ${result.record.lifecycle.state} (${changes.SELECTION_FILE}, local to this worktree${result.previous ? `; previously ${result.previous}` : ''})\n`);
|
|
523
|
+
if (result.action === 'selected') io.err('pincer: note: selection is metadata only — no checkout, stash, reset or commit was made, and selecting grants no authorization\n');
|
|
524
|
+
process.exit(EXIT.OK);
|
|
525
|
+
}
|
|
526
|
+
if (sub === 'show') {
|
|
527
|
+
const o = parseOptions(rest, { switches: ['--json'] });
|
|
528
|
+
if (o.positional.length !== 1) usage('change show requires exactly one change ID');
|
|
529
|
+
const result = changes.loadRecord(root, o.positional[0]);
|
|
530
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
531
|
+
const sel = changes.readSelection(root);
|
|
532
|
+
const now = agreementNow(root, result.record);
|
|
533
|
+
const v = authorization.verdict(root, result.record);
|
|
534
|
+
const loc = locator.read(root, o.positional[0]);
|
|
535
|
+
const evaluations = loc.code ? [] : loc.locator.evaluations;
|
|
536
|
+
if (o.json) io.out(`${JSON.stringify({ schema: 1, runtime: changes.RUNTIME, generated: nowIso(), root, file: result.file, selected: !sel.code && sel.change === o.positional[0], record: result.record, agreement: now.code ? { current: null, problem: { code: now.code, detail: now.problem } } : { current: now.digest, recorded: now.entry ? now.entry.id : null, latest: now.latest ? now.latest.id : null, difference: now.difference }, authorization: { verdict: v.verdict, detail: v.detail, authorized: v.authorized ? v.authorized.id : null, open_decisions: v.open }, evaluations, locator: loc.code ? { code: loc.code, detail: loc.problem } : null }, null, 2)}\n`);
|
|
537
|
+
else io.out(changes.renderShow(o.positional[0], result, { agreement: now, verdict: v, evaluations: loc.code ? [] : evaluations, locatorProblem: loc.code ? loc.problem : null }));
|
|
538
|
+
process.exit(EXIT.OK);
|
|
539
|
+
}
|
|
540
|
+
if (transitions.OPS.includes(sub)) {
|
|
541
|
+
const o = parseOptions(rest, { valued: ['--reason', '--note', '--decision', '--with'] });
|
|
542
|
+
if (o.positional.length !== 1) usage(`change ${sub} requires exactly one change ID`);
|
|
543
|
+
const id = o.positional[0];
|
|
544
|
+
const result = transitions.transition(root, id, sub, { reason: o.reason, note: o.note, decision: o.decision, with: o.with });
|
|
545
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
546
|
+
if (result.action === 'unchanged') { io.out(`change ${id} is already ${result.to}${result.record.lifecycle.superseded_by ? ` by ${result.record.lifecycle.superseded_by}` : ''}; nothing written\n`); process.exit(EXIT.OK); }
|
|
547
|
+
const e = result.event;
|
|
548
|
+
const past = { activate: 'activated', pause: 'paused', resume: 'resumed', complete: 'completed', reopen: 'reopened', cancel: 'cancelled', supersede: 'superseded' }[sub];
|
|
549
|
+
io.out(`${past} change ${id}: ${result.from} → ${result.to} (event ${e.sequence}${e.authorization ? `, authorization ${e.authorization}` : ''}${e.decision ? `, decision ${e.decision}` : ''}${e.replacement ? `, replaced by ${e.replacement}` : ''}${e.reason ? `; reason: ${e.reason}` : ''})\n`);
|
|
550
|
+
if (sub === 'complete') io.err('pincer: note: completed means implementation complete and ready for evaluation, not evaluated or released; commit the record before choosing the candidate\n');
|
|
551
|
+
if (sub === 'pause' && e.note) io.err('pincer: note: the handoff note is authored text; status and resume show it but never derive readiness from it\n');
|
|
552
|
+
process.exit(EXIT.OK);
|
|
553
|
+
}
|
|
554
|
+
if (sub === 'authorize') {
|
|
555
|
+
const o = parseOptions(rest, { valued: ['--agreement', '--reference', '--excerpt', '--constraints', '--basis', '--explanation'], switches: ['--delegated'], repeated: ['--decision'] });
|
|
556
|
+
if (o.positional.length !== 1) usage('change authorize requires exactly one change ID');
|
|
557
|
+
if (!o.agreement) usage('change authorize requires --agreement <digest>');
|
|
558
|
+
const result = authorization.authorize(root, o.positional[0], { agreement: o.agreement, delegated: Boolean(o.delegated), reference: o.reference, excerpt: o.excerpt, constraints: o.constraints, basis: o.basis, explanation: o.explanation, decisions: o.decision });
|
|
559
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
560
|
+
const a = result.authorization;
|
|
561
|
+
if (result.action === 'unchanged') io.out(`unchanged: ${a.id} (${a.disposition}) already records this authorization of agreement ${a.agreement} ${a.digest.slice(0, 12)} for ${o.positional[0]}; nothing written\n`);
|
|
562
|
+
else io.out(`recorded authorization ${a.id} (${a.disposition}${a.basis ? `, basis ${a.basis}` : ''}) of agreement ${a.agreement} ${a.digest.slice(0, 12)} for ${o.positional[0]}${a.decisions.length ? ` · decisions ${a.decisions.join(', ')}` : ''}\n`);
|
|
563
|
+
if (result.action === 'recorded') io.err(`pincer: note: this records local provenance of ${a.disposition === 'user' ? "the user's instruction" : 'a delegation judgment'}, not authenticated identity; execution still needs the change selected and active\n`);
|
|
564
|
+
process.exit(EXIT.OK);
|
|
565
|
+
}
|
|
566
|
+
if (sub === 'decide') {
|
|
567
|
+
const o = parseOptions(rest, { valued: ['--summary', '--id', '--resolve', '--reference', '--excerpt'] });
|
|
568
|
+
if (o.positional.length !== 1) usage('change decide requires exactly one change ID');
|
|
569
|
+
if (!o.resolve && !o.summary) usage('change decide requires --summary <text> (raise) or --resolve D-NN --reference <text> --excerpt <text>');
|
|
570
|
+
const result = authorization.decide(root, o.positional[0], { summary: o.summary, id: o.id, resolve: o.resolve, reference: o.reference, excerpt: o.excerpt });
|
|
571
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
572
|
+
const d = result.decision;
|
|
573
|
+
if (result.action === 'unchanged') io.out(`unchanged: ${d.id} is already ${d.status} (${d.summary}); nothing written\n`);
|
|
574
|
+
else if (result.action === 'raised') { io.out(`raised decision ${d.id} on ${o.positional[0]}: ${d.summary}\n`); io.err(`pincer: note: execution of ${o.positional[0]} is blocked (DECISION_REQUIRED) until the user's decision is recorded with: node scripts/pincer-runtime.cjs change decide ${o.positional[0]} --resolve ${d.id} --reference <text> --excerpt <text>\n`); }
|
|
575
|
+
else { io.out(`resolved decision ${d.id} on ${o.positional[0]}: "${d.excerpt}" (${d.reference})\n`); io.err(`pincer: note: the agreement is now ${result.agreement ? result.agreement.slice(0, 12) : 'unavailable'} and needs authorization: node scripts/pincer-runtime.cjs change authorize ${o.positional[0]} --agreement ${result.agreement || '<digest>'} --decision ${d.id} …\n`); }
|
|
576
|
+
process.exit(EXIT.OK);
|
|
577
|
+
}
|
|
578
|
+
if (sub === 'revise') {
|
|
579
|
+
const o = parseOptions(rest, {});
|
|
580
|
+
if (o.positional.length !== 1) usage('change revise requires exactly one change ID');
|
|
581
|
+
const result = agreement.revise(root, o.positional[0]);
|
|
582
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
583
|
+
if (result.action === 'unchanged') io.out(`unchanged: the current agreement of ${o.positional[0]} is ${result.agreement.id} ${result.agreement.digest.slice(0, 12)} (recorded ${result.agreement.recorded}); nothing written\n`);
|
|
584
|
+
else io.out(`recorded agreement ${result.agreement.id} ${result.agreement.digest.slice(0, 12)} for ${o.positional[0]} (${result.agreement.tickets.length} ticket(s), snapshot ${result.agreement.snapshot})${result.difference ? ` — differs from the previous agreement: ${agreement.renderDifference(result.difference)}` : ''}\n`);
|
|
585
|
+
if (result.action === 'recorded') io.err('pincer: note: recording an agreement authorizes nothing; record its disposition with `change authorize`\n');
|
|
586
|
+
process.exit(EXIT.OK);
|
|
587
|
+
}
|
|
588
|
+
usage(sub ? `unknown change subcommand ${sub} (change supports: list, show, select, revise, authorize, decide, activate, pause, resume, complete, reopen, cancel, supersede)` : 'change requires a subcommand: list, show, select, revise, authorize, decide, activate, pause, resume, complete, reopen, cancel, supersede');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function cmdSnapshot(root, args) {
|
|
592
|
+
const o = parseOptions(args, { switches: ['--json', '--store'] });
|
|
593
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
594
|
+
const manifest = source.snapshot(root);
|
|
595
|
+
for (const problem of manifest.problems) io.err(`pincer: ${problem.code}: ${problem.detail}\n`);
|
|
596
|
+
if (manifest.problems.length) process.exit(EXIT.INVALID);
|
|
597
|
+
if (o.store) source.storeManifest(root, manifest);
|
|
598
|
+
if (o.json) io.out(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
599
|
+
else {
|
|
600
|
+
io.out(`digest ${manifest.digest}\nfiles ${manifest.files.length}\nexcluded ${manifest.excluded.length}\n`);
|
|
601
|
+
for (const l of manifest.limitations) io.err(`pincer: limitation: ${l}\n`);
|
|
602
|
+
}
|
|
603
|
+
process.exit(EXIT.OK);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function cmdValidate(root, args) {
|
|
607
|
+
const digests = args.includes('--digests');
|
|
608
|
+
const files = args.filter(arg => arg !== '--digests');
|
|
609
|
+
if (files.some(arg => arg.startsWith('--'))) usage(`unknown option ${files.find(arg => arg.startsWith('--'))}`);
|
|
610
|
+
if (files.length === 0) usage('validate requires at least one file');
|
|
611
|
+
let invalid = false;
|
|
612
|
+
const report = (prefix, file, problems) => { for (const p of problems) io.err(`${prefix}: ${file}: ${p}\n`); invalid ||= problems.length > 0; };
|
|
613
|
+
for (const file of files) {
|
|
614
|
+
const relative = path.isAbsolute(file) ? path.relative(root, file) : file;
|
|
615
|
+
const base = path.basename(relative);
|
|
616
|
+
if (/^T-[0-9]+.*\.md$/.test(base)) {
|
|
617
|
+
let text;
|
|
618
|
+
try { text = fs.readFileSync(path.resolve(root, relative), 'utf8'); } catch { report('pincer-ticket', relative, ['no such file']); continue; }
|
|
619
|
+
const result = parse.validateTicket(relative, text);
|
|
620
|
+
report('pincer-ticket', relative, result.problems);
|
|
621
|
+
if (result.ok && digests) {
|
|
622
|
+
io.out(`ticket ${parse.ticketDigest(text)}\n`);
|
|
623
|
+
io.out(`check ${parse.checkDigest(text, result.timeout)}\n`);
|
|
624
|
+
}
|
|
625
|
+
} else if (parse.PRD_REF.test(relative)) {
|
|
626
|
+
const result = parse.validatePrd(root, relative);
|
|
627
|
+
report('pincer', relative, result.problems);
|
|
628
|
+
if (result.ok && digests) io.out(`prd ${parse.prdDigest(result.text)}\n`);
|
|
629
|
+
} else {
|
|
630
|
+
let text;
|
|
631
|
+
try { text = fs.readFileSync(path.resolve(root, relative), 'utf8'); } catch { report('pincer', relative, ['no such file']); continue; }
|
|
632
|
+
report('pincer', relative, parse.validateMetadata(text).problems);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
process.exit(invalid ? EXIT.INVALID : EXIT.OK);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function main(argv) {
|
|
639
|
+
const [command, ...rest] = argv;
|
|
640
|
+
const root = repoRoot();
|
|
641
|
+
if (command === 'validate') return cmdValidate(root, rest);
|
|
642
|
+
if (command === 'register') return cmdRegister(root, rest);
|
|
643
|
+
if (command === 'snapshot') return cmdSnapshot(root, rest);
|
|
644
|
+
if (command === 'recover') return cmdRecover(root, rest);
|
|
645
|
+
if (command === 'status') return cmdStatus(root, rest);
|
|
646
|
+
if (command === 'ready') return cmdReady(root, rest);
|
|
647
|
+
if (['start', 'verify', 'done', 'bind'].includes(command)) return cmdLifecycle(root, command, rest);
|
|
648
|
+
if (command === 'migrate') return cmdMigrate(root, rest);
|
|
649
|
+
if (command === 'check') return cmdCheck(root, rest);
|
|
650
|
+
if (command === 'evidence') return cmdEvidence(root, rest);
|
|
651
|
+
if (command === 'change') return cmdChange(root, rest);
|
|
652
|
+
if (command === 'resume') return cmdResume(root, rest);
|
|
653
|
+
if (command === 'coverage') return cmdCoverage(root, rest);
|
|
654
|
+
if (command === 'impact') return cmdImpact(root, rest);
|
|
655
|
+
usage(command ? `unknown command ${command}` : undefined);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
Promise.resolve(main(process.argv.slice(2))).catch(error => {
|
|
659
|
+
io.err(`pincer: unexpected error: ${error && error.stack ? error.stack : error}\n`);
|
|
660
|
+
process.exit(EXIT.INVALID);
|
|
661
|
+
});
|