pincer-workflow 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +9 -7
  2. package/bin/pincer.js +58 -5
  3. package/package.json +3 -3
  4. package/template/.agents/skills/pincer-code/SKILL.md +117 -12
  5. package/template/.agents/skills/pincer-evaluate/SKILL.md +57 -10
  6. package/template/.agents/skills/pincer-narrow/SKILL.md +48 -8
  7. package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
  8. package/template/.agents/skills/pincer-release/SKILL.md +32 -3
  9. package/template/.agents/skills/pincer-status/SKILL.md +25 -2
  10. package/template/.claude/commands/pincer-code.md +117 -12
  11. package/template/.claude/commands/pincer-evaluate.md +57 -10
  12. package/template/.claude/commands/pincer-narrow.md +48 -8
  13. package/template/.claude/commands/pincer-plan.md +12 -4
  14. package/template/.claude/commands/pincer-release.md +32 -3
  15. package/template/.claude/commands/pincer-status.md +25 -2
  16. package/template/.claude/hooks/hook-policy.cjs +24 -3
  17. package/template/.claude/references/prd-template.md +11 -4
  18. package/template/.claude/references/ticket-template.md +4 -0
  19. package/template/.codex/README.md +3 -2
  20. package/template/.github/prompts/pincer-code.prompt.md +117 -12
  21. package/template/.github/prompts/pincer-evaluate.prompt.md +57 -10
  22. package/template/.github/prompts/pincer-narrow.prompt.md +48 -8
  23. package/template/.github/prompts/pincer-plan.prompt.md +12 -4
  24. package/template/.github/prompts/pincer-release.prompt.md +32 -3
  25. package/template/.github/prompts/pincer-status.prompt.md +25 -2
  26. package/template/AGENTS.md +22 -0
  27. package/template/docs/dry-run-checklist.md +70 -6
  28. package/template/docs/release-checklist.md +5 -2
  29. package/template/docs/runtime-contracts.md +1683 -0
  30. package/template/scripts/pincer-evidence.cjs +13 -229
  31. package/template/scripts/pincer-runtime/adopt.cjs +132 -0
  32. package/template/scripts/pincer-runtime/agreement.cjs +240 -0
  33. package/template/scripts/pincer-runtime/authorization.cjs +167 -0
  34. package/template/scripts/pincer-runtime/changes.cjs +517 -0
  35. package/template/scripts/pincer-runtime/checks.cjs +48 -0
  36. package/template/scripts/pincer-runtime/coverage.cjs +361 -0
  37. package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
  38. package/template/scripts/pincer-runtime/evidence.cjs +676 -0
  39. package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
  40. package/template/scripts/pincer-runtime/gates.cjs +73 -0
  41. package/template/scripts/pincer-runtime/identity.cjs +163 -0
  42. package/template/scripts/pincer-runtime/impact.cjs +177 -0
  43. package/template/scripts/pincer-runtime/io.cjs +41 -0
  44. package/template/scripts/pincer-runtime/lifecycle.cjs +311 -0
  45. package/template/scripts/pincer-runtime/locator.cjs +158 -0
  46. package/template/scripts/pincer-runtime/migrate.cjs +204 -0
  47. package/template/scripts/pincer-runtime/parse.cjs +316 -0
  48. package/template/scripts/pincer-runtime/phases.cjs +245 -0
  49. package/template/scripts/pincer-runtime/readiness.cjs +97 -0
  50. package/template/scripts/pincer-runtime/requirements.cjs +255 -0
  51. package/template/scripts/pincer-runtime/resume.cjs +205 -0
  52. package/template/scripts/pincer-runtime/routing.cjs +54 -0
  53. package/template/scripts/pincer-runtime/runner.cjs +242 -0
  54. package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
  55. package/template/scripts/pincer-runtime/source.cjs +129 -0
  56. package/template/scripts/pincer-runtime/state.cjs +314 -0
  57. package/template/scripts/pincer-runtime/status.cjs +514 -0
  58. package/template/scripts/pincer-runtime/transaction.cjs +200 -0
  59. package/template/scripts/pincer-runtime/transitions.cjs +134 -0
  60. package/template/scripts/pincer-runtime.cjs +661 -0
  61. package/template/scripts/pincer-status.sh +11 -162
  62. package/template/scripts/pincer-ticket.sh +19 -139
  63. package/template/scripts/pincer-ticket-lib.sh +0 -321
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+ // PINCER runtime — the source manifest (docs/runtime-contracts.md, "Source
3
+ // manifest"): a versioned SHA-256 identity of the inputs a verification ran
4
+ // against. Tracked plus untracked non-ignored files, modes, deletions; tickets
5
+ // and PRDs normalized; fixed and configured exclusions; secret paths, symlinks
6
+ // and submodules refused rather than silently omitted.
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const parse = require('./parse.cjs');
10
+ const { atomicWrite, tryGit } = require('./fsutil.cjs');
11
+
12
+ const SCHEMA = 1;
13
+ const FIXED_EXCLUDES = ['.git/', '.pincer/', 'NOTES.md', '.prd/evidence/', '.prd/changes/'];
14
+ const EXCLUDE_FILE = '.prd/source-exclude';
15
+ const PROTECTED_PREFIXES = ['tickets/'];
16
+ const NUL = String.fromCharCode(0);
17
+
18
+ const isTicket = p => /^tickets\/T-[0-9]+.*\.md$/.test(p);
19
+ const isPrd = p => parse.PRD_REF.test(p);
20
+ const isSecret = p => { const b = path.posix.basename(p); return b === '.env' || (b.startsWith('.env.') && b !== '.env.example'); };
21
+ const fixedExcluded = p => FIXED_EXCLUDES.some(rule => (rule.endsWith('/') ? p.startsWith(rule) : p === rule));
22
+
23
+ // Glob -> RegExp: `**` spans directories, `*` stays within a segment, `?` is one
24
+ // character; a pattern without `/` matches a basename anywhere; a trailing `/`
25
+ // matches a directory prefix.
26
+ function compilePattern(pattern) {
27
+ const escape = s => s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
28
+ const glob = s => escape(s).replace(/\*\*/g, 'DOUBLESTAR').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/DOUBLESTAR/g, '.*');
29
+ if (pattern.endsWith('/')) return new RegExp(`^${glob(pattern.slice(0, -1))}/`);
30
+ if (!pattern.includes('/')) return new RegExp(`(^|/)${glob(pattern)}$`);
31
+ return new RegExp(`^${glob(pattern.replace(/^\//, ''))}$`);
32
+ }
33
+ function readExcludeFile(root) {
34
+ const file = path.join(root, EXCLUDE_FILE);
35
+ if (!fs.existsSync(file)) return [];
36
+ return fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
37
+ }
38
+
39
+ const splitZ = buffer => (buffer ? buffer.toString('utf8').split(NUL).filter(Boolean) : []);
40
+
41
+ // Compute the manifest. Returns { schema, digest, files, excluded, limitations, problems }.
42
+ function snapshot(root) {
43
+ const problems = [];
44
+ const limitations = [];
45
+ const excluded = [];
46
+ const toplevel = tryGit(root, ['rev-parse', '--show-toplevel']);
47
+ if (toplevel.error) {
48
+ return { schema: SCHEMA, digest: null, files: [], excluded, limitations, problems: [{ code: 'UNSUPPORTED_INPUT', detail: 'not inside a git repository; the source identity needs git' }] };
49
+ }
50
+ const tracked = new Map(); // path -> mode from the index
51
+ const staged = tryGit(root, ['ls-files', '-z', '-s'], { buffer: true });
52
+ if (staged.error) problems.push({ code: 'UNSUPPORTED_INPUT', detail: `git ls-files failed: ${staged.error}` });
53
+ else {
54
+ for (const entry of splitZ(staged.out)) {
55
+ const match = entry.match(/^(\d{6}) [0-9a-f]{40} \d\t([\s\S]*)$/);
56
+ if (match) tracked.set(match[2], match[1]);
57
+ }
58
+ }
59
+ const listed = tryGit(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { buffer: true });
60
+ const paths = listed.error ? [] : [...new Set(splitZ(listed.out))];
61
+ const deleted = new Set(splitZ(tryGit(root, ['ls-files', '-z', '--deleted'], { buffer: true }).out));
62
+ const ignoredDirs = splitZ(tryGit(root, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'], { buffer: true }).out)
63
+ .filter(p => !p.startsWith('.pincer/') && p !== '.pincer/');
64
+ if (ignoredDirs.length) {
65
+ const shown = ignoredDirs.slice(0, 10).join(', ') + (ignoredDirs.length > 10 ? `, ... (${ignoredDirs.length} ignored paths)` : '');
66
+ limitations.push(`ignored paths are not part of the source identity: ${shown}`);
67
+ }
68
+ limitations.push('external services and installed toolchains are not part of the source identity');
69
+
70
+ const patterns = readExcludeFile(root).map(pattern => ({ pattern, regex: compilePattern(pattern) }));
71
+ const entries = [];
72
+ for (const p of paths) {
73
+ if (fixedExcluded(p)) { excluded.push(p); continue; }
74
+ if (isSecret(p)) { problems.push({ code: 'SECRET_PATH', detail: `${p}: secret file in the source view; remove it or ignore it (its contents were not read)` }); continue; }
75
+ const mode = tracked.get(p);
76
+ if (mode === '160000') { problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${p}: submodules are not supported` }); continue; }
77
+ const abs = path.join(root, p);
78
+ let stat = null;
79
+ try { stat = fs.lstatSync(abs); } catch { stat = null; }
80
+ if (mode === '120000' || (stat && stat.isSymbolicLink())) { problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${p}: symbolic links are not supported` }); continue; }
81
+ const rule = patterns.find(({ regex }) => regex.test(p));
82
+ if (rule) {
83
+ if (PROTECTED_PREFIXES.some(prefix => p.startsWith(prefix)) || isPrd(p) || p === EXCLUDE_FILE) problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${EXCLUDE_FILE}: pattern "${rule.pattern}" matches protected path ${p}` });
84
+ else if (tracked.has(p)) problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${EXCLUDE_FILE}: pattern "${rule.pattern}" matches tracked file ${p}; exclusions may cover untracked paths only` });
85
+ else excluded.push(p);
86
+ continue;
87
+ }
88
+ if (deleted.has(p) || !stat) { entries.push({ path: p, deleted: true }); continue; }
89
+ if (stat.isDirectory()) continue;
90
+ let content;
91
+ try { content = fs.readFileSync(abs); } catch (error) { problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${p}: cannot read (${error.code || error.message})` }); continue; }
92
+ let sha;
93
+ if (isTicket(p)) sha = parse.sha256(parse.normalizeTicket(content.toString('utf8')));
94
+ else if (isPrd(p)) sha = parse.sha256(parse.normalizePrd(content.toString('utf8')));
95
+ else sha = parse.sha256(content);
96
+ entries.push({ path: p, sha256: sha, mode: (stat.mode & 0o111) ? '100755' : '100644' });
97
+ }
98
+ entries.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)));
99
+ const digestInput = entries.map(e => `${e.deleted ? 'deleted' : e.mode} ${e.deleted ? '-' : e.sha256} ${e.path}\n`).join('');
100
+ const digest = problems.length ? null : parse.sha256(digestInput);
101
+ return { schema: SCHEMA, digest, files: entries, excluded, limitations, problems };
102
+ }
103
+
104
+ function storeManifest(root, manifest) {
105
+ if (!manifest.digest) throw new Error('cannot store a manifest with problems');
106
+ const file = path.join(root, '.pincer', 'runtime', 'manifests', `${manifest.digest}.json`);
107
+ if (!fs.existsSync(file)) atomicWrite(file, `${JSON.stringify(manifest, null, 2)}\n`, { journalDir: path.join(root, '.pincer', 'runtime', 'journal') });
108
+ return `.pincer/runtime/manifests/${manifest.digest}.json`;
109
+ }
110
+ function readManifest(root, digest) {
111
+ const file = path.join(root, '.pincer', 'runtime', 'manifests', `${digest}.json`);
112
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
113
+ }
114
+
115
+ // Paths that differ between two manifests, for diagnostics.
116
+ function diffManifests(before, after) {
117
+ const a = new Map(before.files.map(e => [e.path, e]));
118
+ const b = new Map(after.files.map(e => [e.path, e]));
119
+ const changed = [];
120
+ for (const [p, e] of b) {
121
+ const prior = a.get(p);
122
+ if (!prior) changed.push(`${p} (added)`);
123
+ else if (JSON.stringify(prior) !== JSON.stringify(e)) changed.push(`${p}${e.deleted ? ' (deleted)' : prior.mode !== e.mode ? ' (mode)' : ''}`);
124
+ }
125
+ for (const p of a.keys()) if (!b.has(p)) changed.push(`${p} (removed)`);
126
+ return changed;
127
+ }
128
+
129
+ module.exports = { SCHEMA, FIXED_EXCLUDES, EXCLUDE_FILE, snapshot, storeManifest, readManifest, diffManifests, compilePattern, isSecret };
@@ -0,0 +1,314 @@
1
+ 'use strict';
2
+ // PINCER runtime — local state store (docs/runtime-contracts.md, "Attempts").
3
+ // Everything under <root>/.pincer/runtime/ belongs to this worktree: an index
4
+ // with the authoritative sequence and current pointers, one record per attempt,
5
+ // content-addressed source manifests, an exclusive lock directory and a journal
6
+ // for atomic replacement. Nothing here is a second editable truth.
7
+ const fs = require('node:fs');
8
+ const os = require('node:os');
9
+ const path = require('node:path');
10
+ const crypto = require('node:crypto');
11
+ const { nowIso, atomicWrite, readJson } = require('./fsutil.cjs');
12
+ const io = require('./io.cjs');
13
+
14
+ const RUNTIME_DIR = '.pincer/runtime';
15
+ const INDEX_SCHEMA = 1;
16
+ const LOCK_WAIT_MS = 10000;
17
+ const LOCK_POLL_MS = 100;
18
+ const GRACE_MS = 5000;
19
+
20
+ function paths(root) {
21
+ const dir = path.join(root, RUNTIME_DIR);
22
+ return {
23
+ dir,
24
+ index: path.join(dir, 'index.json'),
25
+ attempts: path.join(dir, 'attempts'),
26
+ manifests: path.join(dir, 'manifests'),
27
+ lock: path.join(dir, 'lock'),
28
+ owner: path.join(dir, 'lock', 'owner.json'),
29
+ journal: path.join(dir, 'journal'),
30
+ };
31
+ }
32
+ function ensureLayout(root) {
33
+ const p = paths(root);
34
+ for (const dir of [p.dir, p.attempts, p.manifests, p.journal]) fs.mkdirSync(dir, { recursive: true });
35
+ return p;
36
+ }
37
+ const exists = root => fs.existsSync(paths(root).dir);
38
+ // Local attempt history exists once an index was written; a selection alone (a fresh
39
+ // clone that ran `change select`) is not attempt history.
40
+ const hasIndex = root => fs.existsSync(paths(root).index);
41
+
42
+ const emptyIndex = () => ({ schema: INDEX_SCHEMA, sequence: 0, current: {}, running: [] });
43
+ function validateIndex(doc) {
44
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return 'index must be a JSON object';
45
+ if (doc.schema !== INDEX_SCHEMA) return `unsupported index schema ${JSON.stringify(doc.schema)}`;
46
+ if (!Number.isInteger(doc.sequence) || doc.sequence < 0) return 'sequence must be a non-negative integer';
47
+ if (!doc.current || typeof doc.current !== 'object' || Array.isArray(doc.current)) return 'current must be an object';
48
+ if (!Array.isArray(doc.running) || !doc.running.every(id => typeof id === 'string')) return 'running must be an array of attempt IDs';
49
+ return null;
50
+ }
51
+ // Read the index; a missing file is an empty index, a malformed one is an error
52
+ // (code INVALID) and is never overwritten by inspection.
53
+ function readIndex(root) {
54
+ const p = paths(root);
55
+ if (!fs.existsSync(p.index)) return { index: emptyIndex(), missing: true };
56
+ const read = readJson(p.index);
57
+ if (read.error) return { error: `${RUNTIME_DIR}/index.json: ${read.error}`, code: 'INVALID' };
58
+ const invalid = validateIndex(read.data);
59
+ if (invalid) return { error: `${RUNTIME_DIR}/index.json: ${invalid}`, code: 'INVALID' };
60
+ return { index: read.data };
61
+ }
62
+ function writeIndex(root, index) {
63
+ const p = ensureLayout(root);
64
+ atomicWrite(p.index, `${JSON.stringify(index, null, 2)}\n`, { journalDir: p.journal });
65
+ }
66
+
67
+ function isAlive(pid) {
68
+ if (!Number.isInteger(pid) || pid <= 0) return false;
69
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
70
+ }
71
+ function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
72
+
73
+ class StateBusy extends Error {
74
+ constructor(message, owner) { super(message); this.code = 'STATE_BUSY'; this.owner = owner; }
75
+ }
76
+
77
+ // Acquire the exclusive lock: mkdir is atomic; a lock whose owner pid is dead on
78
+ // this host is reclaimed with a diagnostic; a live or foreign-host owner is never
79
+ // stolen. Returns a release function.
80
+ function acquireLock(root, { waitMs, command = 'runtime', log = message => io.err(`${message}\n`) } = {}) {
81
+ const p = ensureLayout(root);
82
+ const bound = waitMs ?? (Number(process.env.PINCER_LOCK_WAIT_MS) > 0 ? Number(process.env.PINCER_LOCK_WAIT_MS) : LOCK_WAIT_MS);
83
+ const deadline = Date.now() + bound;
84
+ const ownerJson = () => `${JSON.stringify({ pid: process.pid, ppid: process.ppid, host: os.hostname(), started: nowIso(), command }, null, 2)}\n`;
85
+ for (;;) {
86
+ // Build the lock directory with its owner file in a private location and
87
+ // rename it into place: a directory rename onto an existing lock fails, so
88
+ // acquisition is atomic and a waiter never sees an owner-less lock.
89
+ const staging = `${p.lock}.new.${process.pid}.${crypto.randomBytes(3).toString('hex')}`;
90
+ try {
91
+ fs.mkdirSync(staging);
92
+ fs.writeFileSync(path.join(staging, 'owner.json'), ownerJson());
93
+ fs.renameSync(staging, p.lock);
94
+ let released = false;
95
+ return () => { if (released) return; released = true; try { fs.rmSync(p.lock, { recursive: true, force: true }); } catch { /* already gone */ } };
96
+ } catch (error) {
97
+ try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* nothing staged */ }
98
+ if (!['EEXIST', 'ENOTEMPTY', 'EISDIR', 'EPERM'].includes(error.code)) throw error;
99
+ }
100
+ const owner = readJson(p.owner).data || null;
101
+ if (owner && owner.host === os.hostname() && !isAlive(owner.pid)) {
102
+ // Claim the stale directory by renaming it first; only the process that
103
+ // won the rename removes it, after confirming the owner is still the
104
+ // dead one it read (another waiter may have replaced the lock meanwhile).
105
+ const claim = `${p.lock}.stale.${process.pid}.${crypto.randomBytes(3).toString('hex')}`;
106
+ try { fs.renameSync(p.lock, claim); } catch { sleep(LOCK_POLL_MS); continue; }
107
+ const claimed = readJson(path.join(claim, 'owner.json')).data || null;
108
+ if (claimed && claimed.host === os.hostname() && !isAlive(claimed.pid)) {
109
+ log(`pincer: reclaiming stale lock left by pid ${claimed.pid} (${claimed.command || 'unknown command'}, started ${claimed.started || '?'}); the process is no longer running`);
110
+ try { fs.rmSync(claim, { recursive: true, force: true }); } catch { /* best effort */ }
111
+ } else {
112
+ // A live holder's lock was renamed by mistake: give it back.
113
+ try { fs.renameSync(claim, p.lock); } catch { try { fs.rmSync(claim, { recursive: true, force: true }); } catch { /* gone */ } }
114
+ }
115
+ continue;
116
+ }
117
+ if (Date.now() >= deadline) {
118
+ const who = owner ? `pid ${owner.pid} on ${owner.host} (${owner.command || 'unknown command'}, started ${owner.started || '?'})` : 'an unknown owner (no owner.json)';
119
+ throw new StateBusy(`${RUNTIME_DIR}/lock is held by ${who}; retry, or run recover if that process died`, owner);
120
+ }
121
+ sleep(LOCK_POLL_MS);
122
+ }
123
+ }
124
+ function withLock(root, fn, options) {
125
+ const release = acquireLock(root, options);
126
+ try { return fn(); } finally { release(); }
127
+ }
128
+
129
+ const compactTimestamp = () => nowIso().replace(/[-:]/g, '');
130
+ const attemptId = sequence => `${String(sequence).padStart(6, '0')}-${compactTimestamp()}-${crypto.randomBytes(3).toString('hex')}`;
131
+ // Context keys (docs/runtime-contracts.md, "Attempts"): ticket keys are change-scoped
132
+ // in every runtime mode; candidate keys gain the change in changes mode (schema 2
133
+ // records) so two changes sharing a check ID and a candidate never share a pointer.
134
+ const contextKey = context => (context.kind === 'candidate'
135
+ ? (context.mode === 'changes' || context.agreement ? `candidate:${context.change}:${context.candidate}:${context.check}` : `candidate:${context.candidate}:${context.check}`)
136
+ : `ticket:${context.change}:${context.ticket}`);
137
+
138
+ const OUTCOMES = ['running', 'passed', 'failed', 'interrupted', 'timed_out', 'error'];
139
+ const SHA256 = /^[0-9a-f]{64}$/;
140
+ // Validate an attempt record read from disk against record schema 1 and, when
141
+ // given, the context `key` it is read for and the `pointedId` the index names.
142
+ // A record that is incomplete, malformed, written for another context or
143
+ // carrying another id than the pointer is never evidence: readiness reports
144
+ // ATTEMPT_ERROR and export refuses. An `interrupted` record may lack log
145
+ // digests (a `recover` that predates their recording). Returns null or a problem.
146
+ function validateAttempt(a, key, pointedId) {
147
+ const obj = v => v && typeof v === 'object' && !Array.isArray(v);
148
+ const str = v => typeof v === 'string' && v.length > 0;
149
+ const digestOrNull = v => v === null || (typeof v === 'string' && SHA256.test(v));
150
+ if (!obj(a)) return 'record is not a JSON object';
151
+ const bad = [];
152
+ // Schema 1 (migrated mode), schema 2 (changes mode: the agreement digest is part
153
+ // of the context) and schema 3 (strict coverage: inventory and coverage digests
154
+ // too) share every other field.
155
+ if (![1, 2, 3].includes(a.schema)) bad.push('schema');
156
+ if (a.runtime !== a.schema) bad.push('runtime');
157
+ if (!str(a.id)) bad.push('id');
158
+ if (!Number.isInteger(a.sequence) || a.sequence < 1) bad.push('sequence');
159
+ const c = a.context;
160
+ if (!obj(c) || !['ticket', 'candidate'].includes(c.kind) || !str(c.change) || !str(c.prd) || !str(c.prd_revision) || !str(c.base)
161
+ || (c.kind === 'ticket' ? !str(c.ticket) || !str(c.ticket_digest) : !str(c.candidate) || !str(c.check))
162
+ || (a.schema === 1 ? 'agreement' in c || 'inventory' in c || 'coverage' in c
163
+ : !(typeof c.agreement === 'string' && SHA256.test(c.agreement))
164
+ || (a.schema === 2 ? 'inventory' in c || 'coverage' in c : !(typeof c.inventory === 'string' && SHA256.test(c.inventory) && typeof c.coverage === 'string' && SHA256.test(c.coverage))))) bad.push('context');
165
+ if (!obj(a.check) || typeof a.check.digest !== 'string' || !SHA256.test(a.check.digest) || typeof a.check.display !== 'string'
166
+ || !Number.isInteger(a.check.timeout_seconds) || a.check.timeout_seconds <= 0) bad.push('check');
167
+ if (!OUTCOMES.includes(a.outcome)) bad.push('outcome');
168
+ const finished = OUTCOMES.includes(a.outcome) && a.outcome !== 'running';
169
+ if (!(a.exit_code === null || Number.isInteger(a.exit_code))) bad.push('exit_code');
170
+ if (!(a.signal === null || str(a.signal))) bad.push('signal');
171
+ if (!obj(a.runner) || !str(a.runner.shell) || !Array.isArray(a.runner.args)) bad.push('runner');
172
+ if (!str(a.cwd)) bad.push('cwd');
173
+ if (!obj(a.environment)) bad.push('environment');
174
+ if (!str(a.started)) bad.push('started');
175
+ if (finished ? !str(a.finished) : a.finished !== null) bad.push('finished');
176
+ if (!obj(a.source) || !digestOrNull(a.source.before) || !digestOrNull(a.source.after)) bad.push('source');
177
+ if (!obj(a.artifacts)) bad.push('artifacts');
178
+ else {
179
+ for (const k of ['stdout', 'stderr']) {
180
+ const info = a.artifacts[k];
181
+ const expectedPath = str(a.id) ? `${RUNTIME_DIR}/attempts/${a.id}/${k}.log` : null;
182
+ const digestRequired = finished && a.outcome !== 'interrupted';
183
+ if (!obj(info) || info.path !== expectedPath || !(digestRequired ? typeof info.sha256 === 'string' && SHA256.test(info.sha256) : digestOrNull(info.sha256))) bad.push(`artifacts.${k}`);
184
+ }
185
+ }
186
+ if (bad.length) return `record is incomplete or malformed: ${bad.join(', ')}`;
187
+ if (key && contextKey(c) !== key) return `record belongs to ${contextKey(c)}, not ${key}`;
188
+ if (pointedId && a.id !== pointedId) return `record ${a.id} is not the attempt the index points at (${pointedId})`;
189
+ return null;
190
+ }
191
+ // Compare an attempt's captured logs with local state: `missing` when a log is
192
+ // gone, `altered` when its content no longer matches the digest the record
193
+ // carries. Annotates and returns the record; never writes.
194
+ function inspectArtifacts(root, attempt) {
195
+ if (!attempt || !attempt.artifacts || typeof attempt.artifacts !== 'object') return attempt;
196
+ for (const k of ['stdout', 'stderr']) {
197
+ const info = attempt.artifacts[k];
198
+ if (!info || typeof info !== 'object' || typeof info.path !== 'string') continue;
199
+ let data;
200
+ try { data = fs.readFileSync(path.join(root, info.path)); } catch { attempt.artifacts[k] = { ...info, missing: true }; continue; }
201
+ if (attempt.outcome !== 'running' && typeof info.sha256 === 'string' && crypto.createHash('sha256').update(data).digest('hex') !== info.sha256) attempt.artifacts[k] = { ...info, altered: true };
202
+ }
203
+ return attempt;
204
+ }
205
+
206
+ function attemptFile(root, id) { return path.join(paths(root).attempts, `${id}.json`); }
207
+ function writeAttempt(root, attempt) {
208
+ const p = ensureLayout(root);
209
+ atomicWrite(attemptFile(root, attempt.id), `${JSON.stringify(attempt, null, 2)}\n`, { journalDir: p.journal });
210
+ }
211
+ function readAttempt(root, id) {
212
+ const read = readJson(attemptFile(root, id));
213
+ if (read.error) return { error: `${RUNTIME_DIR}/attempts/${id}.json: ${read.error}` };
214
+ return { attempt: read.data };
215
+ }
216
+ function listAttempts(root, key) {
217
+ const p = paths(root);
218
+ if (!fs.existsSync(p.attempts)) return [];
219
+ const out = [];
220
+ for (const name of fs.readdirSync(p.attempts)) {
221
+ if (!name.endsWith('.json') || name.startsWith('.')) continue;
222
+ const read = readJson(path.join(p.attempts, name));
223
+ if (read.error || !read.data || typeof read.data !== 'object') continue;
224
+ if (!key || contextKey(read.data.context || {}) === key) out.push(read.data);
225
+ }
226
+ return out.sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
227
+ }
228
+ // The attempt the index points at for a context. The pointer is the authority:
229
+ // a pointed-at record that is missing or unreadable yields null (readiness then
230
+ // reports EVIDENCE_MISSING) rather than an older record that may have passed.
231
+ // Only when the index carries no pointer at all is the highest sequence used.
232
+ function latestAttempt(root, key, index) {
233
+ const idx = index || readIndex(root).index;
234
+ const pointed = idx && idx.current && idx.current[key];
235
+ if (pointed) {
236
+ const read = readAttempt(root, pointed);
237
+ return read.attempt || null;
238
+ }
239
+ const all = listAttempts(root, key);
240
+ return all.length ? all[all.length - 1] : null;
241
+ }
242
+
243
+ // Diagnose and repair after a crash: finalize running attempts whose owner is
244
+ // dead on this host as interrupted, report the rest, remove stray journal files.
245
+ // Never promotes an unfinished record to passed.
246
+ function recover(root, options = {}) {
247
+ return withLock(root, () => {
248
+ const p = paths(root);
249
+ // Committed-but-unapplied transactions are completed and uncommitted staging
250
+ // is discarded before anything else is read (docs/runtime-contracts.md,
251
+ // "Transactions and recovery"); required lazily to avoid a module cycle.
252
+ const transactions = require('./transaction.cjs').recoverPending(root);
253
+ const read = readIndex(root);
254
+ if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
255
+ const index = read.index;
256
+ const report = { finalized: [], live: [], foreign: [], missing: [], journal: [], transactions };
257
+ const stillRunning = [];
258
+ for (const id of index.running) {
259
+ const attempt = readAttempt(root, id).attempt;
260
+ if (!attempt) { report.missing.push(id); continue; }
261
+ if (attempt.outcome !== 'running') continue;
262
+ const owner = attempt.owner || {};
263
+ if (owner.host !== os.hostname()) { report.foreign.push({ id, owner }); stillRunning.push(id); continue; }
264
+ if (isAlive(owner.pid)) { report.live.push({ id, owner }); stillRunning.push(id); continue; }
265
+ attempt.outcome = 'interrupted';
266
+ attempt.finished = nowIso();
267
+ attempt.limitations = [...(attempt.limitations || []), `finalized as interrupted by recover: owner pid ${owner.pid} was no longer running`];
268
+ // Record what the dead runner captured so the logs are bound to the
269
+ // record like every finalized attempt's (a missing log stays unrecorded).
270
+ for (const k of ['stdout', 'stderr']) {
271
+ const info = attempt.artifacts && attempt.artifacts[k];
272
+ if (!info || typeof info.path !== 'string') continue;
273
+ try { const data = fs.readFileSync(path.join(root, info.path)); attempt.artifacts[k] = { ...info, sha256: crypto.createHash('sha256').update(data).digest('hex'), bytes: data.length }; } catch { /* leave as recorded */ }
274
+ }
275
+ const childPid = attempt.child && attempt.child.pid;
276
+ if (childPid && isAlive(childPid)) {
277
+ // Terminate the orphaned group and wait for it here: an unref'd timer
278
+ // would never fire before the command exits.
279
+ const signalGroup = signal => { try { process.kill(-childPid, signal); } catch { try { process.kill(childPid, signal); } catch { /* gone */ } } };
280
+ signalGroup('SIGTERM');
281
+ const deadline = Date.now() + GRACE_MS;
282
+ while (isAlive(childPid) && Date.now() < deadline) sleep(LOCK_POLL_MS);
283
+ if (isAlive(childPid)) {
284
+ signalGroup('SIGKILL');
285
+ const hardDeadline = Date.now() + 2000;
286
+ while (isAlive(childPid) && Date.now() < hardDeadline) sleep(LOCK_POLL_MS);
287
+ attempt.limitations.push(`orphaned child process group ${childPid} ignored SIGTERM for ${GRACE_MS / 1000} s and was sent SIGKILL${isAlive(childPid) ? ' (still alive when recover returned)' : ''}`);
288
+ } else attempt.limitations.push(`orphaned child process group ${childPid} was sent SIGTERM and exited`);
289
+ }
290
+ writeAttempt(root, attempt);
291
+ report.finalized.push(id);
292
+ }
293
+ if (fs.existsSync(p.journal)) {
294
+ for (const name of fs.readdirSync(p.journal)) {
295
+ const file = path.join(p.journal, name);
296
+ let stat = null;
297
+ try { stat = fs.lstatSync(file); } catch { continue; }
298
+ if (stat.isDirectory()) continue; // transaction staging is handled above; an unreadable manifest stays for inspection
299
+ try { fs.rmSync(file, { force: true }); report.journal.push(`${RUNTIME_DIR}/journal/${name}`); } catch { /* ignore */ }
300
+ }
301
+ }
302
+ if (stillRunning.length !== index.running.length || report.missing.length) {
303
+ index.running = stillRunning;
304
+ writeIndex(root, index);
305
+ }
306
+ return report;
307
+ }, { command: 'recover', ...options });
308
+ }
309
+
310
+ module.exports = {
311
+ RUNTIME_DIR, INDEX_SCHEMA, LOCK_WAIT_MS, StateBusy,
312
+ paths, ensureLayout, exists, hasIndex, emptyIndex, readIndex, writeIndex, isAlive,
313
+ acquireLock, withLock, attemptId, contextKey, validateAttempt, inspectArtifacts, writeAttempt, readAttempt, listAttempts, latestAttempt, recover,
314
+ };