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.
- package/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- 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
|
-
|
|
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(
|
|
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
|
-
//
|
|
261
|
-
const proc =
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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,
|