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,297 @@
1
+ 'use strict';
2
+ // PINCER runtime — parser and validator for the supported Markdown grammar
3
+ // (docs/runtime-contracts.md, "Supported grammar" and "Content revisions").
4
+ // Dependency-free. The rules and diagnostics match the v0.4.1 Bash validators
5
+ // so the two never disagree; the Node module is now the only implementation.
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const crypto = require('node:crypto');
9
+ const { spawnSync } = require('node:child_process');
10
+
11
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
12
+ const PRD_REF = /^\.prd\/prd-v([1-9][0-9]{0,8})\.md$/;
13
+ const HEX40 = /^[0-9a-f]{40}$/;
14
+ const DEFAULT_TIMEOUT = 600;
15
+ const MAX_TIMEOUT = 2147483; // setTimeout's 32-bit millisecond limit, in seconds
16
+ const LIFECYCLE_FIELDS = ['status', 'started', 'finished', 'verified', 'last_check'];
17
+ const CHECKBOX_LINE = /^([ \t]*(?:[-+*]|[0-9]+[.)])[ \t]+)\[([ xX])\]/;
18
+
19
+ const sha256 = text => crypto.createHash('sha256').update(text).digest('hex');
20
+ const lines = text => {
21
+ const all = String(text).split('\n');
22
+ if (all.length && all[all.length - 1] === '') all.pop();
23
+ return all;
24
+ };
25
+
26
+ // CLI shorthand -> canonical ID ("3" -> "T-03"); null when not a ticket id.
27
+ function normalizeId(input) {
28
+ let n = String(input);
29
+ if (n.startsWith('T-') || n.startsWith('t-')) n = n.slice(2);
30
+ if (!/^[0-9]{1,6}$/.test(n) || Number(n) === 0) return null;
31
+ return `T-${String(Number(n)).padStart(2, '0')}`;
32
+ }
33
+ function canonicalId(s) {
34
+ if (typeof s !== 'string' || !/^T-[0-9][0-9]+$/.test(s)) return false;
35
+ const n = s.slice(2);
36
+ return n.length <= 6 && Number(n) > 0 && `T-${String(Number(n)).padStart(2, '0')}` === s;
37
+ }
38
+
39
+ // Frontmatter: `---`, unindented unique `key: value` lines (inline `#` comments,
40
+ // blank and comment lines allowed), `---`. Returns the fields and the index of
41
+ // the closing line, or the single structural problem.
42
+ function parseFrontmatter(text, kind = 'ticket') {
43
+ const rows = lines(text);
44
+ const fields = {};
45
+ const order = [];
46
+ if (rows[0] !== '---') return { problem: 'frontmatter must begin with ---', rows };
47
+ for (let i = 1; i < rows.length; i++) {
48
+ const line = rows[i];
49
+ if (line === '---') return { fields, order, end: i, rows };
50
+ if (/^[ \t]*(#.*)?$/.test(line)) continue;
51
+ if (!/^[a-z_][a-z0-9_]*:[ \t]*/.test(line)) {
52
+ return { problem: kind === 'ticket' ? 'frontmatter requires unindented key: value fields' : 'expected unindented key: value metadata', rows };
53
+ }
54
+ const key = line.slice(0, line.indexOf(':'));
55
+ if (key in fields) return { problem: `duplicate ${kind === 'ticket' ? 'frontmatter' : 'metadata'} key: ${key}`, rows };
56
+ let value = line.slice(key.length + 1);
57
+ value = value.replace(/#.*/, '').trim();
58
+ fields[key] = value;
59
+ order.push(key);
60
+ }
61
+ return { problem: 'frontmatter must close with ---', rows };
62
+ }
63
+
64
+ // Read one frontmatter field the way the Bash helper did (first match, comment
65
+ // stripped), tolerating files without valid frontmatter.
66
+ function frontmatterField(text, key) {
67
+ const fm = parseFrontmatter(text);
68
+ if (fm.problem && fm.problem !== 'frontmatter must close with ---') {
69
+ if (fm.problem === 'frontmatter must begin with ---') return '';
70
+ }
71
+ for (const line of lines(text).slice(1)) {
72
+ if (line === '---') break;
73
+ if (line.startsWith(`${key}:`)) return line.slice(key.length + 1).replace(/#.*/, '').trim();
74
+ }
75
+ return '';
76
+ }
77
+
78
+ function verificationCommands(text) {
79
+ const out = [];
80
+ let inBlock = false, otherFence = false, inVerify = false;
81
+ for (const line of lines(text)) {
82
+ if (inBlock) { if (/^[ \t]*```[ \t]*$/.test(line)) break; out.push(line); continue; }
83
+ if (otherFence) { if (/^[ \t]*```/.test(line)) otherFence = false; continue; }
84
+ if (/^## Verification[ \t]*$/.test(line)) { inVerify = true; continue; }
85
+ if (inVerify && /^[ \t]*```bash[ \t]*$/.test(line)) { inBlock = true; continue; }
86
+ if (inVerify && /^## /.test(line)) break;
87
+ if (/^[ \t]*```/.test(line)) otherFence = true;
88
+ }
89
+ return out;
90
+ }
91
+ // The block as the legacy helper hashed and executed it: joined lines, one trailing newline.
92
+ const blockText = text => { const c = verificationCommands(text); return c.length ? `${c.join('\n')}\n` : ''; };
93
+ const legacyBlockHash = text => sha256(blockText(text)).slice(0, 12);
94
+
95
+ function unticked(text) {
96
+ const out = [];
97
+ let otherFence = false, inAccept = false;
98
+ for (const line of lines(text)) {
99
+ if (otherFence) { if (/^[ \t]*```/.test(line)) otherFence = false; continue; }
100
+ if (/^[ \t]*```/.test(line)) { otherFence = true; continue; }
101
+ if (/^## Acceptance Criteria[ \t]*$/.test(line)) { inAccept = true; continue; }
102
+ if (inAccept && /^## /.test(line)) break;
103
+ if (inAccept && /^[ \t]*([-+*]|[0-9]+[.)])[ \t]+\[ \]/.test(line)) out.push(line);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ function effectiveTimeout(fields) {
109
+ return fields.timeout ? Number(fields.timeout) : DEFAULT_TIMEOUT;
110
+ }
111
+
112
+ // Full ticket validation. Returns { ok, problems, fields, timeout }.
113
+ function validateTicket(file, text) {
114
+ const problems = [];
115
+ const fm = parseFrontmatter(text, 'ticket');
116
+ if (fm.problem) return { ok: false, problems: [fm.problem], fields: {} };
117
+ const { fields, rows } = fm;
118
+ let inVerify = false, otherFence = false, section = '';
119
+ let accept = 0, verify = 0, boxes = 0, fences = 0, runnable = 0;
120
+ for (let i = fm.end + 1; i < rows.length; i++) {
121
+ const line = rows[i];
122
+ if (inVerify) {
123
+ if (/^[ \t]*```[ \t]*$/.test(line)) { inVerify = false; continue; }
124
+ if (/^[ \t]*```/.test(line)) { problems.push('Verification requires one closed bash fence'); continue; }
125
+ if (!/^[ \t]*(#.*)?$/.test(line)) runnable++;
126
+ continue;
127
+ }
128
+ if (otherFence) { if (/^[ \t]*```/.test(line)) otherFence = false; continue; }
129
+ if (/^## Acceptance Criteria[ \t]*$/.test(line)) { if (accept++) problems.push('duplicate Acceptance Criteria section'); section = 'accept'; continue; }
130
+ if (/^## Verification[ \t]*$/.test(line)) { if (verify++) problems.push('duplicate Verification section'); section = 'verify'; continue; }
131
+ if (/^## /.test(line)) { section = ''; continue; }
132
+ if (section === 'accept') {
133
+ if (/^[ \t]*(```|~~~)/.test(line)) { problems.push('Acceptance Criteria must contain visible checkboxes, not fences'); continue; }
134
+ if (/^[ \t]*([-+*]|[0-9]+[.)])[ \t]+/.test(line) || /^[ \t]*([-+*]|[0-9]+[.)])?[ \t]*\[/.test(line)) {
135
+ if (!/^[ \t]*([-+*]|[0-9]+[.)])[ \t]+\[[ xX]\][ \t]+[^ \t]/.test(line)) {
136
+ problems.push('malformed acceptance checkbox; use - [ ] text or - [x] text (indented/list marker variants supported)');
137
+ }
138
+ boxes++;
139
+ }
140
+ }
141
+ if (/^[ \t]*~~~/.test(line)) { problems.push('use backtick fences; tilde fences are unsupported'); continue; }
142
+ if (/^[ \t]*```/.test(line)) {
143
+ if (section === 'verify') {
144
+ if (!/^[ \t]*```bash[ \t]*$/.test(line) || fences++) problems.push('Verification requires exactly one bash fence');
145
+ inVerify = true;
146
+ } else otherFence = true;
147
+ }
148
+ }
149
+ if (!('ticket' in fields) || !canonicalId(fields.ticket)) problems.push('ticket must be a canonical ID between T-01 and T-999999');
150
+ else {
151
+ const base = path.basename(file);
152
+ const prefix = `${fields.ticket}-`;
153
+ if (!base.startsWith(prefix) || !/.+\.md$/.test(base) || base.length <= prefix.length + 3) problems.push('ticket ID must match filename T-NN-slug.md');
154
+ }
155
+ if (!['open', 'in_progress', 'done'].includes(fields.status)) problems.push('status must be open, in_progress, or done');
156
+ if (!['S', 'M', 'L'].includes(fields.size)) problems.push('size must be S, M, or L');
157
+ for (const key of ['started', 'finished']) if (key in fields && !TIMESTAMP.test(fields[key])) problems.push(`${key} must be an ISO UTC timestamp`);
158
+ for (const key of ['verified', 'last_check']) {
159
+ if (!(key in fields)) continue;
160
+ const parts = fields[key].split(/[ \t]+/);
161
+ const expected = key === 'verified' ? 2 : 3;
162
+ if (parts.length !== expected || !TIMESTAMP.test(parts[0]) || !/^[0-9a-f]{12}$/.test(parts[parts.length - 1])) problems.push(`malformed ${key} timestamp/hash`);
163
+ else if (key === 'last_check' && !['running', 'passed', 'failed', 'interrupted'].includes(parts[1])) problems.push('invalid last_check outcome');
164
+ }
165
+ if ('timeout' in fields && (!/^[1-9][0-9]{0,8}$/.test(fields.timeout) || Number(fields.timeout) > MAX_TIMEOUT)) problems.push(`timeout must be a positive integer number of seconds (at most ${MAX_TIMEOUT})`);
166
+ const deps = fields.depends_on;
167
+ if (!('depends_on' in fields) || !/^\[[ \t]*(T-[0-9]+([ \t]*,[ \t]*T-[0-9]+)*)?[ \t]*\]$/.test(deps)) problems.push('depends_on must be an inline list such as [T-01, T-02]');
168
+ else {
169
+ const seen = new Set();
170
+ for (const entry of deps.replace(/^\[[ \t]*/, '').replace(/[ \t]*\]$/, '').split(',')) {
171
+ const dep = entry.trim();
172
+ if (dep === '') continue;
173
+ if (!canonicalId(dep)) problems.push(`depends_on contains noncanonical ticket ID: ${dep}`);
174
+ if (dep === fields.ticket) problems.push('ticket cannot depend on itself');
175
+ if (seen.has(dep)) problems.push(`duplicate depends_on ID: ${dep}`);
176
+ seen.add(dep);
177
+ }
178
+ }
179
+ if (!accept || !boxes) problems.push('Acceptance Criteria requires at least one nonempty checkbox');
180
+ if (!verify || fences !== 1 || inVerify || !runnable) problems.push('Verification requires exactly one closed runnable bash fence');
181
+ if (problems.length === 0) {
182
+ const syntax = spawnSync('bash', ['-n', '-c', blockText(text)], { encoding: 'utf8' });
183
+ if (syntax.error) problems.push(`cannot run bash to check the Verification block (${syntax.error.message})`);
184
+ else if (syntax.status !== 0) problems.push('invalid bash syntax in Verification block');
185
+ }
186
+ return { ok: problems.length === 0, problems, fields, timeout: effectiveTimeout(fields) };
187
+ }
188
+
189
+ function dependencies(fields) {
190
+ return ((fields.depends_on || '').match(/T-[0-9]+/g) || []);
191
+ }
192
+
193
+ // PRDs and evaluation notes use the same deliberately small metadata format.
194
+ function validateMetadata(text) {
195
+ const fm = parseFrontmatter(text, 'metadata');
196
+ return fm.problem ? { ok: false, problems: [fm.problem], fields: {} } : { ok: true, problems: [], fields: fm.fields };
197
+ }
198
+
199
+ function validatePrd(root, ref) {
200
+ const match = typeof ref === 'string' ? ref.match(PRD_REF) : null;
201
+ if (!match) return { ok: false, problems: [`invalid PRD reference ${ref}; use .prd/prd-vN.md`], prefix: 'pincer' };
202
+ const file = path.join(root, ref);
203
+ if (!fs.existsSync(file)) return { ok: false, problems: [`PRD does not exist: ${ref}`], prefix: 'pincer' };
204
+ const text = fs.readFileSync(file, 'utf8');
205
+ const meta = validateMetadata(text);
206
+ if (!meta.ok) return { ok: false, problems: meta.problems, file: ref };
207
+ const problems = [];
208
+ const { fields } = meta;
209
+ if (fields.version !== match[1]) problems.push(`version must match filename (${match[1]})`);
210
+ if (!['draft', 'ticketed', 'built'].includes(fields.status)) problems.push('PRD status must be draft, ticketed, or built');
211
+ if (!['', 'small', 'standard', undefined].includes(fields.profile)) problems.push('profile must be small or standard (omit for standard)');
212
+ return { ok: problems.length === 0, problems, file: ref, fields, text, profile: fields.profile || 'standard' };
213
+ }
214
+
215
+ // Normalizations (contract "Content revisions"): lifecycle fields and checkbox
216
+ // marks are the only exceptions; everything else contributes to identity.
217
+ function normalizeTicket(text) {
218
+ const rows = lines(text);
219
+ const out = [];
220
+ let closed = rows[0] !== '---';
221
+ let otherFence = false, inAccept = false;
222
+ for (let i = 0; i < rows.length; i++) {
223
+ const line = rows[i];
224
+ if (!closed) {
225
+ if (i > 0 && line === '---') closed = true;
226
+ else if (i > 0) {
227
+ const key = (line.match(/^([a-z_][a-z0-9_]*):/) || [])[1];
228
+ if (key && LIFECYCLE_FIELDS.includes(key)) continue;
229
+ }
230
+ out.push(line);
231
+ continue;
232
+ }
233
+ if (otherFence) { if (/^[ \t]*```/.test(line)) otherFence = false; out.push(line); continue; }
234
+ if (/^[ \t]*```/.test(line)) { otherFence = true; out.push(line); continue; }
235
+ if (/^## Acceptance Criteria[ \t]*$/.test(line)) inAccept = true;
236
+ else if (/^## /.test(line)) inAccept = false;
237
+ out.push(inAccept ? line.replace(CHECKBOX_LINE, '$1[ ]') : line);
238
+ }
239
+ return `${out.join('\n')}\n`;
240
+ }
241
+ function normalizePrd(text) {
242
+ const rows = lines(text);
243
+ const out = [];
244
+ let closed = rows[0] !== '---';
245
+ for (let i = 0; i < rows.length; i++) {
246
+ const line = rows[i];
247
+ if (!closed) {
248
+ if (i > 0 && line === '---') closed = true;
249
+ else if (i > 0 && /^status:/.test(line)) continue;
250
+ }
251
+ out.push(line);
252
+ }
253
+ return `${out.join('\n')}\n`;
254
+ }
255
+ const ticketDigest = text => sha256(normalizeTicket(text));
256
+ const prdDigest = text => sha256(normalizePrd(text));
257
+ const checkDigest = (text, timeoutSeconds = DEFAULT_TIMEOUT) => sha256(`${blockText(text)}timeout=${timeoutSeconds}\n`);
258
+
259
+ // tickets/T-NN-*.md resolution and set validation.
260
+ function ticketFiles(root) {
261
+ const dir = path.join(root, 'tickets');
262
+ if (!fs.existsSync(dir)) return [];
263
+ return fs.readdirSync(dir).filter(name => /^T-[0-9]+.*\.md$/.test(name)).sort().map(name => `tickets/${name}`);
264
+ }
265
+ function ticketFile(root, input) {
266
+ const id = normalizeId(input);
267
+ if (!id) return { problem: `not a ticket id (expected 1..999999): ${input}` };
268
+ const matches = ticketFiles(root).filter(f => path.basename(f).startsWith(`${id}-`));
269
+ if (matches.length > 1) return { problem: `several files match tickets/${id}-*.md` };
270
+ if (matches.length === 0) return { problem: `no ticket file tickets/${id}-*.md` };
271
+ return { id, file: matches[0] };
272
+ }
273
+ function validateTicketSet(root) {
274
+ const files = ticketFiles(root);
275
+ const ids = new Set();
276
+ for (const file of files) {
277
+ const id = frontmatterField(fs.readFileSync(path.join(root, file), 'utf8'), 'ticket');
278
+ if (id) {
279
+ if (ids.has(id)) return { ok: false, problems: [`duplicate ticket ID: ${id}`] };
280
+ ids.add(id);
281
+ }
282
+ }
283
+ for (const file of files) {
284
+ const result = validateTicket(file, fs.readFileSync(path.join(root, file), 'utf8'));
285
+ if (!result.ok) return { ok: false, file, problems: result.problems };
286
+ }
287
+ return { ok: true, files };
288
+ }
289
+
290
+ module.exports = {
291
+ TIMESTAMP, PRD_REF, HEX40, DEFAULT_TIMEOUT, MAX_TIMEOUT, LIFECYCLE_FIELDS,
292
+ sha256, lines, normalizeId, canonicalId, parseFrontmatter, frontmatterField,
293
+ verificationCommands, blockText, legacyBlockHash, unticked, effectiveTimeout,
294
+ validateTicket, dependencies, validateMetadata, validatePrd,
295
+ normalizeTicket, normalizePrd, ticketDigest, prdDigest, checkDigest,
296
+ ticketFiles, ticketFile, validateTicketSet,
297
+ };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ // PINCER runtime — the one readiness computation (docs/runtime-contracts.md,
3
+ // "Readiness and reason codes"). Pure functions over parsed inputs: the human
4
+ // status, the JSON status, `ready`, `done`, `start` and release all consume
5
+ // these so they cannot disagree. Nothing here reads the clock, executes a
6
+ // command or writes a file.
7
+ const parse = require('./parse.cjs');
8
+ const { validateAttempt } = require('./state.cjs');
9
+
10
+ const reason = (code, detail, next) => ({ code, detail, next });
11
+
12
+ // Legacy mode: the v0.4.1 rules over the ticket's own receipts. Returns
13
+ // { ready, reasons, legacyMessage } where legacyMessage is the exact wording
14
+ // the Bash helper printed for a done ticket that needs attention.
15
+ function legacyTicketReadiness(text, fields) {
16
+ const receipt = fields.verified || '';
17
+ const attempt = fields.last_check || '';
18
+ const hash = parse.legacyBlockHash(text);
19
+ const fail = (message, code, detail = message) => ({ ready: false, legacyMessage: message, reasons: [reason(code, detail, 're-run verify')] });
20
+ if (!attempt) return fail('missing latest verification outcome — re-run verify', 'EVIDENCE_MISSING', 'no recorded verification attempt');
21
+ const parts = attempt.split(/[ \t]+/);
22
+ if (!(parts.length === 3 && parse.TIMESTAMP.test(parts[0]) && parts[1] === 'passed' && /^[a-f0-9]{12}$/.test(parts[2])) || parts[2] !== hash) {
23
+ const outcomeCode = parts[1] === 'running' ? 'ATTEMPT_RUNNING' : parts[1] === 'interrupted' ? 'ATTEMPT_INTERRUPTED' : parts[1] === 'failed' ? 'CHECK_FAILED' : 'CHECK_CHANGED';
24
+ const code = parts[1] === 'passed' && parts[2] !== hash ? 'CHECK_CHANGED' : outcomeCode;
25
+ return fail(`latest verification: ${attempt} — re-run verify`, code, code === 'CHECK_CHANGED' ? 'the Verification block changed after the latest pass' : `latest verification: ${attempt}`);
26
+ }
27
+ if (!receipt) return fail('done without a verification receipt — re-run verify', 'EVIDENCE_MISSING', 'done without a verification receipt');
28
+ const rparts = receipt.split(/[ \t]+/);
29
+ if (!(rparts.length === 2 && parse.TIMESTAMP.test(rparts[0]) && /^[a-f0-9]{12}$/.test(rparts[1])) || rparts[1] !== hash) {
30
+ return fail('stale or malformed verification receipt — re-run verify', 'CHECK_CHANGED', 'stale or malformed verification receipt');
31
+ }
32
+ if (parse.unticked(text).length) return fail('unticked acceptance criteria — complete and re-run verify', 'CRITERIA_UNTICKED', 'unticked acceptance criteria');
33
+ return { ready: true, reasons: [] };
34
+ }
35
+
36
+ // Migrated mode: readiness derives from the latest attempt for the ticket's
37
+ // context and the current inputs. `current` carries the digests computed now;
38
+ // `sourceProblems` are snapshot problems (secret path, unsupported input);
39
+ // `contextKey` is the key the attempt was read for and `pointedId` the id the
40
+ // index names for it (the record must match both); the attempt's artifacts
41
+ // carry `missing`/`altered` from state.inspectArtifacts.
42
+ function migratedTicketReadiness({ text, fields, timeout, attempt, legacyReceipt, current, sourceProblems = [], changedPaths = [], contextKey = null, pointedId = null }) {
43
+ const reasons = [];
44
+ for (const p of sourceProblems) reasons.push(reason(p.code, p.detail, p.code === 'SECRET_PATH' ? 'remove or ignore the secret file' : 'remove the input or change the configuration'));
45
+ if (reasons.length) return { ready: false, reasons };
46
+ if (!attempt) {
47
+ if (legacyReceipt) return { ready: false, reasons: [reason('LEGACY_RECEIPT', `migrated legacy receipt (${legacyReceipt.verified || legacyReceipt.last_check || 'present'}) is history, not runtime evidence`, 'verify')] };
48
+ return { ready: false, reasons: [reason('EVIDENCE_MISSING', 'no runtime attempt recorded', 'verify')] };
49
+ }
50
+ const id = typeof attempt.id === 'string' && attempt.id ? attempt.id : '?';
51
+ // The record must be complete and written for this context before any
52
+ // outcome is honored: a stripped or foreign record is never a pass.
53
+ const invalid = validateAttempt(attempt, contextKey, pointedId);
54
+ if (invalid) return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} ${invalid}`, 'verify')] };
55
+ switch (attempt.outcome) {
56
+ case 'running': return { ready: false, reasons: [reason('ATTEMPT_RUNNING', `attempt ${id} is running`, 'wait, or run recover if its owner died')] };
57
+ case 'interrupted': return { ready: false, reasons: [reason('ATTEMPT_INTERRUPTED', `attempt ${id} was interrupted`, 'verify')] };
58
+ case 'timed_out': return { ready: false, reasons: [reason('ATTEMPT_TIMED_OUT', `attempt ${id} timed out after ${attempt.check && attempt.check.timeout_seconds} s`, 'fix or raise timeout, then verify')] };
59
+ case 'error': return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id}: ${attempt.error || 'could not be recorded'}`, 'inspect the record, then verify')] };
60
+ case 'failed': return { ready: false, reasons: [reason('CHECK_FAILED', `attempt ${id} failed (exit ${attempt.exit_code ?? attempt.signal ?? '?'})`, 'fix, then verify')] };
61
+ case 'passed': break;
62
+ default: return { ready: false, reasons: [reason('ATTEMPT_ERROR', `attempt ${id} has unknown outcome ${JSON.stringify(attempt.outcome)}`, 'verify')] };
63
+ }
64
+ if (current.prdRevision && attempt.context && attempt.context.prd_revision !== current.prdRevision) {
65
+ reasons.push(reason('REVISION_CHANGED', 'the PRD revision changed since the passing attempt', 'register --rebind, then verify'));
66
+ }
67
+ if (attempt.check && attempt.check.digest !== parse.checkDigest(text, timeout)) {
68
+ reasons.push(reason('CHECK_CHANGED', 'the Verification block or timeout changed since the passing attempt', 'verify'));
69
+ }
70
+ if (attempt.context && attempt.context.ticket_digest !== parse.ticketDigest(text)) {
71
+ reasons.push(reason('SOURCE_CHANGED', 'the ticket\'s authored content changed since the passing attempt', 'verify'));
72
+ }
73
+ if (current.sourceDigest && attempt.source && attempt.source.after !== current.sourceDigest) {
74
+ const shown = changedPaths.slice(0, 5).join(', ') + (changedPaths.length > 5 ? `, … (${changedPaths.length} paths)` : '');
75
+ reasons.push(reason('SOURCE_CHANGED', `source changed since the passing attempt${shown ? `: ${shown}` : ''}`, 'verify'));
76
+ }
77
+ const streams = ['stdout', 'stderr'];
78
+ if (streams.some(k => attempt.artifacts[k].missing)) {
79
+ reasons.push(reason('EVIDENCE_MISSING', 'the attempt\'s captured log is missing from local state', 'verify'));
80
+ }
81
+ const altered = streams.filter(k => attempt.artifacts[k].altered);
82
+ if (altered.length) {
83
+ reasons.push(reason('EVIDENCE_MISSING', `the attempt's captured ${altered.join(' and ')} log was altered after the run and no longer matches the recorded digest`, 'verify'));
84
+ }
85
+ if (parse.unticked(text).length) reasons.push(reason('CRITERIA_UNTICKED', 'unticked acceptance criteria', 'tick verified criteria'));
86
+ return { ready: reasons.length === 0, reasons };
87
+ }
88
+
89
+ module.exports = { reason, legacyTicketReadiness, migratedTicketReadiness };
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+ // PINCER runtime — the attempt runner (docs/runtime-contracts.md, "Attempts",
3
+ // "Capture and sanitization"). Persists a `running` record before launch (which
4
+ // supersedes prior readiness for the context immediately), snapshots inputs
5
+ // before and after, executes the check through `bash -eo pipefail` in its own
6
+ // process group with bounded sanitized capture, enforces the timeout with
7
+ // escalation, handles signals, and finalizes the record from what actually
8
+ // happened. Nothing here decides readiness; readiness.cjs reads the records.
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawn, execFileSync } = require('node:child_process');
13
+ const parse = require('./parse.cjs');
14
+ const source = require('./source.cjs');
15
+ const state = require('./state.cjs');
16
+ const { sanitizeLine, sanitizeText } = require('./sanitize.cjs');
17
+ const { nowIso } = require('./fsutil.cjs');
18
+
19
+ const CAPTURE_LIMIT = 1024 * 1024;
20
+ const PARTIAL_LINE_LIMIT = 64 * 1024;
21
+ const GRACE_MS = 5000;
22
+ const DRAIN_MS = 2000;
23
+ const RUNNER_ARGS = ['-eo', 'pipefail', '-c'];
24
+
25
+ let bashInfo = null;
26
+ function runnerInfo() {
27
+ if (bashInfo) return bashInfo;
28
+ let shell = 'bash', version = 'unknown';
29
+ try { shell = execFileSync('bash', ['-c', 'command -v bash'], { encoding: 'utf8' }).trim() || 'bash'; } catch { /* keep default */ }
30
+ try { version = execFileSync('bash', ['--version'], { encoding: 'utf8' }).split('\n')[0]; } catch { /* keep default */ }
31
+ bashInfo = { shell, args: RUNNER_ARGS, version };
32
+ return bashInfo;
33
+ }
34
+
35
+ // A bounded, sanitized capture of one stream into a file, echoed to `echo` when given.
36
+ class Capture {
37
+ constructor(file, echo) {
38
+ this.file = file; this.echo = echo;
39
+ this.fd = fs.openSync(file, 'w');
40
+ this.bytes = 0; this.dropped = 0; this.redactions = 0; this.truncated = false;
41
+ this.partial = ''; this.state = {}; this.failed = null;
42
+ }
43
+ write(chunk) {
44
+ this.partial += chunk.toString('utf8');
45
+ let index;
46
+ while ((index = this.partial.indexOf('\n')) !== -1) {
47
+ this.emit(this.partial.slice(0, index), true);
48
+ this.partial = this.partial.slice(index + 1);
49
+ }
50
+ if (this.partial.length > PARTIAL_LINE_LIMIT) { this.emit(this.partial, false); this.partial = ''; }
51
+ }
52
+ emit(line, newline) {
53
+ const { text, redactions } = sanitizeLine(line, this.state);
54
+ this.redactions += redactions;
55
+ const out = newline ? `${text}\n` : text;
56
+ if (this.echo) this.echo.write(out);
57
+ const buffer = Buffer.from(out, 'utf8');
58
+ if (this.bytes + buffer.length <= CAPTURE_LIMIT) {
59
+ try { fs.writeSync(this.fd, buffer); this.bytes += buffer.length; } catch (error) { this.failed = this.failed || error.message; }
60
+ } else {
61
+ if (!this.truncated) {
62
+ const room = CAPTURE_LIMIT - this.bytes;
63
+ if (room > 0) { try { fs.writeSync(this.fd, buffer.subarray(0, room)); this.bytes += room; } catch (error) { this.failed = this.failed || error.message; } }
64
+ this.truncated = true;
65
+ this.dropped += buffer.length - Math.max(room, 0);
66
+ } else this.dropped += buffer.length;
67
+ }
68
+ }
69
+ close() {
70
+ if (this.partial) { this.emit(this.partial, false); this.partial = ''; }
71
+ if (this.truncated) {
72
+ try { fs.writeSync(this.fd, `\n[pincer: truncated, ${this.dropped} more bytes not stored]\n`); } catch (error) { this.failed = this.failed || error.message; }
73
+ }
74
+ fs.closeSync(this.fd);
75
+ return { bytes: this.bytes, truncated: this.truncated, redactions: this.redactions, failed: this.failed };
76
+ }
77
+ }
78
+
79
+ // Run one attempt. `context` is the attempt context (kind, change, prd, prd_revision,
80
+ // base, ticket/ticket_digest or candidate/check); `commands` the block lines;
81
+ // `timeoutSeconds` the effective timeout; `echo` when the output should also reach
82
+ // the terminal. Returns { attempt } or { code, problem } for refusals before launch.
83
+ async function runAttempt({ root, context, commands, timeoutSeconds, command = 'verify', echo = true, declared = {} }) {
84
+ const block = commands.length ? `${commands.join('\n')}\n` : '';
85
+ const checkDigest = parse.sha256(`${block}timeout=${timeoutSeconds}\n`);
86
+ const display = sanitizeText(block).text.slice(0, 2000);
87
+ const before = source.snapshot(root);
88
+ if (before.problems.length) return { code: before.problems[0].code, problem: before.problems.map(p => `${p.code}: ${p.detail}`).join('\n'), problems: before.problems };
89
+
90
+ const key = state.contextKey(context);
91
+ let attempt, logDir, relLogDir;
92
+ try {
93
+ state.withLock(root, () => {
94
+ const read = state.readIndex(root);
95
+ if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
96
+ const index = read.index;
97
+ const sequence = index.sequence + 1;
98
+ const id = state.attemptId(sequence);
99
+ source.storeManifest(root, before);
100
+ relLogDir = `${state.RUNTIME_DIR}/attempts/${id}`;
101
+ logDir = path.join(root, relLogDir);
102
+ fs.mkdirSync(logDir, { recursive: true });
103
+ attempt = {
104
+ schema: 1, runtime: 1, id, sequence, context,
105
+ check: { digest: checkDigest, display, timeout_seconds: timeoutSeconds },
106
+ outcome: 'running', exit_code: null, signal: null,
107
+ runner: runnerInfo(), cwd: '.',
108
+ environment: { os: `${os.platform()} ${os.release()}`, node: process.version, declared },
109
+ started: nowIso(), finished: null,
110
+ source: { before: before.digest, after: null, files: before.files.length, limitations: before.limitations },
111
+ artifacts: { stdout: { path: `${relLogDir}/stdout.log`, sha256: null, bytes: 0, truncated: false, redactions: 0 }, stderr: { path: `${relLogDir}/stderr.log`, sha256: null, bytes: 0, truncated: false, redactions: 0 } },
112
+ owner: { pid: process.pid, ppid: process.ppid, host: os.hostname() }, child: null,
113
+ limitations: [],
114
+ };
115
+ state.writeAttempt(root, attempt);
116
+ index.sequence = sequence;
117
+ index.current[key] = id;
118
+ index.running.push(id);
119
+ state.writeIndex(root, index);
120
+ }, { command });
121
+ } catch (error) {
122
+ if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
123
+ if (error.code === 'INVALID') return { code: 'INPUT_INVALID', problem: error.message };
124
+ return { code: 'ATTEMPT_ERROR', problem: `cannot persist the attempt record: ${error.message}` };
125
+ }
126
+
127
+ // Execute in a fresh process group so timeouts and signals reach every descendant.
128
+ let child, launchError = null;
129
+ const stdout = new Capture(path.join(logDir, 'stdout.log'), echo ? process.stdout : null);
130
+ const stderr = new Capture(path.join(logDir, 'stderr.log'), echo ? process.stderr : null);
131
+ let timedOut = false, interruptedBy = null, abandoned = false, killTimer = null, graceTimer = null, drainTimer = null, settle = null;
132
+ const sent = [];
133
+ // Signal the whole group, whether or not the shell itself has exited: a
134
+ // background child that inherited the output pipes keeps the run alive and
135
+ // must be terminated the same way. The bare pid is a fallback only while the
136
+ // shell is known to be alive (after it is reaped the pid may be reused). The
137
+ // group id itself can be reused only once every member is gone; the window
138
+ // between the two attempts is a documented limit, not something detected here.
139
+ // `sent` records only signals a kill call delivered.
140
+ const signalGroup = signal => {
141
+ try { process.kill(-child.pid, signal); sent.push(signal); return; } catch { /* no group left */ }
142
+ if (child.exitCode === null && child.signalCode === null) { try { process.kill(child.pid, signal); sent.push(signal); } catch { /* gone */ } }
143
+ };
144
+ let terminating = false;
145
+ const terminate = () => {
146
+ if (!child || !child.pid || terminating) return;
147
+ terminating = true;
148
+ signalGroup('SIGTERM');
149
+ graceTimer = setTimeout(() => {
150
+ signalGroup('SIGKILL');
151
+ // Bound the wait for the pipes to close: a descendant that survives
152
+ // SIGKILL (or was never reachable) must not hold the run open forever.
153
+ drainTimer = setTimeout(() => {
154
+ abandoned = true;
155
+ try { child.stdout.destroy(); child.stderr.destroy(); child.unref(); } catch { /* best effort */ }
156
+ settle({ code: child.exitCode, signal: child.signalCode });
157
+ }, DRAIN_MS);
158
+ }, GRACE_MS);
159
+ };
160
+ const onSignal = signal => { interruptedBy = signal; terminate(); };
161
+ const exit = await new Promise(resolve => {
162
+ let settled = false;
163
+ settle = result => { if (!settled) { settled = true; resolve(result); } };
164
+ try {
165
+ child = spawn(runnerInfo().shell, [...RUNNER_ARGS, block], { cwd: root, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
166
+ } catch (error) { launchError = error.message; return settle({ code: null, signal: null }); }
167
+ child.on('error', error => { launchError = error.message; });
168
+ if (child.pid) {
169
+ attempt.child = { pid: child.pid };
170
+ try { state.withLock(root, () => state.writeAttempt(root, attempt), { command }); } catch { /* the final write records it */ }
171
+ }
172
+ child.stdout.on('data', chunk => stdout.write(chunk));
173
+ child.stderr.on('data', chunk => stderr.write(chunk));
174
+ process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal);
175
+ killTimer = setTimeout(() => { timedOut = true; terminate(); }, timeoutSeconds * 1000);
176
+ child.on('close', (code, signal) => settle({ code, signal }));
177
+ });
178
+ clearTimeout(killTimer); clearTimeout(graceTimer); clearTimeout(drainTimer);
179
+ process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal);
180
+ const outInfo = stdout.close(), errInfo = stderr.close();
181
+ const termination = `${sent.length ? `the child process group was sent ${sent.join(' then ')}` : 'no reachable process remained in the child\'s group (the shell had exited and its descendants left the group)'}${abandoned ? `; output capture was abandoned ${DRAIN_MS / 1000} s after the SIGKILL point and a descendant may still be running` : ''}`;
182
+
183
+ const after = source.snapshot(root);
184
+ const changed = after.digest && before.digest !== after.digest ? source.diffManifests(before, after) : [];
185
+ attempt.finished = nowIso();
186
+ attempt.exit_code = exit.code;
187
+ attempt.signal = exit.signal;
188
+ attempt.source.after = after.digest;
189
+ for (const [name, info] of [['stdout', outInfo], ['stderr', errInfo]]) {
190
+ const file = path.join(logDir, `${name}.log`);
191
+ let sha = null;
192
+ try { sha = parse.sha256(fs.readFileSync(file)); } catch { sha = null; }
193
+ attempt.artifacts[name] = { ...attempt.artifacts[name], sha256: sha, bytes: info.bytes, truncated: info.truncated, redactions: info.redactions };
194
+ }
195
+ const captureFailure = outInfo.failed || errInfo.failed;
196
+ if (launchError) { attempt.outcome = 'error'; attempt.error = `cannot launch the check: ${launchError}`; }
197
+ else if (interruptedBy) { attempt.outcome = 'interrupted'; attempt.limitations.push(`interrupted by ${interruptedBy}; ${termination}`); }
198
+ else if (timedOut) { attempt.outcome = 'timed_out'; attempt.limitations.push(`terminated after ${timeoutSeconds} s; ${termination}`); }
199
+ else if (captureFailure) { attempt.outcome = 'error'; attempt.error = `capture failed: ${captureFailure}`; }
200
+ else if (after.problems.length) { attempt.outcome = 'error'; attempt.error = `source view invalid after the run: ${after.problems[0].code} ${after.problems[0].detail}`; }
201
+ else if (exit.code === 0 && changed.length) { attempt.outcome = 'error'; attempt.error = `SOURCE_CHANGED: the check mutated source: ${changed.slice(0, 5).join(', ')}${changed.length > 5 ? ` (+${changed.length - 5})` : ''}`; }
202
+ else if (exit.code === 0) attempt.outcome = 'passed';
203
+ else attempt.outcome = 'failed';
204
+ if (changed.length && attempt.outcome !== 'error') attempt.limitations.push(`source changed during the run: ${changed.slice(0, 5).join(', ')}`);
205
+ if (after.digest) { try { source.storeManifest(root, after); } catch { /* best effort; the digest is recorded */ } }
206
+
207
+ try {
208
+ state.withLock(root, () => {
209
+ const read = state.readIndex(root);
210
+ if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
211
+ const index = read.index;
212
+ state.writeAttempt(root, attempt);
213
+ index.running = index.running.filter(id => id !== attempt.id);
214
+ state.writeIndex(root, index);
215
+ }, { command });
216
+ } catch (error) {
217
+ return { code: 'ATTEMPT_ERROR', problem: `attempt ${attempt.id} finished ${attempt.outcome} but the record could not be finalized: ${error.message}`, attempt };
218
+ }
219
+ return { attempt, changed };
220
+ }
221
+
222
+ const exitFor = attempt => ({ passed: 0, failed: 1, timed_out: 124, interrupted: 130, error: 4 })[attempt.outcome] ?? 4;
223
+
224
+ module.exports = { runAttempt, exitFor, CAPTURE_LIMIT, GRACE_MS, DRAIN_MS, runnerInfo };