pincer-workflow 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/bin/pincer.js +42 -5
- package/package.json +2 -2
- package/template/.agents/skills/pincer-code/SKILL.md +51 -4
- package/template/.agents/skills/pincer-evaluate/SKILL.md +29 -2
- package/template/.agents/skills/pincer-narrow/SKILL.md +11 -2
- package/template/.agents/skills/pincer-plan/SKILL.md +9 -1
- package/template/.agents/skills/pincer-release/SKILL.md +14 -3
- package/template/.agents/skills/pincer-status/SKILL.md +17 -2
- package/template/.claude/commands/pincer-code.md +51 -4
- package/template/.claude/commands/pincer-evaluate.md +29 -2
- package/template/.claude/commands/pincer-narrow.md +11 -2
- package/template/.claude/commands/pincer-plan.md +9 -1
- package/template/.claude/commands/pincer-release.md +14 -3
- package/template/.claude/commands/pincer-status.md +17 -2
- package/template/.claude/hooks/hook-policy.cjs +17 -3
- package/template/.claude/references/ticket-template.md +4 -0
- package/template/.codex/README.md +3 -2
- package/template/.github/prompts/pincer-code.prompt.md +51 -4
- package/template/.github/prompts/pincer-evaluate.prompt.md +29 -2
- package/template/.github/prompts/pincer-narrow.prompt.md +11 -2
- package/template/.github/prompts/pincer-plan.prompt.md +9 -1
- package/template/.github/prompts/pincer-release.prompt.md +14 -3
- package/template/.github/prompts/pincer-status.prompt.md +17 -2
- package/template/AGENTS.md +6 -0
- package/template/docs/dry-run-checklist.md +46 -3
- package/template/docs/release-checklist.md +2 -1
- package/template/docs/runtime-contracts.md +437 -0
- package/template/scripts/pincer-evidence.cjs +5 -223
- package/template/scripts/pincer-runtime/evidence.cjs +391 -0
- package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
- package/template/scripts/pincer-runtime/identity.cjs +146 -0
- package/template/scripts/pincer-runtime/lifecycle.cjs +289 -0
- package/template/scripts/pincer-runtime/migrate.cjs +127 -0
- package/template/scripts/pincer-runtime/parse.cjs +297 -0
- package/template/scripts/pincer-runtime/readiness.cjs +89 -0
- package/template/scripts/pincer-runtime/runner.cjs +224 -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 +292 -0
- package/template/scripts/pincer-runtime/status.cjs +358 -0
- package/template/scripts/pincer-runtime.cjs +350 -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,350 @@
|
|
|
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]
|
|
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
|
+
//
|
|
16
|
+
// Exit codes: 0 ok · 1 failed/not ready/refused · 2 usage · 3 state busy ·
|
|
17
|
+
// 4 invalid input or state · 124 timed out · 130 interrupted.
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { execFileSync } = require('node:child_process');
|
|
21
|
+
|
|
22
|
+
const parse = require('./pincer-runtime/parse.cjs');
|
|
23
|
+
const identity = require('./pincer-runtime/identity.cjs');
|
|
24
|
+
const source = require('./pincer-runtime/source.cjs');
|
|
25
|
+
const state = require('./pincer-runtime/state.cjs');
|
|
26
|
+
const status = require('./pincer-runtime/status.cjs');
|
|
27
|
+
const runner = require('./pincer-runtime/runner.cjs');
|
|
28
|
+
const sanitize = require('./pincer-runtime/sanitize.cjs');
|
|
29
|
+
const lifecycle = require('./pincer-runtime/lifecycle.cjs');
|
|
30
|
+
const migrate = require('./pincer-runtime/migrate.cjs');
|
|
31
|
+
const evidence = require('./pincer-runtime/evidence.cjs');
|
|
32
|
+
const { atomicWrite, nowIso, tryGit } = require('./pincer-runtime/fsutil.cjs');
|
|
33
|
+
const EXIT = { OK: 0, FAILED: 1, USAGE: 2, BUSY: 3, INVALID: 4, TIMED_OUT: 124, INTERRUPTED: 130 };
|
|
34
|
+
|
|
35
|
+
function repoRoot() {
|
|
36
|
+
if (process.env.CLAUDE_PROJECT_DIR) return path.resolve(process.env.CLAUDE_PROJECT_DIR);
|
|
37
|
+
try {
|
|
38
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
39
|
+
} catch {
|
|
40
|
+
return process.cwd();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function usage(message) {
|
|
45
|
+
if (message) process.stderr.write(`pincer: ${message}\n`);
|
|
46
|
+
process.stderr.write('usage: pincer-runtime.cjs validate <file>... [--digests]\n' +
|
|
47
|
+
' pincer-runtime.cjs register --prd .prd/prd-vN.md [--change <id>] [--authorization <text>] [--replace] [--rebind]\n' +
|
|
48
|
+
' pincer-runtime.cjs snapshot [--json] [--store]\n' +
|
|
49
|
+
' pincer-runtime.cjs recover\n' +
|
|
50
|
+
' pincer-runtime.cjs status [--json]\n' +
|
|
51
|
+
' pincer-runtime.cjs ready [T-NN]\n' +
|
|
52
|
+
' pincer-runtime.cjs start|verify|done T-NN\n' +
|
|
53
|
+
' pincer-runtime.cjs bind T-NN .prd/prd-vN.md\n' +
|
|
54
|
+
' pincer-runtime.cjs migrate --preview|--apply --prd .prd/prd-vN.md [--change <id>] [--authorization <text>]\n' +
|
|
55
|
+
' 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');
|
|
57
|
+
process.exit(EXIT.USAGE);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The clean-view precondition for candidate checks and exports: HEAD is the
|
|
61
|
+
// candidate and nothing is dirty outside NOTES.md and the candidate's evidence
|
|
62
|
+
// directory. Never stashes, resets or commits.
|
|
63
|
+
function requireCandidateView(root, candidate, prd) {
|
|
64
|
+
const head = identity.head(root);
|
|
65
|
+
if (!head) fail('pincer', 'not a git repository with commits', EXIT.INVALID);
|
|
66
|
+
const version = prd.match(parse.PRD_REF)[1];
|
|
67
|
+
const allowed = p => p === 'NOTES.md' || p.startsWith(`.prd/evidence/prd-v${version}/${candidate}/`);
|
|
68
|
+
if (head !== candidate) {
|
|
69
|
+
// A descendant that only adds NOTES.md and the candidate's evidence (the
|
|
70
|
+
// evaluate commit) is still a clean view of the candidate's source.
|
|
71
|
+
const ancestor = tryGit(root, ['merge-base', '--is-ancestor', candidate, 'HEAD']);
|
|
72
|
+
const diff = ancestor.error ? { error: 'not an ancestor' } : tryGit(root, ['diff', '--name-only', candidate, 'HEAD']);
|
|
73
|
+
const differing = diff.error ? null : diff.out.split('\n').filter(Boolean).filter(p => !allowed(p));
|
|
74
|
+
if (!differing || differing.length) {
|
|
75
|
+
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);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const dirty = tryGit(root, ['status', '--porcelain', '--untracked-files=all']);
|
|
79
|
+
if (dirty.error) fail('pincer', `git status failed: ${dirty.error}`, EXIT.INVALID);
|
|
80
|
+
const offending = dirty.out.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^"(.*)"$/, '$1')).filter(p => !allowed(p));
|
|
81
|
+
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);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function cmdCheck(root, args) {
|
|
85
|
+
const o = parseOptions(args, { valued: ['--candidate', '--timeout'] });
|
|
86
|
+
const [checkId, ...command] = o.positional;
|
|
87
|
+
if (!checkId || !evidence.CHECK_ID.test(checkId)) usage('check requires a check ID such as C-01');
|
|
88
|
+
if (!o.candidate || !parse.HEX40.test(o.candidate)) usage('check requires --candidate <full 40-hex commit ID>');
|
|
89
|
+
if (!command.length) usage('check requires the command after --');
|
|
90
|
+
const timeout = o.timeout === undefined ? parse.DEFAULT_TIMEOUT : Number(o.timeout);
|
|
91
|
+
if (!Number.isInteger(timeout) || timeout <= 0) usage('--timeout must be a positive integer number of seconds');
|
|
92
|
+
const bind = identity.loadBinding(root);
|
|
93
|
+
if (bind.code) fail('pincer', `${bind.code}: ${bind.problem}`, EXIT.INVALID);
|
|
94
|
+
const b = bind.binding;
|
|
95
|
+
requireCandidateView(root, o.candidate, b.prd);
|
|
96
|
+
const line = command.join(' ');
|
|
97
|
+
const secretLine = sanitize.inlineSecretLine([line]);
|
|
98
|
+
if (secretLine) fail('pincer', 'the check command assigns a secret-like literal; reference it from the environment instead', EXIT.INVALID);
|
|
99
|
+
process.stdout.write(`── ${checkId} candidate ${o.candidate.slice(0, 7)} ──\n $ ${sanitize.sanitizeText(line).text}\n`);
|
|
100
|
+
const context = { kind: 'candidate', change: b.change, prd: b.prd, prd_revision: b.prd_revision, base: b.base, candidate: o.candidate, check: checkId };
|
|
101
|
+
const result = await runner.runAttempt({ root, context, commands: [line], timeoutSeconds: timeout, command: `check ${checkId}` });
|
|
102
|
+
if (result.code) fail('pincer', `${result.code}: ${result.problem}`, problemExit(result.code));
|
|
103
|
+
const a = result.attempt;
|
|
104
|
+
const logs = `${state.RUNTIME_DIR}/attempts/${a.id}/`;
|
|
105
|
+
if (a.outcome === 'passed') process.stdout.write(`✓ ${checkId} passed — attempt ${a.id} (source ${a.source.after.slice(0, 12)}, logs ${logs})\n`);
|
|
106
|
+
else process.stderr.write(`✗ ${checkId} ${a.outcome}${a.exit_code !== null ? ` (exit ${a.exit_code})` : ''}${a.error ? `: ${a.error}` : ''} — attempt ${a.id} (logs ${logs})\n`);
|
|
107
|
+
process.exit(runner.exitFor(a));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cmdEvidence(root, args) {
|
|
111
|
+
const [sub, ...rest] = args;
|
|
112
|
+
if (sub !== 'export') usage('evidence supports: export');
|
|
113
|
+
const o = parseOptions(rest, { valued: ['--candidate', '--base', '--prd', '--draft'] });
|
|
114
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
115
|
+
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
|
+
if (!o.prd || !parse.PRD_REF.test(o.prd)) usage('evidence export requires --prd .prd/prd-vN.md');
|
|
117
|
+
if (!o.draft) usage('evidence export requires --draft <file>');
|
|
118
|
+
const bind = identity.loadBinding(root, { prd: o.prd });
|
|
119
|
+
if (bind.code) fail('pincer', `${bind.code}: ${bind.problem}`, EXIT.INVALID);
|
|
120
|
+
requireCandidateView(root, o.candidate, o.prd);
|
|
121
|
+
let draft;
|
|
122
|
+
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); }
|
|
123
|
+
const indexRead = state.exists(root) ? state.readIndex(root) : { index: null };
|
|
124
|
+
if (indexRead.error) fail('pincer', indexRead.error, EXIT.INVALID);
|
|
125
|
+
const attemptsFor = checkId => {
|
|
126
|
+
if (!indexRead.index) return { attempt: null, pointed: null };
|
|
127
|
+
const key = state.contextKey({ kind: 'candidate', candidate: o.candidate, check: checkId });
|
|
128
|
+
return { attempt: state.latestAttempt(root, key, indexRead.index), pointed: indexRead.index.current[key] || null };
|
|
129
|
+
};
|
|
130
|
+
const os = require('node:os');
|
|
131
|
+
const result = evidence.exportEvidence(root, {
|
|
132
|
+
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,
|
|
134
|
+
});
|
|
135
|
+
if (result.problems.length) {
|
|
136
|
+
for (const p of result.problems) process.stderr.write(`evidence: ${result.manifest || o.draft}: ${p}\n`);
|
|
137
|
+
process.exit(EXIT.FAILED);
|
|
138
|
+
}
|
|
139
|
+
process.stdout.write(`exported ${result.manifest} (schema 2) — validate: node scripts/pincer-evidence.cjs validate ${result.manifest} --candidate ${o.candidate} --prd ${o.prd}\n`);
|
|
140
|
+
process.exit(EXIT.OK);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function cmdMigrate(root, args) {
|
|
144
|
+
const o = parseOptions(args, { valued: ['--prd', '--change', '--authorization'], switches: ['--preview', '--apply'] });
|
|
145
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
146
|
+
if (!o.prd) usage('migrate requires --prd .prd/prd-vN.md');
|
|
147
|
+
if (Boolean(o.preview) === Boolean(o.apply)) usage('migrate requires exactly one of --preview or --apply');
|
|
148
|
+
const options = { prd: o.prd, change: o.change, authorization: o.authorization ?? null };
|
|
149
|
+
if (o.preview) {
|
|
150
|
+
const p = migrate.plan(root, options);
|
|
151
|
+
process.stdout.write(migrate.renderPlan(p));
|
|
152
|
+
process.exit(p.conflicts.length ? EXIT.FAILED : EXIT.OK);
|
|
153
|
+
}
|
|
154
|
+
const result = migrate.apply(root, options);
|
|
155
|
+
if (result.plan.conflicts.length) { process.stdout.write(migrate.renderPlan(result.plan)); process.exit(EXIT.FAILED); }
|
|
156
|
+
if (result.already) { process.stdout.write(`already migrated: ${o.prd} is bound as change ${result.plan.change}; nothing changed\n`); process.exit(EXIT.OK); }
|
|
157
|
+
if (result.error) { process.stderr.write(`pincer: migration stopped before the binding was written: ${result.error}\n`); process.exit(EXIT.INVALID); }
|
|
158
|
+
const b = result.binding;
|
|
159
|
+
process.stdout.write(`migrated ${o.prd} → change ${b.change} (revision ${b.prd_revision.slice(0, 12)}, base ${b.base.slice(0, 7)}, ${Object.keys(result.plan.tickets.length ? b.legacy_receipts : {}).length || result.plan.tickets.length} ticket(s) rewritten)\n`);
|
|
160
|
+
if (result.backupDir) process.stdout.write(`backups: ${result.backupDir} (${result.backups.length} file(s)); rollback per docs/runtime-contracts.md\n`);
|
|
161
|
+
if (!o.authorization) process.stderr.write('pincer: note: no --authorization recorded; migration does not prove human approval\n');
|
|
162
|
+
process.exit(EXIT.OK);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const fail = (prefix, message, code = EXIT.INVALID) => { process.stderr.write(`${prefix}: ${message}\n`); process.exit(code); };
|
|
166
|
+
|
|
167
|
+
// start | verify | done | bind — both modes; refusals carry their prefix and exit code.
|
|
168
|
+
async function cmdLifecycle(root, command, args) {
|
|
169
|
+
const usageLine = { start: 'start T-NN', verify: 'verify T-NN', done: 'done T-NN', bind: 'bind T-NN .prd/prd-vN.md' }[command];
|
|
170
|
+
const expected = command === 'bind' ? 2 : 1;
|
|
171
|
+
const o = parseOptions(args, {});
|
|
172
|
+
if (o.positional.length !== expected) usage(`usage: pincer-runtime.cjs ${usageLine}`);
|
|
173
|
+
try {
|
|
174
|
+
const result = command === 'bind' ? lifecycle.bind(root, o.positional[0], o.positional[1])
|
|
175
|
+
: command === 'start' ? lifecycle.start(root, o.positional[0])
|
|
176
|
+
: command === 'verify' ? await lifecycle.verify(root, o.positional[0])
|
|
177
|
+
: await lifecycle.done(root, o.positional[0]);
|
|
178
|
+
if (result.out) process.stdout.write(result.out);
|
|
179
|
+
process.exit(result.exit ?? EXIT.OK);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof lifecycle.Refusal) fail(error.prefix, error.message, error.exit);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function cmdStatus(root, args) {
|
|
187
|
+
const o = parseOptions(args, { switches: ['--json'] });
|
|
188
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
189
|
+
const budget = process.env.PINCER_BUILD_BUDGET_MIN || '';
|
|
190
|
+
const result = status.render(root, { budget });
|
|
191
|
+
if (o.json) process.stdout.write(`${JSON.stringify(result.json, null, 2)}\n`);
|
|
192
|
+
else process.stdout.write(result.text);
|
|
193
|
+
process.exit(result.exit);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Read-only readiness gate: a ticket, or the candidate when no ticket is named.
|
|
197
|
+
function cmdReady(root, args) {
|
|
198
|
+
const o = parseOptions(args, {});
|
|
199
|
+
if (o.positional.length > 1) usage('ready takes at most one ticket ID');
|
|
200
|
+
const result = status.render(root, {});
|
|
201
|
+
if (result.exit !== 0) { process.stderr.write(result.text); process.exit(result.exit); }
|
|
202
|
+
const j = result.json;
|
|
203
|
+
if (o.positional.length === 1) {
|
|
204
|
+
const id = parse.normalizeId(o.positional[0]);
|
|
205
|
+
const ticket = id && j.tickets.find(t => t.id === id);
|
|
206
|
+
if (!ticket) { process.stderr.write(`pincer: no ticket ${o.positional[0]} is associated with the selected PRD\n`); process.exit(EXIT.INVALID); }
|
|
207
|
+
if (ticket.readiness.ready) { process.stdout.write(`ready ${id}\n`); process.exit(EXIT.OK); }
|
|
208
|
+
for (const r of ticket.readiness.reasons) process.stdout.write(`not ready ${id}: ${r.code} ${r.detail}\n`);
|
|
209
|
+
process.stdout.write(`next: ${ticket.readiness.next}\n`);
|
|
210
|
+
process.exit(EXIT.FAILED);
|
|
211
|
+
}
|
|
212
|
+
const blockers = [];
|
|
213
|
+
const localUnavailable = j.candidate && j.candidate.local_attempts === 'unavailable';
|
|
214
|
+
for (const t of j.tickets) {
|
|
215
|
+
if (t.status !== 'done') blockers.push({ code: 'EVIDENCE_MISSING', detail: `${t.id} is ${t.status}, not done` });
|
|
216
|
+
else for (const r of t.readiness.reasons) {
|
|
217
|
+
// A fresh clone validates the saved candidate record only; missing local attempts are its stated limit, not a blocker.
|
|
218
|
+
if (localUnavailable && r.code === 'EVIDENCE_MISSING') continue;
|
|
219
|
+
blockers.push({ code: r.code, detail: `${t.id}: ${r.detail}` });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!j.prd) blockers.push({ code: 'INPUT_INVALID', detail: 'no PRD' });
|
|
223
|
+
else if (j.prd.status !== 'built') blockers.push({ code: 'CANDIDATE_STALE', detail: `PRD status is '${j.prd.status}', expected 'built'` });
|
|
224
|
+
if (j.candidate) blockers.push(...j.candidate.reasons);
|
|
225
|
+
if (!blockers.length) { process.stdout.write(`ready candidate ${j.candidate.candidate}\n`); process.exit(EXIT.OK); }
|
|
226
|
+
for (const b of blockers) process.stdout.write(`not ready: ${b.code} ${b.detail}\n`);
|
|
227
|
+
process.stdout.write(`next: ${j.next}\n`);
|
|
228
|
+
process.exit(EXIT.FAILED);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Run a state operation, mapping the contracted failures to exit codes.
|
|
232
|
+
function guarded(fn) {
|
|
233
|
+
try { return fn(); } catch (error) {
|
|
234
|
+
if (error && error.code === 'STATE_BUSY') { process.stderr.write(`pincer: STATE_BUSY: ${error.message}\n`); process.exit(EXIT.BUSY); }
|
|
235
|
+
if (error && error.code === 'INVALID') { process.stderr.write(`pincer: ${error.message}\n`); process.exit(EXIT.INVALID); }
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function cmdRecover(root, args) {
|
|
241
|
+
const o = parseOptions(args, {});
|
|
242
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
243
|
+
if (!state.exists(root)) { process.stdout.write('nothing to recover: no local runtime state\n'); process.exit(EXIT.OK); }
|
|
244
|
+
const report = guarded(() => state.recover(root));
|
|
245
|
+
for (const id of report.finalized) process.stdout.write(`finalized ${id} as interrupted (owner no longer running)\n`);
|
|
246
|
+
for (const { id, owner } of report.live) process.stdout.write(`still running ${id} (pid ${owner.pid} is alive)\n`);
|
|
247
|
+
for (const { id, owner } of report.foreign) process.stdout.write(`still running ${id} (owned by ${owner.host}; not reclaimed from another host)\n`);
|
|
248
|
+
for (const id of report.missing) process.stdout.write(`dropped ${id} from running: record missing\n`);
|
|
249
|
+
for (const file of report.journal) process.stdout.write(`removed stray journal file ${file}\n`);
|
|
250
|
+
if (!Object.values(report).some(list => list.length)) process.stdout.write('nothing to recover\n');
|
|
251
|
+
process.exit(EXIT.OK);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// `--flag value` and `--switch` options; positional arguments keep their order.
|
|
255
|
+
function parseOptions(args, { valued = [], switches = [] } = {}) {
|
|
256
|
+
const options = { positional: [] };
|
|
257
|
+
for (let i = 0; i < args.length; i++) {
|
|
258
|
+
const arg = args[i];
|
|
259
|
+
if (arg === '--') { options.positional.push(...args.slice(i + 1)); break; }
|
|
260
|
+
if (valued.includes(arg)) {
|
|
261
|
+
const value = args[++i];
|
|
262
|
+
if (value === undefined) usage(`${arg} requires a value`);
|
|
263
|
+
options[arg.slice(2)] = value;
|
|
264
|
+
} else if (switches.includes(arg)) options[arg.slice(2)] = true;
|
|
265
|
+
else if (arg.startsWith('--')) usage(`unknown option ${arg}`);
|
|
266
|
+
else options.positional.push(arg);
|
|
267
|
+
}
|
|
268
|
+
return options;
|
|
269
|
+
}
|
|
270
|
+
const problemExit = code => (code === 'STATE_BUSY' ? EXIT.BUSY : EXIT.INVALID);
|
|
271
|
+
|
|
272
|
+
function cmdRegister(root, args) {
|
|
273
|
+
const o = parseOptions(args, { valued: ['--prd', '--change', '--authorization'], switches: ['--replace', '--rebind'] });
|
|
274
|
+
if (!o.prd) usage('register requires --prd .prd/prd-vN.md');
|
|
275
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
276
|
+
const result = identity.register(root, { prd: o.prd, change: o.change, authorization: o.authorization ?? null, replace: Boolean(o.replace), rebind: Boolean(o.rebind) });
|
|
277
|
+
if (result.code) { process.stderr.write(`pincer: ${result.problem}\n`); process.exit(problemExit(result.code)); }
|
|
278
|
+
for (const note of result.notes) process.stderr.write(`pincer: note: ${note}\n`);
|
|
279
|
+
const b = result.binding;
|
|
280
|
+
process.stdout.write(`${result.action} change ${b.change} → ${b.prd} revision ${b.prd_revision.slice(0, 12)} base ${b.base.slice(0, 7)} (${result.file})\n`);
|
|
281
|
+
process.exit(EXIT.OK);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function cmdSnapshot(root, args) {
|
|
285
|
+
const o = parseOptions(args, { switches: ['--json', '--store'] });
|
|
286
|
+
if (o.positional.length) usage(`unexpected argument ${o.positional[0]}`);
|
|
287
|
+
const manifest = source.snapshot(root);
|
|
288
|
+
for (const problem of manifest.problems) process.stderr.write(`pincer: ${problem.code}: ${problem.detail}\n`);
|
|
289
|
+
if (manifest.problems.length) process.exit(EXIT.INVALID);
|
|
290
|
+
if (o.store) source.storeManifest(root, manifest);
|
|
291
|
+
if (o.json) process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
292
|
+
else {
|
|
293
|
+
process.stdout.write(`digest ${manifest.digest}\nfiles ${manifest.files.length}\nexcluded ${manifest.excluded.length}\n`);
|
|
294
|
+
for (const l of manifest.limitations) process.stderr.write(`pincer: limitation: ${l}\n`);
|
|
295
|
+
}
|
|
296
|
+
process.exit(EXIT.OK);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function cmdValidate(root, args) {
|
|
300
|
+
const digests = args.includes('--digests');
|
|
301
|
+
const files = args.filter(arg => arg !== '--digests');
|
|
302
|
+
if (files.some(arg => arg.startsWith('--'))) usage(`unknown option ${files.find(arg => arg.startsWith('--'))}`);
|
|
303
|
+
if (files.length === 0) usage('validate requires at least one file');
|
|
304
|
+
let invalid = false;
|
|
305
|
+
const report = (prefix, file, problems) => { for (const p of problems) process.stderr.write(`${prefix}: ${file}: ${p}\n`); invalid ||= problems.length > 0; };
|
|
306
|
+
for (const file of files) {
|
|
307
|
+
const relative = path.isAbsolute(file) ? path.relative(root, file) : file;
|
|
308
|
+
const base = path.basename(relative);
|
|
309
|
+
if (/^T-[0-9]+.*\.md$/.test(base)) {
|
|
310
|
+
let text;
|
|
311
|
+
try { text = fs.readFileSync(path.resolve(root, relative), 'utf8'); } catch { report('pincer-ticket', relative, ['no such file']); continue; }
|
|
312
|
+
const result = parse.validateTicket(relative, text);
|
|
313
|
+
report('pincer-ticket', relative, result.problems);
|
|
314
|
+
if (result.ok && digests) {
|
|
315
|
+
process.stdout.write(`ticket ${parse.ticketDigest(text)}\n`);
|
|
316
|
+
process.stdout.write(`check ${parse.checkDigest(text, result.timeout)}\n`);
|
|
317
|
+
}
|
|
318
|
+
} else if (parse.PRD_REF.test(relative)) {
|
|
319
|
+
const result = parse.validatePrd(root, relative);
|
|
320
|
+
report('pincer', relative, result.problems);
|
|
321
|
+
if (result.ok && digests) process.stdout.write(`prd ${parse.prdDigest(result.text)}\n`);
|
|
322
|
+
} else {
|
|
323
|
+
let text;
|
|
324
|
+
try { text = fs.readFileSync(path.resolve(root, relative), 'utf8'); } catch { report('pincer', relative, ['no such file']); continue; }
|
|
325
|
+
report('pincer', relative, parse.validateMetadata(text).problems);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
process.exit(invalid ? EXIT.INVALID : EXIT.OK);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function main(argv) {
|
|
332
|
+
const [command, ...rest] = argv;
|
|
333
|
+
const root = repoRoot();
|
|
334
|
+
if (command === 'validate') return cmdValidate(root, rest);
|
|
335
|
+
if (command === 'register') return cmdRegister(root, rest);
|
|
336
|
+
if (command === 'snapshot') return cmdSnapshot(root, rest);
|
|
337
|
+
if (command === 'recover') return cmdRecover(root, rest);
|
|
338
|
+
if (command === 'status') return cmdStatus(root, rest);
|
|
339
|
+
if (command === 'ready') return cmdReady(root, rest);
|
|
340
|
+
if (['start', 'verify', 'done', 'bind'].includes(command)) return cmdLifecycle(root, command, rest);
|
|
341
|
+
if (command === 'migrate') return cmdMigrate(root, rest);
|
|
342
|
+
if (command === 'check') return cmdCheck(root, rest);
|
|
343
|
+
if (command === 'evidence') return cmdEvidence(root, rest);
|
|
344
|
+
usage(command ? `unknown command ${command}` : undefined);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
Promise.resolve(main(process.argv.slice(2))).catch(error => {
|
|
348
|
+
process.stderr.write(`pincer: unexpected error: ${error && error.stack ? error.stack : error}\n`);
|
|
349
|
+
process.exit(EXIT.INVALID);
|
|
350
|
+
});
|
|
@@ -2,167 +2,16 @@
|
|
|
2
2
|
# PINCER status — where the workflow stands, read from the artifacts on disk.
|
|
3
3
|
# Read-only. Every playbook runs this first; /pincer-status wraps it.
|
|
4
4
|
#
|
|
5
|
-
# scripts/pincer-status.sh
|
|
5
|
+
# scripts/pincer-status.sh [--json]
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# and
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
cd "$ROOT"
|
|
17
|
-
BUDGET=${PINCER_BUILD_BUDGET_MIN:-}
|
|
18
|
-
NOW=$(date -u +%s)
|
|
19
|
-
|
|
20
|
-
to_epoch() { # ISO-8601 UTC -> seconds (GNU date, then BSD date)
|
|
21
|
-
date -u -d "$1" +%s 2>/dev/null || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$1" +%s 2>/dev/null || echo 0
|
|
7
|
+
# Compatibility wrapper: the report is computed by scripts/pincer-runtime.cjs
|
|
8
|
+
# (`status`), which prints the PRD state and profile, every ticket with its
|
|
9
|
+
# state and clock-based elapsed time, the wall-clock elapsed build line while a
|
|
10
|
+
# ticket is in progress or PINCER_BUILD_BUDGET_MIN is set, the evidence verdict,
|
|
11
|
+
# readiness warnings, and the next command. Exit codes follow
|
|
12
|
+
# docs/runtime-contracts.md (0 inspected, 4 invalid input).
|
|
13
|
+
command -v node >/dev/null 2>&1 || {
|
|
14
|
+
echo 'pincer-status: Node.js 18+ is required — the runtime is scripts/pincer-runtime.cjs' >&2
|
|
15
|
+
exit 4
|
|
22
16
|
}
|
|
23
|
-
|
|
24
|
-
hhmm() { [ -n "$1" ] && printf '%s' "$1" | cut -c12-16 || printf '—'; }
|
|
25
|
-
|
|
26
|
-
echo "PINCER status · $(date -u +%Y-%m-%dT%H:%MZ) · $ROOT"
|
|
27
|
-
|
|
28
|
-
# ── PRD ──
|
|
29
|
-
if ! prd=$(latest_prd 2>&1); then
|
|
30
|
-
printf 'WARN invalid PRD: %s\n' "$prd"
|
|
31
|
-
echo 'Next repair PRD input before continuing'
|
|
32
|
-
exit 1
|
|
33
|
-
fi
|
|
34
|
-
prd_status=""
|
|
35
|
-
if [ -z "$prd" ]; then
|
|
36
|
-
echo "PRD none"
|
|
37
|
-
else
|
|
38
|
-
prd_status=$(fm_get "$prd" status)
|
|
39
|
-
echo "PRD $prd · status: ${prd_status:-?} · profile: $(prd_profile "$prd") · date: $(fm_get "$prd" date)"
|
|
40
|
-
fi
|
|
41
|
-
|
|
42
|
-
# Reject malformed/ambiguous tickets instead of treating unknown states as open.
|
|
43
|
-
if ! errors=$(validate_ticket_set 2>&1); then
|
|
44
|
-
printf 'WARN invalid tickets: %s\n' "$errors"
|
|
45
|
-
echo "Next repair ticket input before continuing"
|
|
46
|
-
exit 1
|
|
47
|
-
fi
|
|
48
|
-
|
|
49
|
-
# ── Tickets ──
|
|
50
|
-
n_open=0; n_prog=0; n_done=0; first_start=""; in_prog=""; next_open=""; warn=""; reverify=""
|
|
51
|
-
files=""; historical=0; unresolved=0
|
|
52
|
-
for f in tickets/T-*.md; do
|
|
53
|
-
[ -e "$f" ] || continue
|
|
54
|
-
if ! associated=$(ticket_prd "$f" 2>&1); then
|
|
55
|
-
printf 'WARN unresolved ticket PRD: %s\n' "$associated"
|
|
56
|
-
unresolved=$((unresolved + 1)); continue
|
|
57
|
-
fi
|
|
58
|
-
if [ "$associated" = "$prd" ]; then
|
|
59
|
-
files="$files$f
|
|
60
|
-
"
|
|
61
|
-
else
|
|
62
|
-
historical=$((historical + 1))
|
|
63
|
-
fi
|
|
64
|
-
done
|
|
65
|
-
[ "$historical" -eq 0 ] || echo "History $historical ticket(s) associated with other PRDs"
|
|
66
|
-
if [ -z "$files" ]; then
|
|
67
|
-
echo "Tickets none"
|
|
68
|
-
else
|
|
69
|
-
rows=""
|
|
70
|
-
while IFS= read -r f; do
|
|
71
|
-
[ -n "$f" ] || continue
|
|
72
|
-
id=$(fm_get "$f" ticket); [ -n "$id" ] || id=$(basename "$f" | cut -c1-4)
|
|
73
|
-
st=$(fm_get "$f" status); size=$(fm_get "$f" size)
|
|
74
|
-
started=$(fm_get "$f" started); verified=$(fm_get "$f" verified); finished=$(fm_get "$f" finished)
|
|
75
|
-
attempt=$(fm_get "$f" last_check)
|
|
76
|
-
# Done tickets report through ticket_readiness below, so each problem is printed once.
|
|
77
|
-
if [ "$st" != done ] && [ -n "$attempt" ] && ! printf '%s' "$attempt" | grep -q ' passed '; then
|
|
78
|
-
warn="$warn WARN $id latest verification: $attempt — re-run verify\n"
|
|
79
|
-
fi
|
|
80
|
-
deps=$(fm_get "$f" depends_on | grep -oE 'T-[0-9]+' | tr '\n' ' ' || true)
|
|
81
|
-
if [ -n "$started" ]; then
|
|
82
|
-
se=$(to_epoch "$started")
|
|
83
|
-
[ -z "$first_start" ] || [ "$se" -lt "$first_start" ] && first_start=$se
|
|
84
|
-
fi
|
|
85
|
-
case "$st" in
|
|
86
|
-
done)
|
|
87
|
-
n_done=$((n_done + 1))
|
|
88
|
-
detail="started $(hhmm "$started") · finished $(hhmm "$finished")"
|
|
89
|
-
[ -n "$started" ] && [ -n "$finished" ] && detail="$detail ($(mins "$(to_epoch "$started")" "$(to_epoch "$finished")"))"
|
|
90
|
-
if ! readiness=$(ticket_readiness "$f"); then
|
|
91
|
-
warn="$warn WARN $id $readiness\n"
|
|
92
|
-
reverify="$reverify $id"
|
|
93
|
-
fi
|
|
94
|
-
;;
|
|
95
|
-
in_progress)
|
|
96
|
-
n_prog=$((n_prog + 1)); in_prog="$in_prog $id"
|
|
97
|
-
detail="started $(hhmm "$started")"
|
|
98
|
-
[ -n "$started" ] && detail="$detail · elapsed $(mins "$(to_epoch "$started")" "$NOW")"
|
|
99
|
-
if [ -n "$verified" ]; then detail="$detail · receipt ✓"; else detail="$detail · no receipt yet"; fi
|
|
100
|
-
;;
|
|
101
|
-
*)
|
|
102
|
-
n_open=$((n_open + 1)); st=${st:-open}
|
|
103
|
-
blocked=""
|
|
104
|
-
for d in $deps; do
|
|
105
|
-
df=$(ticket_file "$d" 2>/dev/null || true)
|
|
106
|
-
dep_prd=""
|
|
107
|
-
[ -z "$df" ] || dep_prd=$(ticket_prd "$df" 2>/dev/null || true)
|
|
108
|
-
if [ -z "$df" ] || [ "$(fm_get "$df" status)" != done ] ||
|
|
109
|
-
[ "$dep_prd" != "$prd" ] || ! usable_ticket_prd "$df" >/dev/null 2>&1 ||
|
|
110
|
-
! ticket_readiness "$df" >/dev/null; then
|
|
111
|
-
blocked="$blocked $d"
|
|
112
|
-
fi
|
|
113
|
-
done
|
|
114
|
-
if [ -n "$blocked" ]; then detail="blocked by${blocked}"; else detail="ready"; [ -n "$next_open" ] || next_open=$id; fi
|
|
115
|
-
;;
|
|
116
|
-
esac
|
|
117
|
-
rows="$rows$(printf ' %-5s %-12s %-2s %s' "$id" "$st" "${size:-?}" "$detail")\n"
|
|
118
|
-
done <<< "$files"
|
|
119
|
-
echo "Tickets $((n_open + n_prog + n_done)) total · $n_done done · $n_prog in progress · $n_open open"
|
|
120
|
-
printf '%b' "$rows"
|
|
121
|
-
printf '%b' "$warn"
|
|
122
|
-
# Wall-clock elapsed is shown only while work is active or against an explicit
|
|
123
|
-
# budget; on a finished build it is noise and it never measures execution time.
|
|
124
|
-
if [ -n "$first_start" ] && { [ "$n_prog" -gt 0 ] || [ -n "$BUDGET" ]; }; then
|
|
125
|
-
build="Build wall-clock elapsed $(mins "$first_start" "$NOW") since the first ticket started (not active execution time)"
|
|
126
|
-
[ -z "$BUDGET" ] || build="$build · budget ${BUDGET}m"
|
|
127
|
-
echo "$build"
|
|
128
|
-
fi
|
|
129
|
-
fi
|
|
130
|
-
|
|
131
|
-
notes_valid=no
|
|
132
|
-
if notes=$(notes_current "$prd"); then notes_valid=yes; fi
|
|
133
|
-
echo "Notes NOTES.md: $notes"
|
|
134
|
-
# The evidence line reports the shared validator's verdict for the manifest the
|
|
135
|
-
# notes name, independent of whether the candidate is still current.
|
|
136
|
-
manifest=""
|
|
137
|
-
[ -f NOTES.md ] && validate_metadata NOTES.md >/dev/null 2>&1 && manifest=$(fm_get NOTES.md evidence)
|
|
138
|
-
if [ -n "$manifest" ]; then
|
|
139
|
-
if ev=$(evidence_validate "$manifest" "$(fm_get NOTES.md candidate)" "$(fm_get NOTES.md base)" "$prd"); then
|
|
140
|
-
echo "Evidence $manifest · ok"
|
|
141
|
-
else
|
|
142
|
-
echo "Evidence $manifest · $(evidence_reason "$ev")"
|
|
143
|
-
fi
|
|
144
|
-
fi
|
|
145
|
-
|
|
146
|
-
# ── Next action ──
|
|
147
|
-
if [ -z "$prd" ]; then
|
|
148
|
-
next="/pincer-plan <brief> — no PRD yet"
|
|
149
|
-
elif [ "$unresolved" -gt 0 ]; then
|
|
150
|
-
next="resolve PRD association with pincer-ticket.sh bind T-NN .prd/prd-vN.md before continuing"
|
|
151
|
-
elif [ "$prd_status" = draft ]; then
|
|
152
|
-
next="/pincer-narrow — current PRD is draft; earlier tickets and notes do not complete it"
|
|
153
|
-
elif [ -z "$files" ]; then
|
|
154
|
-
next="/pincer-narrow — PRD exists, no tickets yet"
|
|
155
|
-
elif [ -n "$in_prog" ]; then
|
|
156
|
-
next="resume${in_prog}: /pincer-code${in_prog} (check git status for uncommitted work; then verify → done)"
|
|
157
|
-
elif [ "$n_open" -gt 0 ]; then
|
|
158
|
-
next="/pincer-code — next ready ticket: ${next_open:-none (all remaining are blocked — check depends_on)}"
|
|
159
|
-
elif [ -n "$reverify" ]; then
|
|
160
|
-
next="/pincer-code — re-run verify for${reverify}; resolve readiness warnings before evaluation or release"
|
|
161
|
-
elif [ "$notes_valid" = no ] || [ "$prd_status" != built ]; then
|
|
162
|
-
next="/pincer-evaluate — all tickets done"
|
|
163
|
-
[ "$prd_status" = built ] || next="$next (PRD status is '${prd_status:-?}', expected 'built')"
|
|
164
|
-
else
|
|
165
|
-
next="/pincer-release — evaluation matches the current PRD and candidate; audit the artifacts"
|
|
166
|
-
fi
|
|
167
|
-
echo "Next $next"
|
|
168
|
-
[ "$unresolved" -eq 0 ] || exit 1
|
|
17
|
+
exec node "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer-runtime.cjs" status "$@"
|