forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6

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 (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -0,0 +1,328 @@
1
+ 'use strict';
2
+
3
+ const { stableStringify } = require('../../lib/kernel/evaluators');
4
+ const { contentHash } = require('../../lib/file-hash');
5
+ const { resolveOwnedKernel, closeIfOwned } = require('../../lib/kernel/owned-kernel');
6
+ const { resolveIssueActor } = require('../../lib/forge-issues');
7
+
8
+ const SCHEMA_VERSION = 1;
9
+ const EVIDENCE_KIND = 'eval.evidence';
10
+ const EVENT_TYPE = 'eval.evidence.recorded';
11
+ const HEX_40 = /^[0-9a-f]{40}$/;
12
+ const HEX_64 = /^[0-9a-f]{64}$/;
13
+ const HASH_FIELDS = ['eval_set', 'prompt', 'skill', 'tool'];
14
+
15
+ const CASE_FIELDS = [
16
+ 'issue_id', 'pr', 'head_sha', 'model', 'effort', 'role', 'hashes',
17
+ 'started_at', 'ended_at', 'active_ms', 'passive_ms', 'tokens',
18
+ 'retries', 'compactions', 'gates',
19
+ ];
20
+ const LEGACY_RUN_IDENTITY_FIELDS = ['arm_id', 'trial_index'];
21
+ const RUN_IDENTITY_FIELDS = [
22
+ 'arm_id', 'case_id', 'risk', 'split', 'model', 'config', 'budget', 'tier',
23
+ 'trial_index', 'config_hash', 'budget_hash',
24
+ ];
25
+ const CASE_RESULT_FIELDS = ['status', 'hard_failure', 'latency_ms', 'tokens'];
26
+
27
+ function assertObject(value, path) {
28
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
29
+ throw new Error(`${path} must be an object`);
30
+ }
31
+ }
32
+
33
+ function assertExactFields(value, allowed, path) {
34
+ assertObject(value, path);
35
+ for (const key of Object.keys(value)) {
36
+ if (!allowed.includes(key)) throw new Error(`Unknown field '${path}.${key}'`);
37
+ }
38
+ for (const key of allowed) {
39
+ if (!Object.hasOwn(value, key)) throw new Error(`${path}.${key} is required`);
40
+ }
41
+ }
42
+
43
+ function assertString(value, path) {
44
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`${path} is required`);
45
+ }
46
+
47
+ function assertCount(value, path) {
48
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${path} must be a non-negative integer`);
49
+ }
50
+
51
+ function assertTimestamp(value, path) {
52
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value)) || new Date(value).toISOString() !== value) {
53
+ throw new Error(`${path} must be an ISO timestamp`);
54
+ }
55
+ }
56
+
57
+ function resolveCaseFields(evidence) {
58
+ const expandedRunIdentity = Object.hasOwn(evidence?.run_identity || {}, 'case_id');
59
+ const fields = expandedRunIdentity
60
+ ? [...CASE_FIELDS, 'run_identity', 'case_result']
61
+ : Object.hasOwn(evidence || {}, 'run_identity') ? [...CASE_FIELDS, 'run_identity'] : CASE_FIELDS;
62
+ return { expandedRunIdentity, fields };
63
+ }
64
+
65
+ function validateExpandedRunIdentity(evidence) {
66
+ const identity = evidence.run_identity;
67
+ for (const field of ['case_id', 'model', 'config', 'budget']) {
68
+ assertString(identity[field], `evidence.run_identity.${field}`);
69
+ }
70
+ if (!['low', 'medium', 'high'].includes(identity.risk)) {
71
+ throw new Error('evidence.run_identity.risk is invalid');
72
+ }
73
+ if (!['DEV', 'TEST'].includes(identity.split)) {
74
+ throw new Error('evidence.run_identity.split is invalid');
75
+ }
76
+ if (!['current', 'bounded'].includes(identity.config)) {
77
+ throw new Error('evidence.run_identity.config is invalid');
78
+ }
79
+ if (![30, 100, 300].includes(identity.tier)) {
80
+ throw new Error('evidence.run_identity.tier is invalid');
81
+ }
82
+ for (const field of ['config_hash', 'budget_hash']) {
83
+ if (!HEX_64.test(identity[field])) {
84
+ throw new Error(`evidence.run_identity.${field} must be a 64-character lowercase SHA-256 hash`);
85
+ }
86
+ }
87
+ if (identity.model !== evidence.model) {
88
+ throw new Error('evidence.run_identity.model must match evidence.model');
89
+ }
90
+ }
91
+
92
+ function validateRunIdentity(evidence, expanded) {
93
+ if (!Object.hasOwn(evidence, 'run_identity')) return;
94
+ const identityFields = expanded ? RUN_IDENTITY_FIELDS : LEGACY_RUN_IDENTITY_FIELDS;
95
+ assertExactFields(evidence.run_identity, identityFields, 'evidence.run_identity');
96
+ assertString(evidence.run_identity.arm_id, 'evidence.run_identity.arm_id');
97
+ assertCount(evidence.run_identity.trial_index, 'evidence.run_identity.trial_index');
98
+ if (expanded) validateExpandedRunIdentity(evidence);
99
+ }
100
+
101
+ function validateCaseResult(caseResult) {
102
+ assertExactFields(caseResult, CASE_RESULT_FIELDS, 'evidence.case_result');
103
+ if (!['PASS', 'FAIL'].includes(caseResult.status)) {
104
+ throw new Error('evidence.case_result.status is invalid');
105
+ }
106
+ if (typeof caseResult.hard_failure !== 'boolean') {
107
+ throw new Error('evidence.case_result.hard_failure must be a boolean');
108
+ }
109
+ assertCount(caseResult.latency_ms, 'evidence.case_result.latency_ms');
110
+ assertCount(caseResult.tokens, 'evidence.case_result.tokens');
111
+ }
112
+
113
+ function validateHashes(hashes) {
114
+ const hashFields = Object.hasOwn(hashes, 'eval_set')
115
+ ? HASH_FIELDS
116
+ : HASH_FIELDS.filter((field) => field !== 'eval_set');
117
+ assertExactFields(hashes, hashFields, 'evidence.hashes');
118
+ for (const field of hashFields) {
119
+ if (!HEX_64.test(hashes[field])) {
120
+ throw new Error(`evidence.hashes.${field} must be a 64-character lowercase SHA-256 hash`);
121
+ }
122
+ }
123
+ }
124
+
125
+ function validateTiming(evidence) {
126
+ assertTimestamp(evidence.started_at, 'evidence.started_at');
127
+ assertTimestamp(evidence.ended_at, 'evidence.ended_at');
128
+ if (Date.parse(evidence.ended_at) < Date.parse(evidence.started_at)) {
129
+ throw new Error('evidence.ended_at must not precede evidence.started_at');
130
+ }
131
+ }
132
+
133
+ function validateCounters(evidence) {
134
+ for (const field of ['active_ms', 'passive_ms', 'retries', 'compactions']) {
135
+ assertCount(evidence[field], `evidence.${field}`);
136
+ }
137
+ assertExactFields(evidence.tokens, ['input', 'output', 'cached'], 'evidence.tokens');
138
+ for (const field of ['input', 'output', 'cached']) {
139
+ assertCount(evidence.tokens[field], `evidence.tokens.${field}`);
140
+ }
141
+ }
142
+
143
+ function validateGates(gates) {
144
+ if (!Array.isArray(gates)) throw new Error('evidence.gates must be an array');
145
+ gates.forEach((gate, index) => {
146
+ assertExactFields(gate, ['name', 'passed'], `evidence.gates[${index}]`);
147
+ assertString(gate.name, `evidence.gates[${index}].name`);
148
+ if (typeof gate.passed !== 'boolean') throw new Error(`evidence.gates[${index}].passed must be a boolean`);
149
+ });
150
+ }
151
+
152
+ function validateEvidence(evidence) {
153
+ const shape = resolveCaseFields(evidence);
154
+ assertExactFields(evidence, shape.fields, 'evidence');
155
+ assertString(evidence.issue_id, 'evidence.issue_id');
156
+ if (!Number.isSafeInteger(evidence.pr) || evidence.pr <= 0) throw new Error('evidence.pr must be a positive integer');
157
+ if (!HEX_40.test(evidence.head_sha)) throw new Error('evidence.head_sha must be a full 40-character commit SHA');
158
+ for (const field of ['model', 'effort', 'role']) assertString(evidence[field], `evidence.${field}`);
159
+ validateRunIdentity(evidence, shape.expandedRunIdentity);
160
+ if (shape.expandedRunIdentity) validateCaseResult(evidence.case_result);
161
+ validateHashes(evidence.hashes);
162
+ validateTiming(evidence);
163
+ validateCounters(evidence);
164
+ validateGates(evidence.gates);
165
+ }
166
+
167
+ function hashableEnvelope(evidence) {
168
+ return { schema_version: SCHEMA_VERSION, kind: EVIDENCE_KIND, evidence };
169
+ }
170
+
171
+ function createEvalEvidence(evidence) {
172
+ validateEvidence(evidence);
173
+ const normalized = JSON.parse(stableStringify(evidence));
174
+ return {
175
+ ...hashableEnvelope(normalized),
176
+ content_hash: contentHash(stableStringify(hashableEnvelope(normalized))),
177
+ };
178
+ }
179
+
180
+ function buildEvalReplayPayload(evalSet) {
181
+ assertObject(evalSet, 'evalSet');
182
+ if (!Array.isArray(evalSet.queries)) throw new Error('evalSet.queries must be an array');
183
+ return {
184
+ command: evalSet.command,
185
+ queries: evalSet.queries.map((query, index) => {
186
+ const queryPath = 'evalSet.queries[' + index + ']';
187
+ assertObject(query, queryPath);
188
+ assertString(query.name, queryPath + '.name');
189
+ assertString(query.prompt, queryPath + '.prompt');
190
+ if (!Array.isArray(query.assertions) || query.assertions.length === 0) {
191
+ throw new Error(queryPath + '.assertions must be a non-empty array');
192
+ }
193
+ return {
194
+ name: query.name,
195
+ prompt: query.prompt,
196
+ setup: query.setup ?? null,
197
+ teardown: query.teardown ?? null,
198
+ assertions: query.assertions,
199
+ };
200
+ }),
201
+ };
202
+ }
203
+
204
+ function buildEvalEvidenceHashes(inputs) {
205
+ assertExactFields(inputs, ['evalSet', 'skill', 'tool'], 'hash inputs');
206
+ assertString(inputs.skill, 'hash inputs.skill');
207
+ assertString(inputs.tool, 'hash inputs.tool');
208
+ const payload = buildEvalReplayPayload(inputs.evalSet);
209
+ return {
210
+ eval_set: contentHash(stableStringify(payload)),
211
+ prompt: contentHash(stableStringify(payload.queries.map((query) => query.prompt))),
212
+ skill: contentHash(inputs.skill),
213
+ tool: contentHash(inputs.tool),
214
+ };
215
+ }
216
+
217
+ function verifyEvalEvidence(envelope) {
218
+ assertExactFields(envelope, ['schema_version', 'kind', 'evidence', 'content_hash'], 'envelope');
219
+ if (envelope.schema_version !== SCHEMA_VERSION) throw new Error(`Unsupported evidence schema version '${envelope.schema_version}'`);
220
+ if (envelope.kind !== EVIDENCE_KIND) throw new Error(`Unsupported evidence kind '${envelope.kind}'`);
221
+ if (!HEX_64.test(envelope.content_hash)) throw new Error('envelope.content_hash must be a 64-character lowercase SHA-256 hash');
222
+ validateEvidence(envelope.evidence);
223
+ const actual = contentHash(stableStringify(hashableEnvelope(envelope.evidence)));
224
+ if (actual !== envelope.content_hash) throw new Error('Eval evidence content hash mismatch');
225
+ return envelope.evidence;
226
+ }
227
+
228
+ function verifyEvalReplay(envelope, inputs) {
229
+ const evidence = verifyEvalEvidence(envelope);
230
+ assertExactFields(inputs, ['evalSet', 'skill', 'tool'], 'replay.inputs');
231
+ if (!Object.hasOwn(evidence.hashes, 'eval_set')) {
232
+ throw new Error('replay evidence is missing the eval_set hash');
233
+ }
234
+ const expected = buildEvalEvidenceHashes(inputs);
235
+ for (const field of HASH_FIELDS) {
236
+ if (expected[field] !== evidence.hashes[field]) throw new Error(field + ' hash drift detected');
237
+ }
238
+ return evidence;
239
+ }
240
+
241
+ function parseStoredEnvelope(row) {
242
+ const envelope = row.payload_json ? JSON.parse(row.payload_json) : row.payload;
243
+ verifyEvalEvidence(envelope);
244
+ return envelope;
245
+ }
246
+
247
+ function isIdempotencyRace(error) {
248
+ const message = error && error.message ? String(error.message) : '';
249
+ return /UNIQUE constraint failed/i.test(message) && /idempotency_key/i.test(message);
250
+ }
251
+
252
+ async function appendEvalEvidence(projectRoot, envelope, options = {}) {
253
+ const evidence = verifyEvalEvidence(envelope);
254
+ const actor = resolveIssueActor(options.env || process.env) || 'forge';
255
+ const kernel = await resolveOwnedKernel(projectRoot, options.deps);
256
+ const { driver, config } = kernel;
257
+ const semanticIdentity = evidence.run_identity && Object.hasOwn(evidence.run_identity, 'case_id')
258
+ ? {
259
+ issue_id: evidence.issue_id,
260
+ pr: evidence.pr,
261
+ head_sha: evidence.head_sha,
262
+ run_identity: evidence.run_identity,
263
+ }
264
+ : null;
265
+ const identityHash = semanticIdentity ? contentHash(stableStringify(semanticIdentity)) : envelope.content_hash;
266
+ const idempotencyKey = `${EVENT_TYPE}:${identityHash}`;
267
+
268
+ function replay(existing) {
269
+ const storedEnvelope = parseStoredEnvelope(existing);
270
+ if (storedEnvelope.content_hash !== envelope.content_hash) {
271
+ return {
272
+ ok: false,
273
+ duplicate: false,
274
+ conflict: true,
275
+ status: 'INCOMPLETE',
276
+ event: existing,
277
+ envelope: storedEnvelope,
278
+ actor,
279
+ };
280
+ }
281
+ return { ok: true, duplicate: true, event: existing, envelope: storedEnvelope, actor };
282
+ }
283
+
284
+ try {
285
+ const issue = await driver.loadKernelEntity('issue', evidence.issue_id, {}, config);
286
+ if (!issue) return { ok: false, issueMissing: true, actor };
287
+
288
+ const existing = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
289
+ if (existing) return replay(existing);
290
+
291
+ const event = {
292
+ entity_type: 'issue',
293
+ entity_id: evidence.issue_id,
294
+ event_type: EVENT_TYPE,
295
+ idempotency_key: idempotencyKey,
296
+ expected_revision: 0,
297
+ actor,
298
+ origin: 'eval',
299
+ payload_json: stableStringify(envelope),
300
+ created_at: options.now || new Date().toISOString(),
301
+ };
302
+ try {
303
+ const inserted = await driver.insertKernelEvent(event, {}, config);
304
+ return { ok: true, duplicate: false, event: inserted, envelope, actor };
305
+ } catch (error) {
306
+ if (!isIdempotencyRace(error)) throw error;
307
+ const winner = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
308
+ if (!winner) {
309
+ return { ok: false, duplicate: false, conflict: true, status: 'INCOMPLETE', actor };
310
+ }
311
+ return replay(winner);
312
+ }
313
+ } finally {
314
+ closeIfOwned(kernel);
315
+ }
316
+ }
317
+
318
+ module.exports = {
319
+ SCHEMA_VERSION,
320
+ EVIDENCE_KIND,
321
+ EVENT_TYPE,
322
+ createEvalEvidence,
323
+ buildEvalReplayPayload,
324
+ buildEvalEvidenceHashes,
325
+ verifyEvalEvidence,
326
+ verifyEvalReplay,
327
+ appendEvalEvidence,
328
+ };
@@ -10,7 +10,12 @@
10
10
 
11
11
  const path = require('path');
12
12
  const fs = require('fs');
13
- const { execSync } = require('node:child_process');
13
+ const { execFileSync, execSync, spawn } = require('node:child_process');
14
+
15
+ const FULL_COMMIT_SHA = /^[0-9a-f]{40}$/;
16
+ const DEFAULT_COMMAND_TIMEOUT_MS = 120000;
17
+ const TERMINATION_GRACE_MS = 5000;
18
+ const FORCE_KILL_SETTLE_MS = 1000;
14
19
 
15
20
  // ── active worktree tracking (cleanup on crash) ─────────────────────
16
21
  // Tracks active eval worktrees so we can clean up on process exit/crash.
@@ -119,9 +124,24 @@ function getWorktreesDir() {
119
124
  /**
120
125
  * Create a git worktree with a unique name for eval isolation.
121
126
  *
127
+ * @param {string} [headSha] — optional full commit SHA for exact replay
122
128
  * @returns {Promise<{ path: string, branch: string }>}
123
129
  */
124
- async function createEvalWorktree() {
130
+ async function createEvalWorktree(headSha) {
131
+ if (headSha !== undefined) {
132
+ if (!FULL_COMMIT_SHA.test(headSha)) {
133
+ throw new Error('Eval replay requires a full 40-character commit SHA');
134
+ }
135
+ try {
136
+ const resolved = execFileSync('git', ['rev-parse', '--verify', `${headSha}^{commit}`], {
137
+ cwd: getRepoRoot(), encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
138
+ }).trim();
139
+ if (resolved !== headSha) throw new Error('mismatch');
140
+ } catch (_err) {
141
+ throw new Error(`Eval replay commit is not available: ${headSha}`);
142
+ }
143
+ }
144
+
125
145
  // Self-heal: remove stale eval dirs from previous crashed runs
126
146
  cleanupStaleEvalWorktrees();
127
147
 
@@ -133,7 +153,7 @@ async function createEvalWorktree() {
133
153
  const wtPath = path.join(worktreesDir, name);
134
154
 
135
155
  // Create the worktree with a detached HEAD first, then create branch
136
- execSync(`git worktree add -b "${branch}" "${wtPath}" HEAD`, {
156
+ execFileSync('git', ['worktree', 'add', '-b', branch, wtPath, headSha || 'HEAD'], {
137
157
  cwd: getRepoRoot(),
138
158
  encoding: 'utf-8',
139
159
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -238,9 +258,17 @@ async function resetWorktree(worktreePath) {
238
258
  * @param {string} worktreePath — absolute path to the worktree (used as cwd)
239
259
  * @param {number} [timeout=120000] — timeout in milliseconds
240
260
  * @param {string[]} [cmdOverride] — optional command array for testing
261
+ * @param {object} [envOverride] — additional subprocess environment entries
241
262
  * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
242
263
  */
243
- async function executeCommand(_command, prompt, worktreePath, timeout = 120000, cmdOverride) {
264
+ async function executeCommand(
265
+ _command,
266
+ prompt,
267
+ worktreePath,
268
+ timeout = DEFAULT_COMMAND_TIMEOUT_MS,
269
+ cmdOverride,
270
+ envOverride = {},
271
+ ) {
244
272
  // Build the command to run
245
273
  const cmd = cmdOverride || [
246
274
  'claude',
@@ -253,54 +281,66 @@ async function executeCommand(_command, prompt, worktreePath, timeout = 120000,
253
281
  ];
254
282
 
255
283
  // Build environment: inherit current env, strip CLAUDECODE, set FORGE_EVAL
256
- const env = { ...process.env };
284
+ const env = { ...process.env, ...envOverride };
257
285
  delete env.CLAUDECODE;
258
286
  env.FORGE_EVAL = '1';
259
287
 
260
- // Spawn with Bun.spawn (array form, no shell interpolation)
261
- const proc = Bun.spawn(cmd, { // eslint-disable-line no-undef -- Bun global provided by runtime
288
+ // Array-form spawn works under both Node and Bun without shell interpolation.
289
+ const proc = spawn(cmd[0], cmd.slice(1), {
262
290
  cwd: worktreePath,
263
291
  env,
264
- stdout: 'pipe',
265
- stderr: 'pipe',
292
+ stdio: ['ignore', 'pipe', 'pipe'],
293
+ windowsHide: true,
266
294
  });
267
295
 
268
296
  let timedOut = false;
269
- let timeoutId;
270
-
271
- // Set up timeout
272
- const timeoutPromise = new Promise((resolve) => {
273
- timeoutId = setTimeout(() => {
274
- timedOut = true;
275
- proc.kill();
276
- resolve();
277
- }, timeout);
278
- });
279
-
280
- // Wait for process to exit (or timeout)
281
- const exitPromise = proc.exited.then(() => {
282
- clearTimeout(timeoutId);
283
- });
284
-
285
- await Promise.race([exitPromise, timeoutPromise]);
286
-
287
- // Read stdout and stderr
288
297
  let stdout = '';
289
298
  let stderr = '';
290
299
 
291
- try {
292
- stdout = await new Response(proc.stdout).text();
293
- } catch (_err) {
294
- // stream may be closed on kill — ignore
295
- }
296
-
297
- try {
298
- stderr = await new Response(proc.stderr).text();
299
- } catch (_err) {
300
- // stream may be closed on kill — ignore
301
- }
302
-
303
- const exitCode = timedOut ? (proc.exitCode ?? 1) : proc.exitCode;
300
+ proc.stdout.setEncoding('utf8');
301
+ proc.stderr.setEncoding('utf8');
302
+ proc.stdout.on('data', (chunk) => { stdout += chunk; });
303
+ proc.stderr.on('data', (chunk) => { stderr += chunk; });
304
+
305
+ const exitCode = await new Promise((resolve) => {
306
+ let settled = false;
307
+ let timeoutId;
308
+ let terminationTimer;
309
+ let forceKillTimer;
310
+
311
+ const clearTimers = () => {
312
+ clearTimeout(timeoutId);
313
+ clearTimeout(terminationTimer);
314
+ clearTimeout(forceKillTimer);
315
+ };
316
+
317
+ const settle = (code) => {
318
+ if (settled) return;
319
+ settled = true;
320
+ clearTimers();
321
+ resolve(code ?? 1);
322
+ };
323
+
324
+ const onTimeout = () => {
325
+ timedOut = true;
326
+ try { proc.kill('SIGTERM'); } catch (_error) { /* already exited */ }
327
+ terminationTimer = setTimeout(() => {
328
+ try { proc.kill('SIGKILL'); } catch (_error) { /* already exited */ }
329
+ // A misbehaving child must not hold the evaluation forever even if no
330
+ // close event is delivered after the forced termination.
331
+ forceKillTimer = setTimeout(() => settle(1), FORCE_KILL_SETTLE_MS);
332
+ }, TERMINATION_GRACE_MS);
333
+ };
334
+
335
+ timeoutId = setTimeout(onTimeout, timeout);
336
+ proc.once('error', (error) => {
337
+ stderr += error.message;
338
+ settle(1);
339
+ });
340
+ proc.once('close', (code) => {
341
+ settle(code);
342
+ });
343
+ });
304
344
 
305
345
  return {
306
346
  stdout,