faberun 0.3.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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The faberun signal block in the target repository's AGENTS.md.
|
|
3
|
+
*
|
|
4
|
+
* Every agent that opens the repository — whatever the harness — reads
|
|
5
|
+
* AGENTS.md before its first prompt. The runner mirrors its active state
|
|
6
|
+
* there so a takeover session learns that active work exists without
|
|
7
|
+
* probing .runs/. The block is machine-managed: agents read it, never edit
|
|
8
|
+
* it.
|
|
9
|
+
*
|
|
10
|
+
* The defect this module fixes is what the block says, not whether it is
|
|
11
|
+
* rewritten. A run that parked — every node terminal but none successful —
|
|
12
|
+
* used to disappear, because the old renderer only listed runs holding a
|
|
13
|
+
* node that was not terminal. A session reading the block learned nothing
|
|
14
|
+
* about the work that most needs it. The block now renders the phase-2
|
|
15
|
+
* `runOutcome` per linked run: `parked` with its nodes, their error codes and
|
|
16
|
+
* the exact `resume` command; `succeeded` as one line; plus the most recent
|
|
17
|
+
* campaign-level `attention` entry from `.runs/inbox.jsonl`. It is bounded,
|
|
18
|
+
* because every session pays for it in its first tokens.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { discoverCampaigns } from "../campaign/index.mjs";
|
|
24
|
+
import { runProgress } from "../engine/supervise.mjs";
|
|
25
|
+
import { readInbox } from "../notify/index.mjs";
|
|
26
|
+
import { SIGNAL_END, SIGNAL_START } from "./signal-block.mjs";
|
|
27
|
+
import { HANDOFF_FILE } from "../campaign/layout.mjs";
|
|
28
|
+
|
|
29
|
+
export { SIGNAL_END, SIGNAL_START } from "./signal-block.mjs";
|
|
30
|
+
|
|
31
|
+
/** Keep the block cheap for every session; a campaign with many runs is summarised, not dumped. */
|
|
32
|
+
const SIGNAL_LINES_BYTE_BUDGET = 2_600;
|
|
33
|
+
/** How many parked nodes are named before the rest are counted. */
|
|
34
|
+
const MAX_PARKED_NODES = 6;
|
|
35
|
+
/** One attention entry, bounded like a notification summary. */
|
|
36
|
+
const ATTENTION_CHARS = 200;
|
|
37
|
+
|
|
38
|
+
const HEADER = "Before starting new work here, check `.runs/`: if a campaign is active or a run is not terminal, continue it instead of starting over — read its `HANDOFF.md`/`STATUS.md`, attach to the campaign, and `resume` or `supervise` the run. Active runs are supervised by a deterministic detached process: do not poll `status` in a loop — on resume, check status once and act only on terminal states.";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} runsDir
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function renderAgentSignalBlock(runsDir) {
|
|
45
|
+
/** @type {string[]} */
|
|
46
|
+
const lines = [];
|
|
47
|
+
/** @type {Set<string>} */
|
|
48
|
+
const linked = new Set();
|
|
49
|
+
const { campaigns } = discoverCampaigns(runsDir);
|
|
50
|
+
for (const { campaign } of campaigns.filter(({ campaign }) => campaign.status !== "closed")) {
|
|
51
|
+
lines.push(`- faberun campaign \`${campaign.id}\`: active — read \`.runs/campaigns/${campaign.id}/${HANDOFF_FILE}\``);
|
|
52
|
+
for (const runId of campaign.linkedRunIds) {
|
|
53
|
+
linked.add(runId);
|
|
54
|
+
lines.push(...runSignalLines(runsDir, runId));
|
|
55
|
+
}
|
|
56
|
+
const attention = campaignAttentionLine(runsDir, campaign);
|
|
57
|
+
if (attention) lines.push(attention);
|
|
58
|
+
}
|
|
59
|
+
for (const line of activeRunLines(runsDir, linked)) lines.push(line);
|
|
60
|
+
if (!lines.length) return "";
|
|
61
|
+
return `${SIGNAL_START}\n${HEADER}\n\n${boundLines(lines).join("\n")}\n${SIGNAL_END}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One linked run's outcome as block lines. `runProgress` folds the phase-2
|
|
66
|
+
* `runOutcome`, so a parked run is rendered by its own declared nodes rather
|
|
67
|
+
* than being dropped for having no non-terminal one.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} runsDir
|
|
70
|
+
* @param {string} runId
|
|
71
|
+
* @returns {string[]}
|
|
72
|
+
*/
|
|
73
|
+
function runSignalLines(runsDir, runId) {
|
|
74
|
+
const runDir = join(runsDir, runId);
|
|
75
|
+
const resume = `node src/cli.mjs resume ${runDir}`;
|
|
76
|
+
if (!existsSync(runDir)) {
|
|
77
|
+
return [` - run \`${runId}\`: missing — resume \`${resume}\``];
|
|
78
|
+
}
|
|
79
|
+
const progress = runProgress(runDir);
|
|
80
|
+
switch (progress.runOutcome) {
|
|
81
|
+
case "succeeded":
|
|
82
|
+
return [` - run \`${runId}\`: succeeded (${progress.terminal}/${progress.total} nodes)`];
|
|
83
|
+
case "canceled":
|
|
84
|
+
return [` - run \`${runId}\`: canceled`];
|
|
85
|
+
case "waiting":
|
|
86
|
+
return [` - run \`${runId}\`: waiting${progress.waitingUntil ? ` until ${progress.waitingUntil}` : ""} — supervise \`node src/cli.mjs supervise ${runDir}\``];
|
|
87
|
+
case "parked":
|
|
88
|
+
break;
|
|
89
|
+
default:
|
|
90
|
+
// No snapshot yet: the run exists but has not proved an outcome. It is
|
|
91
|
+
// active work, not parked work, so it must not claim parked nodes.
|
|
92
|
+
return [` - run \`${runId}\`: active — read \`.runs/${runId}/STATUS.md\`; \`resume\` or \`supervise\` it`];
|
|
93
|
+
}
|
|
94
|
+
const nodes = progress.outcomeNodes ?? [];
|
|
95
|
+
const shown = nodes.slice(0, MAX_PARKED_NODES).map(nodeText).join(", ");
|
|
96
|
+
const more = nodes.length > MAX_PARKED_NODES ? `, +${nodes.length - MAX_PARKED_NODES} more` : "";
|
|
97
|
+
return [` - run \`${runId}\`: parked — ${shown || "no nodes named"}${more} — resume \`${resume}\``];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Standalone runs that no active campaign links. A parked run is included, so
|
|
102
|
+
* a run with no campaign at all still blocks a naive "start fresh".
|
|
103
|
+
*
|
|
104
|
+
* @param {string} runsDir
|
|
105
|
+
* @param {Set<string>} linked
|
|
106
|
+
* @returns {string[]}
|
|
107
|
+
*/
|
|
108
|
+
function activeRunLines(runsDir, linked) {
|
|
109
|
+
if (!existsSync(runsDir)) return [];
|
|
110
|
+
/** @type {string[]} */
|
|
111
|
+
const lines = [];
|
|
112
|
+
for (const name of readdirSync(runsDir)) {
|
|
113
|
+
if (linked.has(name)) continue;
|
|
114
|
+
const nodeDir = join(runsDir, name, "nodes");
|
|
115
|
+
if (!existsSync(nodeDir)) continue;
|
|
116
|
+
const runDir = join(runsDir, name);
|
|
117
|
+
const progress = runProgress(runDir);
|
|
118
|
+
// A nodes directory with no committed snapshot is a creation in progress,
|
|
119
|
+
// not evidence of work; the old renderer left it off and so does this one.
|
|
120
|
+
if (progress.runOutcome === undefined && progress.total === 0) continue;
|
|
121
|
+
if (progress.runOutcome === "succeeded" || progress.runOutcome === "canceled") continue;
|
|
122
|
+
const resume = `node src/cli.mjs resume ${runDir}`;
|
|
123
|
+
if (progress.runOutcome === "parked" && progress.state === "done") {
|
|
124
|
+
const nodes = (progress.outcomeNodes ?? []).slice(0, MAX_PARKED_NODES).map(nodeText).join(", ");
|
|
125
|
+
lines.push(`- faberun run \`${name}\`: parked — ${nodes || "no nodes named"} — resume \`${resume}\``);
|
|
126
|
+
} else {
|
|
127
|
+
lines.push(`- faberun run \`${name}\`: active (${progress.terminal}/${progress.total} nodes done) — read \`.runs/${name}/STATUS.md\`; \`resume\` or \`supervise\` it`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return lines;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The most recent campaign-level attention: an inbox entry when one exists,
|
|
135
|
+
* otherwise the durable record on the campaign itself.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} runsDir
|
|
138
|
+
* @param {import("../campaign/index.mjs").Campaign} campaign
|
|
139
|
+
* @returns {string|null}
|
|
140
|
+
*/
|
|
141
|
+
function campaignAttentionLine(runsDir, campaign) {
|
|
142
|
+
const latest = readInbox(runsDir)
|
|
143
|
+
.filter((entry) => entry.type === "attention" && (entry.campaignId === campaign.id || entry.campaignId === null))
|
|
144
|
+
.at(-1);
|
|
145
|
+
if (latest) return ` - attention: ${boundedAttention(latest.summary)}`;
|
|
146
|
+
if (campaign.attention && typeof campaign.attention.message === "string") {
|
|
147
|
+
const code = campaign.attention.code ? ` (${campaign.attention.code})` : "";
|
|
148
|
+
return ` - attention: ${boundedAttention(`${campaign.attention.message}${code}`)}`;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {import("../engine/supervise.mjs").OutcomeNode} node
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
function nodeText(node) {
|
|
158
|
+
return `\`${node.id}:${node.status ?? "unknown"}${node.errorCode ? ` ${node.errorCode}` : ""}\``;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {string} value @returns {string} */
|
|
162
|
+
function boundedAttention(value) {
|
|
163
|
+
return value.length <= ATTENTION_CHARS ? value : `${value.slice(0, ATTENTION_CHARS - 1)}…`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string[]} lines
|
|
168
|
+
* @returns {string[]}
|
|
169
|
+
*/
|
|
170
|
+
function boundLines(lines) {
|
|
171
|
+
/** @type {string[]} */
|
|
172
|
+
const out = [];
|
|
173
|
+
let bytes = 0;
|
|
174
|
+
for (const line of lines) {
|
|
175
|
+
const size = Buffer.byteLength(line, "utf8") + 1;
|
|
176
|
+
if (bytes + size > SIGNAL_LINES_BYTE_BUDGET) {
|
|
177
|
+
out.push("- … signal truncated; read the campaign HANDOFF.md for the rest");
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
out.push(line);
|
|
181
|
+
bytes += size;
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Rewrites the managed signal block at the bottom of <repo>/AGENTS.md from
|
|
188
|
+
* the current .runs state. Leaves the file untouched when there is no
|
|
189
|
+
* AGENTS.md, no active work, or nothing changed. Returns true when the file
|
|
190
|
+
* was written.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} runsDir
|
|
193
|
+
* @returns {boolean}
|
|
194
|
+
*/
|
|
195
|
+
export function syncAgentSignal(runsDir) {
|
|
196
|
+
const agentsPath = join(dirname(runsDir), "AGENTS.md");
|
|
197
|
+
if (!existsSync(agentsPath)) return false;
|
|
198
|
+
const current = readFileSync(agentsPath, "utf8");
|
|
199
|
+
const block = renderAgentSignalBlock(runsDir);
|
|
200
|
+
const start = current.indexOf(SIGNAL_START);
|
|
201
|
+
const end = current.indexOf(SIGNAL_END);
|
|
202
|
+
if (!block && start < 0 && end < 0) return false;
|
|
203
|
+
let before;
|
|
204
|
+
let after;
|
|
205
|
+
if (start >= 0 && end > start) {
|
|
206
|
+
before = current.slice(0, start).trimEnd();
|
|
207
|
+
after = current.slice(end + SIGNAL_END.length);
|
|
208
|
+
} else if (start >= 0) {
|
|
209
|
+
before = current.slice(0, start).trimEnd();
|
|
210
|
+
after = "";
|
|
211
|
+
} else if (end >= 0) {
|
|
212
|
+
before = current.slice(0, end).trimEnd();
|
|
213
|
+
after = current.slice(end + SIGNAL_END.length);
|
|
214
|
+
} else {
|
|
215
|
+
before = current.trimEnd();
|
|
216
|
+
after = "";
|
|
217
|
+
}
|
|
218
|
+
const next = [before, block, after.trimStart()].filter((part) => part.length).join("\n\n") + "\n";
|
|
219
|
+
if (next === current) return false;
|
|
220
|
+
writeFileSync(agentsPath, next);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity a run is pinned to: the git head it started from, a fingerprint
|
|
3
|
+
* of the dirty tree, the hash of every task packet, the agent-guidance files in
|
|
4
|
+
* force, and the harness versions observed.
|
|
5
|
+
*
|
|
6
|
+
* This is what makes a resume honest. A run that resumes against a different
|
|
7
|
+
* head, a changed packet or an edited `AGENTS.md` is not the run that was
|
|
8
|
+
* approved, and `validateCompleteSourceIdentity` is where that is refused.
|
|
9
|
+
*/
|
|
10
|
+
import { Buffer } from "node:buffer";
|
|
11
|
+
import { assertObject, rejectUnknown, requireId, requirePacketHash, requireString } from "../contract/assert.mjs";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { errorCode } from "../util.mjs";
|
|
14
|
+
import { boundedGitSync } from "./worktree.mjs";
|
|
15
|
+
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { normalizeManagedSignalBlock } from "./signal-block.mjs";
|
|
18
|
+
|
|
19
|
+
/** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
|
|
20
|
+
/** @typedef {import("../contract/index.mjs").SourceIdentity} SourceIdentity */
|
|
21
|
+
/** @typedef {{encoding?: "utf8"|"buffer", stdio?: import("node:child_process").StdioOptions}} GitReadOptions */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run one bounded synchronous git read and surface a failure as a throw, so
|
|
25
|
+
* the callers below keep their `try/catch` shape. Every `execFileSync("git")`
|
|
26
|
+
* here went through this helper so a held index lock cannot hang the controller.
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]} args
|
|
29
|
+
* @param {GitReadOptions} [options]
|
|
30
|
+
* @returns {string|Buffer}
|
|
31
|
+
*/
|
|
32
|
+
function gitSyncOrThrow(args, options = {}) {
|
|
33
|
+
const result = boundedGitSync(args, options);
|
|
34
|
+
if (result.error || result.status !== 0) {
|
|
35
|
+
throw result.error ?? new Error(`git ${args.join(" ")} exited ${result.status}`);
|
|
36
|
+
}
|
|
37
|
+
return result.stdout;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {unknown} value
|
|
42
|
+
* @param {string} label
|
|
43
|
+
* @param {JsonObject|null} expected
|
|
44
|
+
*/
|
|
45
|
+
export function validateSourceIdentity(value, label, expected = null) {
|
|
46
|
+
assertObject(value, label);
|
|
47
|
+
const allowed = new Set([
|
|
48
|
+
"kind", "id", "campaignId", "contractId", "nodeId", "cwd", "gitHead",
|
|
49
|
+
"dirtyTreeFingerprint", "packetHashes", "harnessVersions",
|
|
50
|
+
]);
|
|
51
|
+
rejectUnknown(value, allowed, label);
|
|
52
|
+
requireString(value.kind, `${label}.kind`);
|
|
53
|
+
for (const key of ["id", "campaignId", "contractId", "nodeId"]) {
|
|
54
|
+
if (value[key] !== undefined) requireId(value[key], `${label}.${key}`);
|
|
55
|
+
}
|
|
56
|
+
if (value.cwd !== undefined) requireString(value.cwd, `${label}.cwd`);
|
|
57
|
+
for (const key of ["gitHead", "dirtyTreeFingerprint"]) {
|
|
58
|
+
if (value[key] !== undefined && value[key] !== null) requireString(value[key], `${label}.${key}`);
|
|
59
|
+
}
|
|
60
|
+
if (value.packetHashes !== undefined) validateHashMap(value.packetHashes, `${label}.packetHashes`);
|
|
61
|
+
if (value.harnessVersions !== undefined) {
|
|
62
|
+
assertObject(value.harnessVersions, `${label}.harnessVersions`);
|
|
63
|
+
for (const [key, version] of Object.entries(value.harnessVersions)) {
|
|
64
|
+
requireId(key, `${label}.harnessVersions key`);
|
|
65
|
+
if (version !== null) requireString(version, `${label}.harnessVersions.${key}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (expected) {
|
|
69
|
+
for (const [key, expectedValue] of Object.entries(expected)) {
|
|
70
|
+
if (value[key] !== expectedValue) throw new TypeError(`${label}.${key} does not match its source`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return /** @type {SourceIdentity} */ ({ ...value });
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* @param {{id: string, campaignId: string, cwd: string, nodes: {id: string, packetHash: string}[]}} contract
|
|
77
|
+
* @param {Record<string, string|null>} harnessVersions
|
|
78
|
+
* @param {{ignorePaths?: string[], ignoreRoots?: string[]}} options
|
|
79
|
+
*/
|
|
80
|
+
export function captureSourceIdentity(contract, harnessVersions = {}, options = {}) {
|
|
81
|
+
const git = gitIdentity(contract.cwd, options);
|
|
82
|
+
return validateSourceIdentity({
|
|
83
|
+
kind: "run",
|
|
84
|
+
contractId: contract.id,
|
|
85
|
+
campaignId: contract.campaignId,
|
|
86
|
+
cwd: contract.cwd,
|
|
87
|
+
gitHead: git.gitHead,
|
|
88
|
+
dirtyTreeFingerprint: git.dirtyTreeFingerprint,
|
|
89
|
+
packetHashes: Object.fromEntries(contract.nodes.map((node) => [node.id, node.packetHash])),
|
|
90
|
+
harnessVersions,
|
|
91
|
+
}, "run source identity", { kind: "run", contractId: contract.id, campaignId: contract.campaignId });
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* @param {JsonObject} value
|
|
95
|
+
*/
|
|
96
|
+
export function validateCompleteSourceIdentity(value) {
|
|
97
|
+
for (const key of ["cwd", "gitHead", "dirtyTreeFingerprint", "packetHashes", "harnessVersions"]) {
|
|
98
|
+
if (!Object.hasOwn(value, key)) throw new TypeError(`run metadata.sourceIdentity.${key} is required for resume`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* @param {unknown} value
|
|
103
|
+
* @param {string} label
|
|
104
|
+
*/
|
|
105
|
+
function validateHashMap(value, label) {
|
|
106
|
+
assertObject(value, label);
|
|
107
|
+
for (const [key, hash] of Object.entries(value)) {
|
|
108
|
+
requireId(key, `${label} key`);
|
|
109
|
+
requirePacketHash(hash, `${label}.${key}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The pathspec that scopes a source-identity read: everything, minus the
|
|
114
|
+
* factory's own run tree, minus the machine-managed AGENTS.md signal, minus
|
|
115
|
+
* whatever the caller declared out of scope.
|
|
116
|
+
*
|
|
117
|
+
* @param {{ignorePaths?: string[], ignoreRoots?: string[]}} options
|
|
118
|
+
* @returns {string[]}
|
|
119
|
+
*/
|
|
120
|
+
function sourcePathspec(options = {}) {
|
|
121
|
+
return [
|
|
122
|
+
".",
|
|
123
|
+
":(exclude).runs",
|
|
124
|
+
":(exclude)AGENTS.md",
|
|
125
|
+
...(options.ignorePaths ?? []).map((path) => `:(exclude)${path}`),
|
|
126
|
+
...(options.ignoreRoots ?? []).map((path) => `:(exclude)${path}`),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The commit a source identity is pinned to. Without `baseRef` this is the
|
|
132
|
+
* checkout's own HEAD, with the unborn-HEAD probe that makes a fresh
|
|
133
|
+
* repository report `null` rather than fail. With `baseRef` it is that ref's
|
|
134
|
+
* sha, so a run can be cut from a landing branch while the operator's tree
|
|
135
|
+
* stays on whatever they were doing.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} cwd
|
|
138
|
+
* @param {string|undefined} baseRef
|
|
139
|
+
* @returns {string|null}
|
|
140
|
+
*/
|
|
141
|
+
function resolveGitHead(cwd, baseRef) {
|
|
142
|
+
if (baseRef) {
|
|
143
|
+
try {
|
|
144
|
+
return String(gitSyncOrThrow(["-C", cwd, "rev-parse", baseRef], {
|
|
145
|
+
encoding: "utf8",
|
|
146
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
147
|
+
})).trim() || null;
|
|
148
|
+
} catch {
|
|
149
|
+
// An unknown ref resolves to null; the caller decides whether that is fatal.
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const headPath = resolve(cwd, ".git", "HEAD");
|
|
154
|
+
let headText = null;
|
|
155
|
+
try { headText = readFileSync(headPath, "utf8").trim(); } catch {
|
|
156
|
+
// A missing or unreadable .git/HEAD leaves headText null; rev-parse below still decides gitHead.
|
|
157
|
+
}
|
|
158
|
+
if (headText?.startsWith("ref: ") === true) {
|
|
159
|
+
try { lstatSync(resolve(cwd, ".git", headText.slice(5))); }
|
|
160
|
+
catch (error) { if (errorCode(error) !== "ENOENT") throw error; return null; }
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return String(gitSyncOrThrow(["-C", cwd, "rev-parse", "HEAD"], {
|
|
164
|
+
encoding: "utf8",
|
|
165
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
166
|
+
})).trim() || null;
|
|
167
|
+
} catch {
|
|
168
|
+
// Any rev-parse failure (no repo, unborn HEAD, git absent) leaves gitHead null.
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The paths the working tree has modified, added or left untracked, ignoring
|
|
175
|
+
* the factory's own `.runs` tree and the machine-managed AGENTS.md signal. A
|
|
176
|
+
* clean tree is `[]`; an unreadable tree is treated as clean because the
|
|
177
|
+
* caller has no dirt to name.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} cwd
|
|
180
|
+
* @param {{ignorePaths?: string[], ignoreRoots?: string[]}} [options]
|
|
181
|
+
* @returns {string[]}
|
|
182
|
+
*/
|
|
183
|
+
export function dirtyTreePaths(cwd, options = {}) {
|
|
184
|
+
try {
|
|
185
|
+
const status = String(gitSyncOrThrow(["-C", cwd, "status", "--porcelain=v1", "--untracked-files=all", "-z", "--", ...sourcePathspec(options)], {
|
|
186
|
+
encoding: "utf8",
|
|
187
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
188
|
+
}));
|
|
189
|
+
return status.split("\0").filter(Boolean);
|
|
190
|
+
} catch {
|
|
191
|
+
// Not a repository, or git absent: there is no dirt the launch can name.
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Refuse a launch whose base is the checked-out HEAD and whose tree is dirty.
|
|
198
|
+
* The base is what every worktree is cut from, so a dirty tree only matters
|
|
199
|
+
* when it *is* the base: then the validator sees files the worker will never
|
|
200
|
+
* have. A base ref that resolves elsewhere leaves the operator's checkout
|
|
201
|
+
* alone and is always clean enough to launch from.
|
|
202
|
+
*
|
|
203
|
+
* The launch's own contract file is passed in `ignorePaths`: it is the input
|
|
204
|
+
* being launched, written or re-authored immediately before the command, and
|
|
205
|
+
* is not source the worktrees are cut from.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} cwd
|
|
208
|
+
* @param {string|undefined} baseRef
|
|
209
|
+
* @param {{ignorePaths?: string[], ignoreRoots?: string[]}} [options]
|
|
210
|
+
* @returns {void}
|
|
211
|
+
*/
|
|
212
|
+
export function assertLaunchBaseClean(cwd, baseRef, options = {}) {
|
|
213
|
+
const headSha = resolveGitHead(cwd, undefined);
|
|
214
|
+
const baseSha = baseRef ? resolveGitHead(cwd, baseRef) : headSha;
|
|
215
|
+
if (baseRef && !baseSha) {
|
|
216
|
+
throw Object.assign(new Error(`base ref does not resolve: ${baseRef}`), { code: "base_ref_unresolved" });
|
|
217
|
+
}
|
|
218
|
+
if (!baseSha || !headSha || baseSha !== headSha) return;
|
|
219
|
+
const dirty = dirtyTreePaths(cwd, options);
|
|
220
|
+
if (dirty.length) {
|
|
221
|
+
const label = baseRef ?? "HEAD";
|
|
222
|
+
throw Object.assign(
|
|
223
|
+
new Error(`refusing to launch against ${label}: the working tree has ${dirty.length} uncommitted path${dirty.length === 1 ? "" : "s"}; commit or stash before running when the cwd HEAD is the base, or pass --base-ref for a different base`),
|
|
224
|
+
{ code: "dirty_work_tree" },
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {string} cwd
|
|
231
|
+
* @param {{ignorePaths?: string[], ignoreRoots?: string[], baseRef?: string}} options
|
|
232
|
+
* @returns {{gitHead: string|null, dirtyTreeFingerprint: string|null}}
|
|
233
|
+
*/
|
|
234
|
+
export function gitIdentity(cwd, options = {}) {
|
|
235
|
+
try {
|
|
236
|
+
const pathspec = sourcePathspec(options);
|
|
237
|
+
const gitHead = resolveGitHead(cwd, options.baseRef);
|
|
238
|
+
const status = /** @type {Buffer} */ (gitSyncOrThrow(["-C", cwd, "status", "--porcelain=v1", "--untracked-files=all", "-z", "--", ...pathspec], {
|
|
239
|
+
encoding: "buffer",
|
|
240
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
241
|
+
}));
|
|
242
|
+
const statusText = status.toString("utf8");
|
|
243
|
+
const diff = !gitHead || status.length === 0
|
|
244
|
+
? Buffer.alloc(0)
|
|
245
|
+
: /** @type {Buffer} */ (gitSyncOrThrow(["-C", cwd, "diff", "--binary", "HEAD", "--", ...pathspec], {
|
|
246
|
+
encoding: "buffer",
|
|
247
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
248
|
+
}));
|
|
249
|
+
const untrackedFiles = statusText.split("\0")
|
|
250
|
+
.filter((entry) => entry.startsWith("?? "))
|
|
251
|
+
.map((entry) => entry.slice(3));
|
|
252
|
+
const contents = createHash("sha256");
|
|
253
|
+
for (const relativePath of untrackedFiles) {
|
|
254
|
+
const absolutePath = resolve(cwd, relativePath);
|
|
255
|
+
const metadata = lstatSync(absolutePath);
|
|
256
|
+
contents.update(`${relativePath}\0${metadata.mode}\0`);
|
|
257
|
+
if (metadata.isSymbolicLink()) contents.update(readlinkSync(absolutePath));
|
|
258
|
+
else if (metadata.isFile()) contents.update(readFileSync(absolutePath));
|
|
259
|
+
contents.update("\0");
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
gitHead,
|
|
263
|
+
dirtyTreeFingerprint: createHash("sha256")
|
|
264
|
+
.update(status)
|
|
265
|
+
.update(diff)
|
|
266
|
+
.update(contents.digest())
|
|
267
|
+
.update(agentGuidanceIdentity(cwd))
|
|
268
|
+
.digest("hex"),
|
|
269
|
+
};
|
|
270
|
+
} catch {
|
|
271
|
+
return { gitHead: null, dirtyTreeFingerprint: null };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Hash AGENTS.md separately so its machine-managed signal may change without
|
|
276
|
+
* hiding edits to human-authored repository guidance.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} cwd
|
|
279
|
+
* @returns {Buffer}
|
|
280
|
+
*/
|
|
281
|
+
function agentGuidanceIdentity(cwd) {
|
|
282
|
+
const path = resolve(cwd, "AGENTS.md");
|
|
283
|
+
const identity = createHash("sha256").update("AGENTS.md\0");
|
|
284
|
+
try {
|
|
285
|
+
const metadata = lstatSync(path);
|
|
286
|
+
identity.update(`${metadata.mode}\0`);
|
|
287
|
+
if (metadata.isSymbolicLink()) identity.update(`symlink\0${readlinkSync(path)}`);
|
|
288
|
+
else if (metadata.isFile()) identity.update(`file\0${normalizeManagedSignalBlock(readFileSync(path, "utf8"))}`);
|
|
289
|
+
else identity.update("unsupported");
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
292
|
+
identity.update("missing");
|
|
293
|
+
}
|
|
294
|
+
return identity.digest();
|
|
295
|
+
}
|