pincer-workflow 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -19
- package/bin/pincer.js +17 -1
- package/package.json +3 -3
- package/template/.agents/skills/pincer-code/SKILL.md +88 -14
- package/template/.agents/skills/pincer-evaluate/SKILL.md +42 -13
- package/template/.agents/skills/pincer-narrow/SKILL.md +53 -12
- package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
- package/template/.agents/skills/pincer-release/SKILL.md +18 -0
- package/template/.agents/skills/pincer-status/SKILL.md +29 -5
- package/template/.claude/commands/pincer-code.md +88 -14
- package/template/.claude/commands/pincer-evaluate.md +42 -13
- package/template/.claude/commands/pincer-narrow.md +53 -12
- package/template/.claude/commands/pincer-plan.md +12 -4
- package/template/.claude/commands/pincer-release.md +18 -0
- package/template/.claude/commands/pincer-status.md +29 -5
- package/template/.claude/hooks/hook-policy.cjs +13 -6
- package/template/.claude/references/prd-template.md +11 -4
- package/template/.codex/README.md +1 -1
- package/template/.github/prompts/pincer-code.prompt.md +88 -14
- package/template/.github/prompts/pincer-evaluate.prompt.md +42 -13
- package/template/.github/prompts/pincer-narrow.prompt.md +53 -12
- package/template/.github/prompts/pincer-plan.prompt.md +12 -4
- package/template/.github/prompts/pincer-release.prompt.md +18 -0
- package/template/.github/prompts/pincer-status.prompt.md +29 -5
- package/template/AGENTS.md +17 -1
- package/template/docs/dry-run-checklist.md +30 -3
- package/template/docs/release-checklist.md +3 -1
- package/template/docs/runtime-contracts.md +1428 -96
- package/template/scripts/pincer-evidence.cjs +9 -7
- 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 +303 -18
- package/template/scripts/pincer-runtime/gates.cjs +73 -0
- package/template/scripts/pincer-runtime/identity.cjs +21 -4
- 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 +34 -12
- package/template/scripts/pincer-runtime/locator.cjs +158 -0
- package/template/scripts/pincer-runtime/migrate.cjs +140 -63
- package/template/scripts/pincer-runtime/parse.cjs +20 -1
- package/template/scripts/pincer-runtime/phases.cjs +245 -0
- package/template/scripts/pincer-runtime/readiness.cjs +15 -2
- package/template/scripts/pincer-runtime/requirements.cjs +255 -0
- package/template/scripts/pincer-runtime/resume.cjs +273 -0
- package/template/scripts/pincer-runtime/routing.cjs +54 -0
- package/template/scripts/pincer-runtime/runner.cjs +24 -6
- package/template/scripts/pincer-runtime/scaffold.cjs +254 -0
- package/template/scripts/pincer-runtime/state.cjs +29 -7
- package/template/scripts/pincer-runtime/status.cjs +178 -22
- package/template/scripts/pincer-runtime/transaction.cjs +200 -0
- package/template/scripts/pincer-runtime/transitions.cjs +134 -0
- package/template/scripts/pincer-runtime.cjs +412 -76
- package/template/scripts/pincer-status.sh +1 -1
- package/template/scripts/pincer-ticket.sh +1 -1
|
@@ -6,12 +6,19 @@
|
|
|
6
6
|
// node scripts/pincer-runtime.cjs register --prd .prd/prd-vN.md [--change <id>] [--authorization <text>] [--replace] [--rebind]
|
|
7
7
|
// node scripts/pincer-runtime.cjs snapshot [--json] [--store]
|
|
8
8
|
// node scripts/pincer-runtime.cjs recover
|
|
9
|
-
// node scripts/pincer-runtime.cjs status [--json]
|
|
9
|
+
// node scripts/pincer-runtime.cjs status [--json] [--change <id>]
|
|
10
10
|
// node scripts/pincer-runtime.cjs ready [T-NN]
|
|
11
11
|
// node scripts/pincer-runtime.cjs start|verify|done T-NN · bind T-NN .prd/prd-vN.md
|
|
12
12
|
// node scripts/pincer-runtime.cjs migrate --preview|--apply --prd .prd/prd-vN.md [--change <id>] [--authorization <text>]
|
|
13
13
|
// node scripts/pincer-runtime.cjs check C-NN --candidate <sha> [--timeout <seconds>] -- <command...>
|
|
14
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>] [--brief] [--json]
|
|
20
|
+
// node scripts/pincer-runtime.cjs coverage [--change <id>] [--json] · coverage scaffold --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]
|
|
15
22
|
//
|
|
16
23
|
// Exit codes: 0 ok · 1 failed/not ready/refused · 2 usage · 3 state busy ·
|
|
17
24
|
// 4 invalid input or state · 124 timed out · 130 interrupted.
|
|
@@ -19,6 +26,7 @@ const fs = require('node:fs');
|
|
|
19
26
|
const path = require('node:path');
|
|
20
27
|
const { execFileSync } = require('node:child_process');
|
|
21
28
|
|
|
29
|
+
const io = require('./pincer-runtime/io.cjs');
|
|
22
30
|
const parse = require('./pincer-runtime/parse.cjs');
|
|
23
31
|
const identity = require('./pincer-runtime/identity.cjs');
|
|
24
32
|
const source = require('./pincer-runtime/source.cjs');
|
|
@@ -29,6 +37,16 @@ const sanitize = require('./pincer-runtime/sanitize.cjs');
|
|
|
29
37
|
const lifecycle = require('./pincer-runtime/lifecycle.cjs');
|
|
30
38
|
const migrate = require('./pincer-runtime/migrate.cjs');
|
|
31
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');
|
|
32
50
|
const { atomicWrite, nowIso, tryGit } = require('./pincer-runtime/fsutil.cjs');
|
|
33
51
|
const EXIT = { OK: 0, FAILED: 1, USAGE: 2, BUSY: 3, INVALID: 4, TIMED_OUT: 124, INTERRUPTED: 130 };
|
|
34
52
|
|
|
@@ -42,21 +60,44 @@ function repoRoot() {
|
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
function usage(message) {
|
|
45
|
-
if (message)
|
|
46
|
-
|
|
63
|
+
if (message) io.err(`pincer: ${message}\n`);
|
|
64
|
+
io.err('usage: pincer-runtime.cjs validate <file>... [--digests]\n' +
|
|
47
65
|
' pincer-runtime.cjs register --prd .prd/prd-vN.md [--change <id>] [--authorization <text>] [--replace] [--rebind]\n' +
|
|
48
66
|
' pincer-runtime.cjs snapshot [--json] [--store]\n' +
|
|
49
67
|
' pincer-runtime.cjs recover\n' +
|
|
50
|
-
' pincer-runtime.cjs status [--json]\n' +
|
|
68
|
+
' pincer-runtime.cjs status [--json] [--change <id>]\n' +
|
|
51
69
|
' pincer-runtime.cjs ready [T-NN]\n' +
|
|
52
70
|
' pincer-runtime.cjs start|verify|done T-NN\n' +
|
|
53
71
|
' pincer-runtime.cjs bind T-NN .prd/prd-vN.md\n' +
|
|
54
72
|
' pincer-runtime.cjs migrate --preview|--apply --prd .prd/prd-vN.md [--change <id>] [--authorization <text>]\n' +
|
|
55
73
|
' pincer-runtime.cjs check C-NN --candidate <sha> [--timeout <seconds>] -- <command...>\n' +
|
|
56
|
-
' pincer-runtime.cjs evidence export --candidate <sha> --base <sha> --prd .prd/prd-vN.md --draft <file>\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>] [--brief] [--json] (the read-only report; `change resume` is the lifecycle operation)\n' +
|
|
81
|
+
' pincer-runtime.cjs coverage [--change <id>] [--json] · coverage scaffold --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');
|
|
57
83
|
process.exit(EXIT.USAGE);
|
|
58
84
|
}
|
|
59
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
|
+
|
|
60
101
|
// The clean-view precondition for candidate checks and exports: HEAD is the
|
|
61
102
|
// candidate and nothing is dirty outside NOTES.md and the candidate's evidence
|
|
62
103
|
// directory. Never stashes, resets or commits.
|
|
@@ -64,7 +105,12 @@ function requireCandidateView(root, candidate, prd) {
|
|
|
64
105
|
const head = identity.head(root);
|
|
65
106
|
if (!head) fail('pincer', 'not a git repository with commits', EXIT.INVALID);
|
|
66
107
|
const version = prd.match(parse.PRD_REF)[1];
|
|
67
|
-
|
|
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);
|
|
68
114
|
if (head !== candidate) {
|
|
69
115
|
// A descendant that only adds NOTES.md and the candidate's evidence (the
|
|
70
116
|
// evaluate commit) is still a clean view of the candidate's source.
|
|
@@ -86,24 +132,48 @@ async function cmdCheck(root, args) {
|
|
|
86
132
|
const [checkId, ...command] = o.positional;
|
|
87
133
|
if (!checkId || !evidence.CHECK_ID.test(checkId)) usage('check requires a check ID such as C-01');
|
|
88
134
|
if (!o.candidate || !parse.HEX40.test(o.candidate)) usage('check requires --candidate <full 40-hex commit ID>');
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
151
|
requireCandidateView(root, o.candidate, b.prd);
|
|
96
|
-
const line =
|
|
97
|
-
const secretLine = sanitize.inlineSecretLine(
|
|
152
|
+
const line = commands.join('\n');
|
|
153
|
+
const secretLine = sanitize.inlineSecretLine(commands);
|
|
98
154
|
if (secretLine) fail('pincer', 'the check command assigns a secret-like literal; reference it from the environment instead', EXIT.INVALID);
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
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));
|
|
103
173
|
const a = result.attempt;
|
|
104
174
|
const logs = `${state.RUNTIME_DIR}/attempts/${a.id}/`;
|
|
105
|
-
if (a.outcome === 'passed')
|
|
106
|
-
else
|
|
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`);
|
|
107
177
|
process.exit(runner.exitFor(a));
|
|
108
178
|
}
|
|
109
179
|
|
|
@@ -115,8 +185,8 @@ function cmdEvidence(root, args) {
|
|
|
115
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>`);
|
|
116
186
|
if (!o.prd || !parse.PRD_REF.test(o.prd)) usage('evidence export requires --prd .prd/prd-vN.md');
|
|
117
187
|
if (!o.draft) usage('evidence export requires --draft <file>');
|
|
118
|
-
const
|
|
119
|
-
|
|
188
|
+
const b = candidateBinding(root, 'export', o.prd);
|
|
189
|
+
const bind = { binding: b };
|
|
120
190
|
requireCandidateView(root, o.candidate, o.prd);
|
|
121
191
|
let draft;
|
|
122
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); }
|
|
@@ -124,19 +194,43 @@ function cmdEvidence(root, args) {
|
|
|
124
194
|
if (indexRead.error) fail('pincer', indexRead.error, EXIT.INVALID);
|
|
125
195
|
const attemptsFor = checkId => {
|
|
126
196
|
if (!indexRead.index) return { attempt: null, pointed: null };
|
|
127
|
-
const key = state.contextKey({ kind: 'candidate', candidate: o.candidate, check: checkId });
|
|
197
|
+
const key = state.contextKey({ kind: 'candidate', change: b.change, candidate: o.candidate, check: checkId, mode: b.mode });
|
|
128
198
|
return { attempt: state.latestAttempt(root, key, indexRead.index), pointed: indexRead.index.current[key] || null };
|
|
129
199
|
};
|
|
130
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
|
+
}
|
|
131
216
|
const result = evidence.exportEvidence(root, {
|
|
132
217
|
candidate: o.candidate, base: o.base, prd: o.prd, draft, binding: bind.binding, attemptsFor,
|
|
133
|
-
environment: { os: `${os.platform()} ${os.release()}`, node: process.version }, now: nowIso(), atomicWrite,
|
|
218
|
+
environment: { os: `${os.platform()} ${os.release()}`, node: process.version }, now: nowIso(), atomicWrite, strict,
|
|
134
219
|
});
|
|
135
220
|
if (result.problems.length) {
|
|
136
|
-
for (const p of result.problems)
|
|
221
|
+
for (const p of result.problems) io.err(`evidence: ${result.manifest || o.draft}: ${p}\n`);
|
|
137
222
|
process.exit(EXIT.FAILED);
|
|
138
223
|
}
|
|
139
|
-
|
|
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
|
+
}
|
|
140
234
|
process.exit(EXIT.OK);
|
|
141
235
|
}
|
|
142
236
|
|
|
@@ -148,21 +242,22 @@ function cmdMigrate(root, args) {
|
|
|
148
242
|
const options = { prd: o.prd, change: o.change, authorization: o.authorization ?? null };
|
|
149
243
|
if (o.preview) {
|
|
150
244
|
const p = migrate.plan(root, options);
|
|
151
|
-
|
|
245
|
+
io.out(migrate.renderPlan(p));
|
|
152
246
|
process.exit(p.conflicts.length ? EXIT.FAILED : EXIT.OK);
|
|
153
247
|
}
|
|
154
248
|
const result = migrate.apply(root, options);
|
|
155
|
-
if (result.plan.conflicts.length) {
|
|
156
|
-
if (result.already) {
|
|
157
|
-
if (result.error) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
if (result.backupDir)
|
|
161
|
-
if (
|
|
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`);
|
|
162
257
|
process.exit(EXIT.OK);
|
|
163
258
|
}
|
|
164
259
|
|
|
165
|
-
const fail = (prefix, message, code = EXIT.INVALID) => {
|
|
260
|
+
const fail = (prefix, message, code = EXIT.INVALID) => { io.err(`${prefix}: ${message}\n`); process.exit(code); };
|
|
166
261
|
|
|
167
262
|
// start | verify | done | bind — both modes; refusals carry their prefix and exit code.
|
|
168
263
|
async function cmdLifecycle(root, command, args) {
|
|
@@ -175,7 +270,7 @@ async function cmdLifecycle(root, command, args) {
|
|
|
175
270
|
: command === 'start' ? lifecycle.start(root, o.positional[0])
|
|
176
271
|
: command === 'verify' ? await lifecycle.verify(root, o.positional[0])
|
|
177
272
|
: await lifecycle.done(root, o.positional[0]);
|
|
178
|
-
if (result.out)
|
|
273
|
+
if (result.out) io.out(result.out);
|
|
179
274
|
process.exit(result.exit ?? EXIT.OK);
|
|
180
275
|
} catch (error) {
|
|
181
276
|
if (error instanceof lifecycle.Refusal) fail(error.prefix, error.message, error.exit);
|
|
@@ -184,32 +279,38 @@ async function cmdLifecycle(root, command, args) {
|
|
|
184
279
|
}
|
|
185
280
|
|
|
186
281
|
function cmdStatus(root, args) {
|
|
187
|
-
const o = parseOptions(args, { switches: ['--json'] });
|
|
282
|
+
const o = parseOptions(args, { switches: ['--json'], valued: ['--change'] });
|
|
188
283
|
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
189
284
|
const budget = process.env.PINCER_BUILD_BUDGET_MIN || '';
|
|
190
|
-
const result = status.render(root, { budget });
|
|
191
|
-
if (o.json)
|
|
192
|
-
else
|
|
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);
|
|
193
288
|
process.exit(result.exit);
|
|
194
289
|
}
|
|
195
290
|
|
|
196
291
|
// Read-only readiness gate: a ticket, or the candidate when no ticket is named.
|
|
197
292
|
function cmdReady(root, args) {
|
|
198
|
-
const o = parseOptions(args, {});
|
|
293
|
+
const o = parseOptions(args, { valued: ['--change'] });
|
|
199
294
|
if (o.positional.length > 1) usage('ready takes at most one ticket ID');
|
|
200
|
-
const result = status.render(root, {});
|
|
201
|
-
if (result.exit !== 0) {
|
|
295
|
+
const result = status.render(root, { change: o.change || null });
|
|
296
|
+
if (result.exit !== 0) { io.err(result.text); process.exit(result.exit); }
|
|
202
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` });
|
|
203
302
|
if (o.positional.length === 1) {
|
|
204
303
|
const id = parse.normalizeId(o.positional[0]);
|
|
205
304
|
const ticket = id && j.tickets.find(t => t.id === id);
|
|
206
|
-
if (!ticket) {
|
|
207
|
-
if (ticket.readiness.ready) {
|
|
208
|
-
for (const r of
|
|
209
|
-
|
|
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`);
|
|
210
310
|
process.exit(EXIT.FAILED);
|
|
211
311
|
}
|
|
212
|
-
const blockers = [];
|
|
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` });
|
|
213
314
|
const localUnavailable = j.candidate && j.candidate.local_attempts === 'unavailable';
|
|
214
315
|
for (const t of j.tickets) {
|
|
215
316
|
if (t.status !== 'done') blockers.push({ code: 'EVIDENCE_MISSING', detail: `${t.id} is ${t.status}, not done` });
|
|
@@ -222,17 +323,24 @@ function cmdReady(root, args) {
|
|
|
222
323
|
if (!j.prd) blockers.push({ code: 'INPUT_INVALID', detail: 'no PRD' });
|
|
223
324
|
else if (j.prd.status !== 'built') blockers.push({ code: 'CANDIDATE_STALE', detail: `PRD status is '${j.prd.status}', expected 'built'` });
|
|
224
325
|
if (j.candidate) blockers.push(...j.candidate.reasons);
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
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`);
|
|
228
336
|
process.exit(EXIT.FAILED);
|
|
229
337
|
}
|
|
230
338
|
|
|
231
339
|
// Run a state operation, mapping the contracted failures to exit codes.
|
|
232
340
|
function guarded(fn) {
|
|
233
341
|
try { return fn(); } catch (error) {
|
|
234
|
-
if (error && error.code === 'STATE_BUSY') {
|
|
235
|
-
if (error && error.code === 'INVALID') {
|
|
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); }
|
|
236
344
|
throw error;
|
|
237
345
|
}
|
|
238
346
|
}
|
|
@@ -240,24 +348,33 @@ function guarded(fn) {
|
|
|
240
348
|
function cmdRecover(root, args) {
|
|
241
349
|
const o = parseOptions(args, {});
|
|
242
350
|
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
243
|
-
if (!state.exists(root)) {
|
|
351
|
+
if (!state.exists(root)) { io.out('nothing to recover: no local runtime state\n'); process.exit(EXIT.OK); }
|
|
244
352
|
const report = guarded(() => state.recover(root));
|
|
245
|
-
for (const
|
|
246
|
-
for (const
|
|
247
|
-
for (const
|
|
248
|
-
for (const id of report.
|
|
249
|
-
for (const
|
|
250
|
-
|
|
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');
|
|
251
363
|
process.exit(EXIT.OK);
|
|
252
364
|
}
|
|
253
365
|
|
|
254
366
|
// `--flag value` and `--switch` options; positional arguments keep their order.
|
|
255
|
-
function parseOptions(args, { valued = [], switches = [] } = {}) {
|
|
367
|
+
function parseOptions(args, { valued = [], switches = [], repeated = [] } = {}) {
|
|
256
368
|
const options = { positional: [] };
|
|
369
|
+
for (const arg of repeated) options[arg.slice(2)] = [];
|
|
257
370
|
for (let i = 0; i < args.length; i++) {
|
|
258
371
|
const arg = args[i];
|
|
259
372
|
if (arg === '--') { options.positional.push(...args.slice(i + 1)); break; }
|
|
260
|
-
if (
|
|
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)) {
|
|
261
378
|
const value = args[++i];
|
|
262
379
|
if (value === undefined) usage(`${arg} requires a value`);
|
|
263
380
|
options[arg.slice(2)] = value;
|
|
@@ -268,30 +385,245 @@ function parseOptions(args, { valued = [], switches = [] } = {}) {
|
|
|
268
385
|
return options;
|
|
269
386
|
}
|
|
270
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);
|
|
271
392
|
|
|
272
393
|
function cmdRegister(root, args) {
|
|
273
394
|
const o = parseOptions(args, { valued: ['--prd', '--change', '--authorization'], switches: ['--replace', '--rebind'] });
|
|
274
395
|
if (!o.prd) usage('register requires --prd .prd/prd-vN.md');
|
|
275
396
|
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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');
|
|
281
418
|
process.exit(EXIT.OK);
|
|
282
419
|
}
|
|
283
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
|
+
// coverage scaffold --change <id> [--json] — the read-only coverage draft
|
|
426
|
+
// (docs/runtime-contracts.md, "Coverage draft"). Writes nothing and launches nothing;
|
|
427
|
+
// the draft it prints is not a map and `coverage adopt` does not accept it.
|
|
428
|
+
if (sub === 'scaffold') {
|
|
429
|
+
const o = parseOptions(rest, { switches: ['--json'], valued: ['--change'] });
|
|
430
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]} (coverage scaffold takes --change <id> [--json])`);
|
|
431
|
+
if (!o.change) usage('coverage scaffold requires --change <id>');
|
|
432
|
+
const scaffold = require('./pincer-runtime/scaffold.cjs');
|
|
433
|
+
const resolved = changes.resolveSelected(root, { change: o.change });
|
|
434
|
+
if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
|
|
435
|
+
const result = scaffold.build(root, resolved.record);
|
|
436
|
+
if (!result.ok) fail('pincer', `${result.code}: ${result.problems[0]}`, exitForCode(result.code));
|
|
437
|
+
if (o.json) io.out(`${JSON.stringify(result.draft, null, 2)}\n`);
|
|
438
|
+
else io.out(scaffold.render(result.draft));
|
|
439
|
+
process.exit(EXIT.OK);
|
|
440
|
+
}
|
|
441
|
+
if (sub !== 'adopt') {
|
|
442
|
+
// The read-only coverage report (docs/runtime-contracts.md, "Coverage and impact commands").
|
|
443
|
+
const o = parseOptions(args, { switches: ['--json'], valued: ['--change'] });
|
|
444
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]} (coverage takes [--change <id>] [--json], or the adopt subcommand)`);
|
|
445
|
+
const phases = require('./pincer-runtime/phases.cjs');
|
|
446
|
+
const resolved = changes.resolveSelected(root, { change: o.change || null });
|
|
447
|
+
if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
|
|
448
|
+
const st = status.render(root, { change: o.change || null });
|
|
449
|
+
if (st.exit === 4) { io.err(st.text); process.exit(EXIT.INVALID); }
|
|
450
|
+
// A report asked for with --change may describe a change this worktree has not
|
|
451
|
+
// selected; no execution command it prints could run as written.
|
|
452
|
+
const selected = !resolved.selection || resolved.selection.change === resolved.record.change;
|
|
453
|
+
const result = phases.report(root, resolved.record, { gathered: st.gathered, generated: st.json.generated, selected });
|
|
454
|
+
if (o.json) io.out(`${JSON.stringify(result, null, 2)}\n`);
|
|
455
|
+
else io.out(phases.render(result));
|
|
456
|
+
process.exit(EXIT.OK);
|
|
457
|
+
}
|
|
458
|
+
const o = parseOptions(rest, { valued: ['--change', '--agreement'], switches: ['--preview', '--apply'] });
|
|
459
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
460
|
+
if (!o.change) usage('coverage adopt requires --change <id>');
|
|
461
|
+
if (Boolean(o.preview) === Boolean(o.apply)) usage('coverage adopt requires exactly one of --preview or --apply');
|
|
462
|
+
if (o.agreement !== undefined && !/^[0-9a-f]{64}$/.test(o.agreement)) usage('--agreement must be the 64-hex agreement digest the preview printed');
|
|
463
|
+
if (o.preview) {
|
|
464
|
+
const p = adopt.plan(root, { change: o.change });
|
|
465
|
+
io.out(adopt.renderPlan(p));
|
|
466
|
+
process.exit(p.conflicts.length ? EXIT.FAILED : EXIT.OK);
|
|
467
|
+
}
|
|
468
|
+
const result = adopt.apply(root, { change: o.change, agreement: o.agreement ?? null });
|
|
469
|
+
if (result.plan.conflicts.length) { io.out(adopt.renderPlan(result.plan)); process.exit(EXIT.FAILED); }
|
|
470
|
+
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); }
|
|
471
|
+
if (result.error) { io.err(`pincer: ${result.code}: adoption refused; nothing was written: ${result.error}\n`); process.exit(exitForCode(result.code)); }
|
|
472
|
+
const r = result.record, p = result.plan;
|
|
473
|
+
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`);
|
|
474
|
+
io.out(`backup: ${result.backup} (rollback per docs/runtime-contracts.md, "Adoption and rollback")\n`);
|
|
475
|
+
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`);
|
|
476
|
+
process.exit(EXIT.OK);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// impact [--change <id>] [--from G-NN|A-NN] [--json] — read-only structural differences
|
|
480
|
+
// against a retained agreement (docs/runtime-contracts.md, "Coverage and impact commands").
|
|
481
|
+
function cmdImpact(root, args) {
|
|
482
|
+
const o = parseOptions(args, { switches: ['--json'], valued: ['--change', '--from'] });
|
|
483
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
484
|
+
const resolved = changes.resolveSelected(root, { change: o.change || null });
|
|
485
|
+
if (resolved.code) fail('pincer', `${resolved.code}: ${resolved.problem}`, exitForCode(resolved.code));
|
|
486
|
+
const result = impact.compute(root, resolved.record, { from: o.from || null });
|
|
487
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
488
|
+
if (o.json) io.out(`${JSON.stringify(result, null, 2)}\n`);
|
|
489
|
+
else io.out(impact.render(result));
|
|
490
|
+
process.exit(EXIT.OK);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// resume [--change <id>] [--json] — the read-only resume report (never the lifecycle operation).
|
|
494
|
+
function cmdResume(root, args) {
|
|
495
|
+
const o = parseOptions(args, { switches: ['--json', '--brief'], valued: ['--change'] });
|
|
496
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
497
|
+
const result = resume.build(root, { change: o.change || null });
|
|
498
|
+
// --brief is a projection of the same computed report: same verdict, same next
|
|
499
|
+
// action, same blocker categories, fewer bytes (docs/runtime-contracts.md, "Brief
|
|
500
|
+
// resume"). It never recomputes a decision and never changes the exit code.
|
|
501
|
+
if (o.brief) {
|
|
502
|
+
const brief = resume.brief(result.json);
|
|
503
|
+
if (o.json) io.out(`${JSON.stringify(brief, null, 2)}\n`);
|
|
504
|
+
else io.out(resume.renderBrief(brief));
|
|
505
|
+
process.exit(result.exit);
|
|
506
|
+
}
|
|
507
|
+
if (o.json) io.out(`${JSON.stringify(result.json, null, 2)}\n`);
|
|
508
|
+
else io.out(result.text);
|
|
509
|
+
process.exit(result.exit);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The current agreement of a record against its recorded entries (read-only).
|
|
513
|
+
function agreementNow(root, record) {
|
|
514
|
+
const now = agreement.compute(root, record);
|
|
515
|
+
if (now.code) return now;
|
|
516
|
+
const entry = agreement.entryFor(record, now.digest);
|
|
517
|
+
const latest = agreement.latestEntry(record);
|
|
518
|
+
let difference = null, rendered = null;
|
|
519
|
+
if (!entry && latest) {
|
|
520
|
+
const snap = agreement.readSnapshot(root, record, latest);
|
|
521
|
+
if (!snap.code) { difference = agreement.difference(snap.snapshot, now); rendered = agreement.renderDifference(difference); }
|
|
522
|
+
}
|
|
523
|
+
return { digest: now.digest, entry, latest, difference, rendered };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// change list | show | select | revise — inspection and local/authored records.
|
|
527
|
+
function cmdChange(root, args) {
|
|
528
|
+
const [sub, ...rest] = args;
|
|
529
|
+
if (sub === 'list') {
|
|
530
|
+
const o = parseOptions(rest, { switches: ['--json'] });
|
|
531
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
532
|
+
const result = changes.list(root);
|
|
533
|
+
const loaded = changes.loadRecords(root);
|
|
534
|
+
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; } }
|
|
535
|
+
const sel = changes.readSelection(root);
|
|
536
|
+
const selection = sel.code ? { change: null, problem: { code: sel.code, detail: sel.problem } } : { change: sel.change, problem: null };
|
|
537
|
+
result.changes.forEach(c => { c.selected = Boolean(sel.change) && sel.change === c.id; });
|
|
538
|
+
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`);
|
|
539
|
+
else io.out(changes.renderList(result, { selection: sel.code ? null : sel }));
|
|
540
|
+
process.exit(result.problems.length ? EXIT.INVALID : EXIT.OK);
|
|
541
|
+
}
|
|
542
|
+
if (sub === 'select') {
|
|
543
|
+
const o = parseOptions(rest, {});
|
|
544
|
+
if (o.positional.length !== 1) usage('change select requires exactly one change ID');
|
|
545
|
+
const result = changes.select(root, o.positional[0]);
|
|
546
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
547
|
+
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`);
|
|
548
|
+
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');
|
|
549
|
+
process.exit(EXIT.OK);
|
|
550
|
+
}
|
|
551
|
+
if (sub === 'show') {
|
|
552
|
+
const o = parseOptions(rest, { switches: ['--json'] });
|
|
553
|
+
if (o.positional.length !== 1) usage('change show requires exactly one change ID');
|
|
554
|
+
const result = changes.loadRecord(root, o.positional[0]);
|
|
555
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
556
|
+
const sel = changes.readSelection(root);
|
|
557
|
+
const now = agreementNow(root, result.record);
|
|
558
|
+
const v = authorization.verdict(root, result.record);
|
|
559
|
+
const loc = locator.read(root, o.positional[0]);
|
|
560
|
+
const evaluations = loc.code ? [] : loc.locator.evaluations;
|
|
561
|
+
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`);
|
|
562
|
+
else io.out(changes.renderShow(o.positional[0], result, { agreement: now, verdict: v, evaluations: loc.code ? [] : evaluations, locatorProblem: loc.code ? loc.problem : null }));
|
|
563
|
+
process.exit(EXIT.OK);
|
|
564
|
+
}
|
|
565
|
+
if (transitions.OPS.includes(sub)) {
|
|
566
|
+
const o = parseOptions(rest, { valued: ['--reason', '--note', '--decision', '--with'] });
|
|
567
|
+
if (o.positional.length !== 1) usage(`change ${sub} requires exactly one change ID`);
|
|
568
|
+
const id = o.positional[0];
|
|
569
|
+
const result = transitions.transition(root, id, sub, { reason: o.reason, note: o.note, decision: o.decision, with: o.with });
|
|
570
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
571
|
+
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); }
|
|
572
|
+
const e = result.event;
|
|
573
|
+
const past = { activate: 'activated', pause: 'paused', resume: 'resumed', complete: 'completed', reopen: 'reopened', cancel: 'cancelled', supersede: 'superseded' }[sub];
|
|
574
|
+
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`);
|
|
575
|
+
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');
|
|
576
|
+
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');
|
|
577
|
+
process.exit(EXIT.OK);
|
|
578
|
+
}
|
|
579
|
+
if (sub === 'authorize') {
|
|
580
|
+
const o = parseOptions(rest, { valued: ['--agreement', '--reference', '--excerpt', '--constraints', '--basis', '--explanation'], switches: ['--delegated'], repeated: ['--decision'] });
|
|
581
|
+
if (o.positional.length !== 1) usage('change authorize requires exactly one change ID');
|
|
582
|
+
if (!o.agreement) usage('change authorize requires --agreement <digest>');
|
|
583
|
+
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 });
|
|
584
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
585
|
+
const a = result.authorization;
|
|
586
|
+
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`);
|
|
587
|
+
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`);
|
|
588
|
+
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`);
|
|
589
|
+
process.exit(EXIT.OK);
|
|
590
|
+
}
|
|
591
|
+
if (sub === 'decide') {
|
|
592
|
+
const o = parseOptions(rest, { valued: ['--summary', '--id', '--resolve', '--reference', '--excerpt'] });
|
|
593
|
+
if (o.positional.length !== 1) usage('change decide requires exactly one change ID');
|
|
594
|
+
if (!o.resolve && !o.summary) usage('change decide requires --summary <text> (raise) or --resolve D-NN --reference <text> --excerpt <text>');
|
|
595
|
+
const result = authorization.decide(root, o.positional[0], { summary: o.summary, id: o.id, resolve: o.resolve, reference: o.reference, excerpt: o.excerpt });
|
|
596
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
597
|
+
const d = result.decision;
|
|
598
|
+
if (result.action === 'unchanged') io.out(`unchanged: ${d.id} is already ${d.status} (${d.summary}); nothing written\n`);
|
|
599
|
+
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`); }
|
|
600
|
+
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`); }
|
|
601
|
+
process.exit(EXIT.OK);
|
|
602
|
+
}
|
|
603
|
+
if (sub === 'revise') {
|
|
604
|
+
const o = parseOptions(rest, {});
|
|
605
|
+
if (o.positional.length !== 1) usage('change revise requires exactly one change ID');
|
|
606
|
+
const result = agreement.revise(root, o.positional[0]);
|
|
607
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, exitForCode(result.code));
|
|
608
|
+
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`);
|
|
609
|
+
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`);
|
|
610
|
+
if (result.action === 'recorded') io.err('pincer: note: recording an agreement authorizes nothing; record its disposition with `change authorize`\n');
|
|
611
|
+
process.exit(EXIT.OK);
|
|
612
|
+
}
|
|
613
|
+
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');
|
|
614
|
+
}
|
|
615
|
+
|
|
284
616
|
function cmdSnapshot(root, args) {
|
|
285
617
|
const o = parseOptions(args, { switches: ['--json', '--store'] });
|
|
286
618
|
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
287
619
|
const manifest = source.snapshot(root);
|
|
288
|
-
for (const problem of manifest.problems)
|
|
620
|
+
for (const problem of manifest.problems) io.err(`pincer: ${problem.code}: ${problem.detail}\n`);
|
|
289
621
|
if (manifest.problems.length) process.exit(EXIT.INVALID);
|
|
290
622
|
if (o.store) source.storeManifest(root, manifest);
|
|
291
|
-
if (o.json)
|
|
623
|
+
if (o.json) io.out(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
292
624
|
else {
|
|
293
|
-
|
|
294
|
-
for (const l of manifest.limitations)
|
|
625
|
+
io.out(`digest ${manifest.digest}\nfiles ${manifest.files.length}\nexcluded ${manifest.excluded.length}\n`);
|
|
626
|
+
for (const l of manifest.limitations) io.err(`pincer: limitation: ${l}\n`);
|
|
295
627
|
}
|
|
296
628
|
process.exit(EXIT.OK);
|
|
297
629
|
}
|
|
@@ -302,7 +634,7 @@ function cmdValidate(root, args) {
|
|
|
302
634
|
if (files.some(arg => arg.startsWith('--'))) usage(`unknown option ${files.find(arg => arg.startsWith('--'))}`);
|
|
303
635
|
if (files.length === 0) usage('validate requires at least one file');
|
|
304
636
|
let invalid = false;
|
|
305
|
-
const report = (prefix, file, problems) => { for (const p of problems)
|
|
637
|
+
const report = (prefix, file, problems) => { for (const p of problems) io.err(`${prefix}: ${file}: ${p}\n`); invalid ||= problems.length > 0; };
|
|
306
638
|
for (const file of files) {
|
|
307
639
|
const relative = path.isAbsolute(file) ? path.relative(root, file) : file;
|
|
308
640
|
const base = path.basename(relative);
|
|
@@ -312,13 +644,13 @@ function cmdValidate(root, args) {
|
|
|
312
644
|
const result = parse.validateTicket(relative, text);
|
|
313
645
|
report('pincer-ticket', relative, result.problems);
|
|
314
646
|
if (result.ok && digests) {
|
|
315
|
-
|
|
316
|
-
|
|
647
|
+
io.out(`ticket ${parse.ticketDigest(text)}\n`);
|
|
648
|
+
io.out(`check ${parse.checkDigest(text, result.timeout)}\n`);
|
|
317
649
|
}
|
|
318
650
|
} else if (parse.PRD_REF.test(relative)) {
|
|
319
651
|
const result = parse.validatePrd(root, relative);
|
|
320
652
|
report('pincer', relative, result.problems);
|
|
321
|
-
if (result.ok && digests)
|
|
653
|
+
if (result.ok && digests) io.out(`prd ${parse.prdDigest(result.text)}\n`);
|
|
322
654
|
} else {
|
|
323
655
|
let text;
|
|
324
656
|
try { text = fs.readFileSync(path.resolve(root, relative), 'utf8'); } catch { report('pincer', relative, ['no such file']); continue; }
|
|
@@ -341,10 +673,14 @@ function main(argv) {
|
|
|
341
673
|
if (command === 'migrate') return cmdMigrate(root, rest);
|
|
342
674
|
if (command === 'check') return cmdCheck(root, rest);
|
|
343
675
|
if (command === 'evidence') return cmdEvidence(root, rest);
|
|
676
|
+
if (command === 'change') return cmdChange(root, rest);
|
|
677
|
+
if (command === 'resume') return cmdResume(root, rest);
|
|
678
|
+
if (command === 'coverage') return cmdCoverage(root, rest);
|
|
679
|
+
if (command === 'impact') return cmdImpact(root, rest);
|
|
344
680
|
usage(command ? `unknown command ${command}` : undefined);
|
|
345
681
|
}
|
|
346
682
|
|
|
347
683
|
Promise.resolve(main(process.argv.slice(2))).catch(error => {
|
|
348
|
-
|
|
684
|
+
io.err(`pincer: unexpected error: ${error && error.stack ? error.stack : error}\n`);
|
|
349
685
|
process.exit(EXIT.INVALID);
|
|
350
686
|
});
|