pincer-workflow 0.6.0 → 1.0.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 (138) hide show
  1. package/README.md +157 -106
  2. package/bin/pincer.js +189 -166
  3. package/package.json +4 -3
  4. package/template/.agents/skills/pincer-blueprint/SKILL.md +70 -0
  5. package/template/.agents/skills/pincer-status/SKILL.md +29 -41
  6. package/template/.claude/commands/pincer-blueprint.md +7 -0
  7. package/template/.claude/commands/pincer-status.md +4 -45
  8. package/template/.claude/hooks/hook-policy.cjs +60 -25
  9. package/template/.claude/hooks/ticket-guard.sh +6 -3
  10. package/template/.claude/pincer/library/agents/accessibility-reviewer.md +27 -0
  11. package/template/.claude/pincer/library/agents/code-quality-reviewer.md +25 -0
  12. package/template/.claude/pincer/library/agents/codebase-explorer.md +23 -0
  13. package/template/.claude/pincer/library/agents/fix-agent.md +23 -0
  14. package/template/.claude/pincer/library/agents/history-reviewer.md +26 -0
  15. package/template/.claude/pincer/library/agents/implementer.md +24 -0
  16. package/template/.claude/pincer/library/agents/security-reviewer.md +26 -0
  17. package/template/.claude/pincer/library/agents/silent-failure-hunter.md +27 -0
  18. package/template/.claude/pincer/library/agents/standards-reviewer.md +25 -0
  19. package/template/.claude/pincer/library/agents/test-coverage-reviewer.md +26 -0
  20. package/template/.claude/pincer/library/agents-skeleton.md +89 -0
  21. package/template/.claude/pincer/library/agreement.md +46 -0
  22. package/template/.claude/pincer/library/models.json +6 -0
  23. package/template/.claude/pincer/library/stages/blueprint/SKILL.md +69 -0
  24. package/template/.claude/pincer/library/stages/blueprint/formats/harness.md +82 -0
  25. package/template/.claude/pincer/library/stages/blueprint/formats/roster-rules.md +61 -0
  26. package/template/.claude/pincer/library/stages/blueprint/prompts/confirm-kind.md +19 -0
  27. package/template/.claude/pincer/library/stages/blueprint/prompts/first-job.md +18 -0
  28. package/template/.claude/pincer/library/stages/blueprint/prompts/q-handoffs.md +21 -0
  29. package/template/.claude/pincer/library/stages/blueprint/prompts/q-models.md +20 -0
  30. package/template/.claude/pincer/library/stages/blueprint/prompts/q-pipeline.md +27 -0
  31. package/template/.claude/pincer/library/stages/blueprint/prompts/q-repo.md +21 -0
  32. package/template/.claude/pincer/library/stages/blueprint/prompts/q-responsibility.md +30 -0
  33. package/template/.claude/pincer/library/stages/blueprint/prompts/q-roster.md +20 -0
  34. package/template/.claude/pincer/library/stages/blueprint/prompts/q-simplify.md +26 -0
  35. package/template/.claude/pincer/library/stages/blueprint/prompts/q-systems.md +28 -0
  36. package/template/.claude/pincer/library/stages/code/SKILL.md +59 -0
  37. package/template/.claude/pincer/library/stages/code/formats/pr.md +55 -0
  38. package/template/.claude/pincer/library/stages/code/prompts/implement.md +25 -0
  39. package/template/.claude/pincer/library/stages/evaluate/SKILL.md +51 -0
  40. package/template/.claude/pincer/library/stages/evaluate/formats/evidence.md +49 -0
  41. package/template/.claude/pincer/library/stages/evaluate/formats/finding-report.md +72 -0
  42. package/template/.claude/pincer/library/stages/evaluate/formats/finding.md +35 -0
  43. package/template/.claude/pincer/library/stages/evaluate/prompts/review.md +25 -0
  44. package/template/.claude/pincer/library/stages/narrow/SKILL.md +47 -0
  45. package/template/.claude/pincer/library/stages/narrow/formats/ticket.md +126 -0
  46. package/template/.claude/pincer/library/stages/narrow/prompts/explore.md +19 -0
  47. package/template/.claude/pincer/library/stages/plan/SKILL.md +46 -0
  48. package/template/.claude/pincer/library/stages/plan/formats/spec.md +100 -0
  49. package/template/.claude/pincer/library/stages/plan/prompts/explore.md +20 -0
  50. package/template/.claude/pincer/library/stages/release/SKILL.md +48 -0
  51. package/template/.claude/pincer/library/stages/status/SKILL.md +37 -0
  52. package/template/.claude/skills/pincer-blueprint/SKILL.md +69 -0
  53. package/template/.claude/skills/pincer-blueprint/formats/harness.md +82 -0
  54. package/template/.claude/skills/pincer-blueprint/formats/roster-rules.md +61 -0
  55. package/template/.claude/skills/pincer-blueprint/prompts/confirm-kind.md +19 -0
  56. package/template/.claude/skills/pincer-blueprint/prompts/first-job.md +18 -0
  57. package/template/.claude/skills/pincer-blueprint/prompts/q-handoffs.md +21 -0
  58. package/template/.claude/skills/pincer-blueprint/prompts/q-models.md +20 -0
  59. package/template/.claude/skills/pincer-blueprint/prompts/q-pipeline.md +27 -0
  60. package/template/.claude/skills/pincer-blueprint/prompts/q-repo.md +21 -0
  61. package/template/.claude/skills/pincer-blueprint/prompts/q-responsibility.md +30 -0
  62. package/template/.claude/skills/pincer-blueprint/prompts/q-roster.md +20 -0
  63. package/template/.claude/skills/pincer-blueprint/prompts/q-simplify.md +26 -0
  64. package/template/.claude/skills/pincer-blueprint/prompts/q-systems.md +28 -0
  65. package/template/.claude/skills/pincer-status/SKILL.md +37 -0
  66. package/template/.codex/README.md +39 -35
  67. package/template/.github/copilot-instructions.md +19 -14
  68. package/template/.github/prompts/pincer-blueprint.prompt.md +70 -0
  69. package/template/.github/prompts/pincer-status.prompt.md +29 -41
  70. package/template/AGENTS.md +34 -67
  71. package/template/docs/engine.md +236 -0
  72. package/template/docs/release-checklist.md +22 -28
  73. package/template/scripts/pincer/blueprint.cjs +137 -0
  74. package/template/scripts/pincer/cli.cjs +233 -0
  75. package/template/scripts/pincer/evidence.cjs +142 -0
  76. package/template/scripts/pincer/generate.cjs +373 -0
  77. package/template/scripts/pincer/harness.cjs +269 -0
  78. package/template/scripts/pincer/models.cjs +121 -0
  79. package/template/scripts/{pincer-runtime → pincer}/parse.cjs +83 -133
  80. package/template/scripts/pincer/recon.cjs +205 -0
  81. package/template/scripts/pincer/status.cjs +161 -0
  82. package/template/scripts/pincer/ticket.cjs +203 -0
  83. package/template/scripts/pincer/util.cjs +104 -0
  84. package/template/scripts/pincer-blueprint.cjs +7 -0
  85. package/template/scripts/pincer-evidence.cjs +5 -73
  86. package/template/scripts/pincer-status.sh +10 -12
  87. package/template/scripts/pincer-ticket.sh +15 -18
  88. package/template/scripts/sync-prompts.sh +2 -50
  89. package/template/.agents/skills/pincer-code/SKILL.md +0 -237
  90. package/template/.agents/skills/pincer-evaluate/SKILL.md +0 -178
  91. package/template/.agents/skills/pincer-narrow/SKILL.md +0 -138
  92. package/template/.agents/skills/pincer-plan/SKILL.md +0 -134
  93. package/template/.agents/skills/pincer-release/SKILL.md +0 -82
  94. package/template/.claude/agents/code-quality-reviewer.md +0 -38
  95. package/template/.claude/agents/codebase-explorer.md +0 -23
  96. package/template/.claude/commands/pincer-code.md +0 -235
  97. package/template/.claude/commands/pincer-evaluate.md +0 -176
  98. package/template/.claude/commands/pincer-narrow.md +0 -136
  99. package/template/.claude/commands/pincer-plan.md +0 -132
  100. package/template/.claude/commands/pincer-release.md +0 -80
  101. package/template/.claude/references/prd-template.md +0 -108
  102. package/template/.claude/references/ticket-template.md +0 -86
  103. package/template/.github/prompts/pincer-code.prompt.md +0 -237
  104. package/template/.github/prompts/pincer-evaluate.prompt.md +0 -178
  105. package/template/.github/prompts/pincer-narrow.prompt.md +0 -138
  106. package/template/.github/prompts/pincer-plan.prompt.md +0 -134
  107. package/template/.github/prompts/pincer-release.prompt.md +0 -82
  108. package/template/docs/dry-run-checklist.md +0 -264
  109. package/template/docs/runtime-contracts.md +0 -1683
  110. package/template/scripts/pincer-runtime/adopt.cjs +0 -132
  111. package/template/scripts/pincer-runtime/agreement.cjs +0 -240
  112. package/template/scripts/pincer-runtime/authorization.cjs +0 -167
  113. package/template/scripts/pincer-runtime/changes.cjs +0 -517
  114. package/template/scripts/pincer-runtime/checks.cjs +0 -48
  115. package/template/scripts/pincer-runtime/coverage.cjs +0 -361
  116. package/template/scripts/pincer-runtime/dispositions.cjs +0 -95
  117. package/template/scripts/pincer-runtime/evidence.cjs +0 -676
  118. package/template/scripts/pincer-runtime/fsutil.cjs +0 -37
  119. package/template/scripts/pincer-runtime/gates.cjs +0 -73
  120. package/template/scripts/pincer-runtime/identity.cjs +0 -163
  121. package/template/scripts/pincer-runtime/impact.cjs +0 -177
  122. package/template/scripts/pincer-runtime/io.cjs +0 -41
  123. package/template/scripts/pincer-runtime/lifecycle.cjs +0 -311
  124. package/template/scripts/pincer-runtime/locator.cjs +0 -158
  125. package/template/scripts/pincer-runtime/migrate.cjs +0 -204
  126. package/template/scripts/pincer-runtime/phases.cjs +0 -245
  127. package/template/scripts/pincer-runtime/readiness.cjs +0 -97
  128. package/template/scripts/pincer-runtime/requirements.cjs +0 -255
  129. package/template/scripts/pincer-runtime/resume.cjs +0 -205
  130. package/template/scripts/pincer-runtime/routing.cjs +0 -54
  131. package/template/scripts/pincer-runtime/runner.cjs +0 -242
  132. package/template/scripts/pincer-runtime/sanitize.cjs +0 -63
  133. package/template/scripts/pincer-runtime/source.cjs +0 -129
  134. package/template/scripts/pincer-runtime/state.cjs +0 -314
  135. package/template/scripts/pincer-runtime/status.cjs +0 -514
  136. package/template/scripts/pincer-runtime/transaction.cjs +0 -200
  137. package/template/scripts/pincer-runtime/transitions.cjs +0 -134
  138. package/template/scripts/pincer-runtime.cjs +0 -661
@@ -1,314 +0,0 @@
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
- };