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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. 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
+ }