pincer-workflow 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +7 -5
  2. package/bin/pincer.js +42 -5
  3. package/package.json +2 -2
  4. package/template/.agents/skills/pincer-code/SKILL.md +51 -4
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +29 -2
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +11 -2
  7. package/template/.agents/skills/pincer-plan/SKILL.md +9 -1
  8. package/template/.agents/skills/pincer-release/SKILL.md +14 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +17 -2
  10. package/template/.claude/commands/pincer-code.md +51 -4
  11. package/template/.claude/commands/pincer-evaluate.md +29 -2
  12. package/template/.claude/commands/pincer-narrow.md +11 -2
  13. package/template/.claude/commands/pincer-plan.md +9 -1
  14. package/template/.claude/commands/pincer-release.md +14 -3
  15. package/template/.claude/commands/pincer-status.md +17 -2
  16. package/template/.claude/hooks/hook-policy.cjs +17 -3
  17. package/template/.claude/references/ticket-template.md +4 -0
  18. package/template/.codex/README.md +3 -2
  19. package/template/.github/prompts/pincer-code.prompt.md +51 -4
  20. package/template/.github/prompts/pincer-evaluate.prompt.md +29 -2
  21. package/template/.github/prompts/pincer-narrow.prompt.md +11 -2
  22. package/template/.github/prompts/pincer-plan.prompt.md +9 -1
  23. package/template/.github/prompts/pincer-release.prompt.md +14 -3
  24. package/template/.github/prompts/pincer-status.prompt.md +17 -2
  25. package/template/AGENTS.md +6 -0
  26. package/template/docs/dry-run-checklist.md +46 -3
  27. package/template/docs/release-checklist.md +2 -1
  28. package/template/docs/runtime-contracts.md +437 -0
  29. package/template/scripts/pincer-evidence.cjs +5 -223
  30. package/template/scripts/pincer-runtime/evidence.cjs +391 -0
  31. package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
  32. package/template/scripts/pincer-runtime/identity.cjs +146 -0
  33. package/template/scripts/pincer-runtime/lifecycle.cjs +289 -0
  34. package/template/scripts/pincer-runtime/migrate.cjs +127 -0
  35. package/template/scripts/pincer-runtime/parse.cjs +297 -0
  36. package/template/scripts/pincer-runtime/readiness.cjs +89 -0
  37. package/template/scripts/pincer-runtime/runner.cjs +224 -0
  38. package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
  39. package/template/scripts/pincer-runtime/source.cjs +129 -0
  40. package/template/scripts/pincer-runtime/state.cjs +292 -0
  41. package/template/scripts/pincer-runtime/status.cjs +358 -0
  42. package/template/scripts/pincer-runtime.cjs +350 -0
  43. package/template/scripts/pincer-status.sh +11 -162
  44. package/template/scripts/pincer-ticket.sh +19 -139
  45. package/template/scripts/pincer-ticket-lib.sh +0 -321
@@ -0,0 +1,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
- # Elapsed times come from the `started` / `finished` stamps that
8
- # scripts/pincer-ticket.sh writes, i.e. from the wall clock — never estimated
9
- # and never a measure of active execution time. The build-wide elapsed line is
10
- # printed only while a ticket is in progress or an explicit budget is set.
11
- # Optional build budget: PINCER_BUILD_BUDGET_MIN.
12
- set -uo pipefail
13
-
14
- source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pincer-ticket-lib.sh"
15
- ROOT=${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}
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
- mins() { echo "$(( ($2 - $1) / 60 ))m"; }
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 "$@"