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,352 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
6
+
7
+ /** @typedef {{status: "ready", path: string, branch: string, commit: string|null, baseSha: string}} AttemptWorktree */
8
+ /** @typedef {{sha: string, empty: boolean}} SealedAttempt */
9
+ /** @typedef {{encoding?: "utf8"|"buffer", stdio?: import("node:child_process").StdioOptions, timeoutMs?: number, maxBuffer?: number, cwd?: string, env?: NodeJS.ProcessEnv}} BoundedGitOptions */
10
+ /** @typedef {{status: number|null, signal: NodeJS.Signals|null, stdout: string|Buffer, stderr: string|Buffer, error?: Error & {code?: string}, timedOut: boolean}} BoundedGitResult */
11
+
12
+ /**
13
+ * The wall-clock bound on every synchronous git subprocess. A controller that
14
+ * blocks forever on `git` while another process holds `.git/index.lock` is a
15
+ * frozen loop; git has no timeout of its own, so the wrapper supplies one.
16
+ * 30s is far longer than any one of these calls takes at the sizes this runner
17
+ * uses, and far shorter than an operator waiting on a silent run.
18
+ */
19
+ export const GIT_SYNC_TIMEOUT_MS = 30_000;
20
+
21
+ /**
22
+ * The timeout a call uses: the explicit option, else the operator override
23
+ * (`FABERUN_GIT_TIMEOUT_MS`, for a slow disk or a test), else the
24
+ * default. Read at call time so the env is honoured without a restart.
25
+ *
26
+ * @param {number|undefined} optionMs
27
+ * @returns {number}
28
+ */
29
+ function gitSyncTimeoutMs(optionMs) {
30
+ if (optionMs !== undefined) return optionMs;
31
+ const raw = process.env.FABERUN_GIT_TIMEOUT_MS;
32
+ const parsed = raw === undefined ? Number.NaN : Number(raw);
33
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : GIT_SYNC_TIMEOUT_MS;
34
+ }
35
+
36
+ /**
37
+ * The one place a synchronous `git` process is spawned. `execFileSync`-style
38
+ * callers (`runGit`) read `stdout`, `spawnSync`-style callers read the result
39
+ * object, and both are held to the same timeout. A spawn that hits the timeout
40
+ * carries a named `git_timeout` error instead of hanging.
41
+ *
42
+ * @param {string[]} args
43
+ * @param {BoundedGitOptions} [options]
44
+ * @returns {BoundedGitResult}
45
+ */
46
+ export function boundedGitSync(args, options = {}) {
47
+ const timeoutMs = gitSyncTimeoutMs(options.timeoutMs);
48
+ const result = spawnSync("git", args, {
49
+ encoding: options.encoding ?? "utf8",
50
+ stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
51
+ timeout: timeoutMs,
52
+ killSignal: "SIGKILL",
53
+ ...(options.maxBuffer !== undefined ? { maxBuffer: options.maxBuffer } : {}),
54
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
55
+ ...(options.env !== undefined ? { env: options.env } : {}),
56
+ });
57
+ const timedOut = result.error !== undefined && /** @type {{code?: string}} */ (result.error).code === "ETIMEDOUT";
58
+ if (timedOut) {
59
+ /** @type {Error & {code?: string}} */
60
+ const error = new Error(`git ${args.join(" ")} timed out after ${timeoutMs}ms`);
61
+ error.code = "git_timeout";
62
+ error.cause = result.error;
63
+ return { ...result, error, timedOut: true };
64
+ }
65
+ return { ...result, timedOut: false };
66
+ }
67
+
68
+ /**
69
+ * Run git and, when it fails, carry git's own stderr into the error.
70
+ *
71
+ * Node's `execFileSync` error says only `Command failed: git -C … commit -qm
72
+ * …` and drops the reason. That is how an empty-change-set commit exiting 1
73
+ * was misdiagnosed twice across two campaigns: the surfaced error named the
74
+ * command, never git's "nothing to commit". Every git call here goes through
75
+ * this helper so a failure always says why.
76
+ *
77
+ * @param {string[]} args @returns {string}
78
+ */
79
+ function runGit(args) {
80
+ const result = boundedGitSync(args, { encoding: "utf8" });
81
+ if (result.error || result.status !== 0) {
82
+ const error = /** @type {Error & {stderr?: unknown, stdout?: unknown, status?: number|null, signal?: string|null}} */ (result.error ?? new Error(`Command failed: git ${args.join(" ")}`));
83
+ if (result.error === undefined) {
84
+ error.stderr = result.stderr;
85
+ error.stdout = result.stdout;
86
+ error.status = result.status;
87
+ error.signal = result.signal;
88
+ }
89
+ const reason = gitFailureReason(error);
90
+ if (reason && !String(error.message).includes(reason)) error.message = `${String(error.message).split("\n")[0]}: ${reason}`;
91
+ throw error;
92
+ }
93
+ return String(result.stdout ?? "").trim();
94
+ }
95
+
96
+ /** @param {string} runId @returns {string} */
97
+ export function runRefName(runId) {
98
+ return `refs/faberun/${runId}/run`;
99
+ }
100
+
101
+ /** @param {string} runId @returns {string} */
102
+ export function candidateRefName(runId) {
103
+ return `refs/faberun/${runId}/candidate`;
104
+ }
105
+
106
+ /** @param {string} runDir @param {string} runId @returns {string} */
107
+ function worktreeRoot(runDir, runId) {
108
+ return join(dirname(runDir), "worktrees", runId);
109
+ }
110
+
111
+ /** @param {string} runDir @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
112
+ export function attemptWorktreePath(runDir, runId, nodeId, attempt) {
113
+ return join(worktreeRoot(runDir, runId), `${nodeId}.${attempt}`);
114
+ }
115
+
116
+ /** @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
117
+ function attemptBranchName(runId, nodeId, attempt) {
118
+ return `faberun/${runId}/${nodeId}/${attempt}`;
119
+ }
120
+
121
+ /** @param {string} runDir @param {string} runId @returns {string} */
122
+ export function candidateWorktreePath(runDir, runId) {
123
+ return join(worktreeRoot(runDir, runId), ".candidate");
124
+ }
125
+
126
+ /** @param {unknown} error @returns {string} */
127
+ function gitFailureReason(error) {
128
+ const streams = /** @type {{stderr?: unknown, stdout?: unknown}} */ (error ?? {});
129
+ return [streams.stderr, streams.stdout]
130
+ .map((stream) => (typeof stream === "string" ? stream : stream ? String(stream) : ""))
131
+ .map((text) => text.trim())
132
+ .find(Boolean) ?? "";
133
+ }
134
+
135
+ /** @param {string} repo @param {string[]} args @returns {string} */
136
+ export function git(repo, args) {
137
+ return runGit(["-C", repo, ...args]);
138
+ }
139
+
140
+ /** @param {string} repo @param {string} [ref] @returns {string|null} */
141
+ export function gitHead(repo, ref = "HEAD") {
142
+ try {
143
+ return git(repo, ["rev-parse", ref]);
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The path of the worktree that has `branch` checked out right now, or null.
151
+ * The operator's own checkout is included: `git worktree list` reports it, so
152
+ * moving that branch under a live checkout is exactly what this guards.
153
+ *
154
+ * @param {string} repo
155
+ * @param {string} branch
156
+ * @returns {string|null}
157
+ */
158
+ export function worktreeCheckedOutAt(repo, branch) {
159
+ const ref = `refs/heads/${branch}`;
160
+ let output;
161
+ try {
162
+ output = git(repo, ["worktree", "list", "--porcelain"]);
163
+ } catch {
164
+ // A repository that cannot list its worktrees cannot prove the branch is
165
+ // safe to move; the caller treats null as "not checked out".
166
+ return null;
167
+ }
168
+ let path = null;
169
+ for (const line of output.split("\n")) {
170
+ if (line.startsWith("worktree ")) path = line.slice("worktree ".length);
171
+ else if (line.startsWith("branch ") && line.slice("branch ".length) === ref) return path;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /** @param {string} repo @param {string} runId @param {string|null|undefined} head @returns {string} */
177
+ export function createRunRef(repo, runId, head) {
178
+ if (!head) throw Object.assign(new Error("an execution repository must have at least one commit"), { code: "git_head_required" });
179
+ const ref = runRefName(runId);
180
+ if (gitHead(repo, ref)) throw new Error(`run ref already exists: ${ref}`);
181
+ runGit(["-C", repo, "update-ref", ref, head]);
182
+ return ref;
183
+ }
184
+
185
+ /**
186
+ * @param {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, base?: string}} args
187
+ * `base` cuts the new branch from a sealed sha instead of the run ref tip —
188
+ * the previous attempt's sealed work, when it left one (TECH-SPEC lean
189
+ * v0.3 section 3 rule 4). Omitted, it falls back to the run ref tip as
190
+ * before.
191
+ * @returns {AttemptWorktree}
192
+ */
193
+ export function createAttemptWorktree({ repo, runDir, runId, nodeId, attempt, base }) {
194
+ const runRefSha = gitHead(repo, runRefName(runId));
195
+ if (!runRefSha) throw Object.assign(new Error(`integration ref is unavailable for ${runId}`), { code: "run_ref_missing" });
196
+ const path = attemptWorktreePath(runDir, runId, nodeId, attempt);
197
+ const branch = attemptBranchName(runId, nodeId, attempt);
198
+ mkdirSync(dirname(path), { recursive: true });
199
+ const existingCommit = gitHead(path);
200
+ const existingBranch = gitHead(repo, branch);
201
+ if (existingCommit) {
202
+ if (existingBranch !== existingCommit) throw new Error(`attempt worktree identity does not match ${branch}: ${path}`);
203
+ } else if (existingBranch) {
204
+ runGit(["-C", repo, "worktree", "add", path, branch]);
205
+ } else {
206
+ runGit(["-C", repo, "worktree", "add", path, "-b", branch, base ?? runRefName(runId)]);
207
+ }
208
+ prepareWorktreeEnvironment(repo, path);
209
+ return { status: "ready", path, branch, commit: gitHead(path), baseSha: base ?? runRefSha };
210
+ }
211
+
212
+ /**
213
+ * Give a fresh worktree the environment repository tooling needs: the
214
+ * installed `node_modules`, linked as a symlink and never copied, so
215
+ * commitlint through the commit-msg hook and `npm run typecheck` work without
216
+ * an install. A no-op when the repository has nothing installed, or the
217
+ * worktree already has an entry at that path.
218
+ *
219
+ * Every worktree a run creates goes through here — attempts and the
220
+ * integration candidate alike. That is the point of the single function: the
221
+ * candidate re-runs the verification the attempt just passed, so any
222
+ * environment the attempt had and the candidate lacked turns a correct node
223
+ * into a failed one, and the failure names the node rather than the missing
224
+ * install.
225
+ *
226
+ * @param {string} repo @param {string} path @returns {void}
227
+ */
228
+ function prepareWorktreeEnvironment(repo, path) {
229
+ const source = join(repo, "node_modules");
230
+ if (!existsSync(source)) return;
231
+ const target = join(path, "node_modules");
232
+ if (existsSync(target) || isSymlink(target)) return;
233
+ symlinkSync(source, target);
234
+ }
235
+
236
+ /** @param {string} path @returns {boolean} */
237
+ function isSymlink(path) {
238
+ try {
239
+ return lstatSync(path).isSymbolicLink();
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * @param {{repo: string, path: string, baseSha: string|null, runId: string, nodeId: string, attempt: number}} args
247
+ * @returns {SealedAttempt}
248
+ */
249
+ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
250
+ // The attempt-local `.runs` result sidecar must never enter the attempt
251
+ // commit. Naming it through an exclude pathspec makes `git add` exit 1 with
252
+ // advice.addIgnoredFile as soon as the sidecar exists in a repository that
253
+ // ignores `.runs/` (every real worker writes it), so stage everything and
254
+ // unstage the sidecar afterwards; that also covers a repository that does
255
+ // not ignore it.
256
+ //
257
+ // The probe below must therefore exclude exactly what the staging step
258
+ // unstages, `node_modules` included: `node_modules/` in .gitignore does not
259
+ // match the symlink of the same name, so a re-sealed attempt whose only
260
+ // entry is that link would look dirty, stage it, unstage it, and commit an
261
+ // empty change set — which exits 1 and turns every retry of an
262
+ // already-sealed attempt into a hard failure.
263
+ const dirty = git(path, ["status", "--porcelain=v1", "--", ".", ":(exclude).runs", ":(exclude)node_modules"]);
264
+ if (dirty) {
265
+ runGit(["-C", path, "add", "-A", "--", "."]);
266
+ // node_modules is linked into the worktree as a symlink, which `node_modules/`
267
+ // in .gitignore does not match; never let the link into the attempt commit.
268
+ runGit(["-C", path, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".runs", "node_modules"]);
269
+ runGit([
270
+ "-C", path,
271
+ "-c", "user.email=runner@example.test",
272
+ "-c", "user.name=faberun",
273
+ "-c", "commit.gpgSign=false",
274
+ // The seal is bookkeeping, not a contribution: it checkpoints one
275
+ // attempt's worktree onto a throwaway `faberun/<run>/<node>/<attempt>` branch
276
+ // so the next attempt can build on it, and nothing here is ever pushed.
277
+ // Running the target repository's hooks on it is wrong twice over.
278
+ // Measured 2026-09-13 against a repository with a plain failing
279
+ // `.git/hooks/pre-commit`: every seal failed, and because the commit
280
+ // message never varies between attempts, every node of every run failed
281
+ // the same way with no way out. A lint or test hook is also work the
282
+ // controller already does deliberately through `verification`, on a
283
+ // schedule it chose. The identity and signing overrides above are the
284
+ // same argument: this commit answers to the factory, not to the repo's
285
+ // conventions for human commits.
286
+ "commit", "--no-verify", "-qm", `faberun ${runId} ${nodeId} attempt ${attempt}`,
287
+ ]);
288
+ }
289
+ const sha = gitHead(path);
290
+ if (!sha) throw new Error(`attempt worktree has no commit: ${path}`);
291
+ const empty = Boolean(baseSha && gitDiffEmpty(path, baseSha, sha));
292
+ return { sha, empty };
293
+ }
294
+
295
+ /** @param {string} repo @param {string} base @param {string} head @returns {boolean} */
296
+ export function gitDiffEmpty(repo, base, head) {
297
+ try {
298
+ runGit(["-C", repo, "diff", "--quiet", base, head]);
299
+ return true;
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
304
+
305
+ /** @param {string} repo @param {string} ref @param {string} next @param {string} previous @returns {void} */
306
+ export function updateRefConditional(repo, ref, next, previous) {
307
+ runGit(["-C", repo, "update-ref", ref, next, previous]);
308
+ }
309
+
310
+ /** @param {string} repo @param {string} ref @returns {void} */
311
+ export function deleteRef(repo, ref) {
312
+ try {
313
+ runGit(["-C", repo, "update-ref", "-d", ref]);
314
+ } catch {
315
+ // Deleting an already absent cleanup ref is idempotent.
316
+ }
317
+ }
318
+
319
+ /**
320
+ * @param {{repo: string, runDir: string, runId: string, ref?: string}} args
321
+ * @returns {string}
322
+ */
323
+ export function createCandidateWorktree({ repo, runDir, runId, ref = candidateRefName(runId) }) {
324
+ const path = candidateWorktreePath(runDir, runId);
325
+ mkdirSync(dirname(path), { recursive: true });
326
+ runGit(["-C", repo, "worktree", "add", "--detach", path, ref]);
327
+ prepareWorktreeEnvironment(repo, path);
328
+ return path;
329
+ }
330
+
331
+ /** @param {string} repo @param {string|null|undefined} path @returns {void} */
332
+ export function removeWorktree(repo, path) {
333
+ if (!path) return;
334
+ try {
335
+ runGit(["-C", repo, "worktree", "remove", "--force", path]);
336
+ } catch (error) {
337
+ if (existsSync(path)) rmSync(path, { recursive: true, force: true });
338
+ else if (/** @type {{status?: number}} */ (error)?.status !== 128) throw error;
339
+ }
340
+ }
341
+
342
+ /** @param {string} repo @param {string} runDir @param {string} runId @returns {void} */
343
+ export function cleanupCandidate(repo, runDir, runId) {
344
+ removeWorktree(repo, candidateWorktreePath(runDir, runId));
345
+ deleteRef(repo, candidateRefName(runId));
346
+ }
347
+
348
+ /** @param {NodeSnapshot|undefined} state @returns {string|null} */
349
+ export function attemptWorkspace(state) {
350
+ const path = state?.worktree?.path;
351
+ return path && state?.worktree?.status !== "removed" && existsSync(path) ? path : null;
352
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The run's closing artifacts: the final status table, the report, and
3
+ * `findings.json`. Written once when the controller settles, from the persisted
4
+ * node snapshots alone.
5
+ *
6
+ * This is presentation. It lived inside the engine only because that is where
7
+ * the loop happened to end; nothing in the control path reads what it writes.
8
+ */
9
+ import { compactCost, compactTokens, errorCode } from "../util.mjs";
10
+ import { basename, join } from "node:path";
11
+ import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
12
+ import { scopeFindingsNote } from "../contract/scope-findings.mjs";
13
+ import { MARK, fit, roleCosts, statusNote, writeStatusArtifacts } from "./render.mjs";
14
+ import { unlinkSync } from "node:fs";
15
+
16
+ /** @typedef {ReturnType<typeof import("../run/lock.mjs").acquire>} LockHandle */
17
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
18
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
19
+
20
+ /**
21
+ * @param {string} runDir
22
+ * @param {string} runsDir
23
+ * @param {ValidatedContract} contract
24
+ * @param {Map<string, NodeSnapshot>} states
25
+ * @param {LockHandle|null} [lock]
26
+ */
27
+ export function render(runDir, runsDir, contract, states, lock = null) {
28
+ lock?.assert();
29
+ writeTextAtomic(join(runDir, "STATUS.md"), renderFinalStatus(runDir, contract, states));
30
+ writeStatusArtifacts(runDir, runsDir, contract, states);
31
+ }
32
+ /**
33
+ * @param {string} runDir
34
+ * @param {ValidatedContract} contract
35
+ * @param {Map<string, NodeSnapshot>} states
36
+ * @returns {string}
37
+ */
38
+ function renderFinalStatus(runDir, contract, states) {
39
+ const nodes = /** @type {NodeSnapshot[]} */ (contract.nodes.map((node) => states.get(node.id)).filter((node) => node !== undefined));
40
+ const runMetadata = /** @type {{identityWarnings?: string[]}} */ (readJson(join(runDir, "run.json")) ?? {});
41
+ const identityWarnings = runMetadata.identityWarnings ?? [];
42
+ const counts = new Map();
43
+ for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
44
+ const summary = [...counts].map(([status, count]) => `${count} ${status}`).join(" · ");
45
+ // The note carries every advisory marker a node earned (scope finding,
46
+ // review verdict, gate summary), so the cell holds the composed note whole.
47
+ const widths = [3, 24, 9, 28, 7, 64];
48
+ /** @param {unknown[]} cells */
49
+ const row = (cells) => cells.map((cell, index) => fit(String(cell ?? ""), widths[index])).join(" ");
50
+ const lines = [
51
+ `# run ${basename(runDir)}`,
52
+ "",
53
+ contract.goal,
54
+ "",
55
+ `${nodes.length} nodes · ${summary}`,
56
+ "",
57
+ "```",
58
+ row(["", "NODE", "STATE", "RUNTIME", "TRY", "NOTE"]),
59
+ row(widths.map((width) => "-".repeat(width))),
60
+ ];
61
+ for (const node of nodes) {
62
+ const runtime = node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : "-";
63
+ const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
64
+ const detail = statusNote(node) ?? "-";
65
+ // A scope finding leads the note and drops the phase boilerplate: the
66
+ // operator has to see it, and the fixed cell cannot hold both.
67
+ const note = scopeFindingsNote(node.scopeFindings)
68
+ ? detail
69
+ : `${detail} · phase ${planNode?.phase ?? "-"} · ${node.invocations?.at(-1)?.continuationMode ?? "fresh"}`;
70
+ lines.push(row([MARK[node.status] ?? "[?]", node.id, node.status, runtime, node.attempt ?? 0, note]));
71
+ }
72
+ lines.push("```", "", "## Needs you", "");
73
+ const attention = nodes.filter((node) => !["pending", "running", "done"].includes(node.status));
74
+ if (!attention.length && !identityWarnings.length) lines.push("Nothing needs you right now.");
75
+ for (const warning of identityWarnings) lines.push(`- [~] ${warning}`);
76
+ for (const node of attention) lines.push(`- ${MARK[node.status] ?? "[?]"} ${node.id}: ${node.gate?.summary ?? node.error?.message ?? node.status}`);
77
+ return `${lines.join("\n")}\n`;
78
+ }
79
+ /**
80
+ * @param {string} runDir
81
+ * @param {ValidatedContract} contract
82
+ * @param {Map<string, NodeSnapshot>} states
83
+ * @returns {string}
84
+ */
85
+ export function renderFinalReport(runDir, contract, states) {
86
+ const nodes = /** @type {NodeSnapshot[]} */ (contract.nodes.map((node) => states.get(node.id)).filter((node) => node !== undefined));
87
+ const counts = new Map();
88
+ for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
89
+ const summary = [...counts].map(([status, count]) => `${count} ${status}`).join(" · ");
90
+ const widths = [3, 24, 9, 7, 7, 28, 10, 10, 10, 12, 64];
91
+ /** @param {unknown[]} cells */
92
+ const row = (cells) => cells.map((cell, index) => fit(String(cell ?? ""), widths[index])).join(" ");
93
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 };
94
+ let totalCostUsd = null;
95
+ const lines = [
96
+ `# run ${basename(runDir)}`,
97
+ "",
98
+ `${nodes.length} nodes · ${summary}`,
99
+ "",
100
+ "```",
101
+ row(["", "NODE", "STATE", "TRY", "REV", "RUNTIME", "IN", "OUT", "CACHE", "COST", "NOTE"]),
102
+ row(widths.map((width) => "-".repeat(width))),
103
+ ];
104
+ for (const node of nodes) {
105
+ const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
106
+ totals.inputTokens += usage.inputTokens ?? 0;
107
+ totals.outputTokens += usage.outputTokens ?? 0;
108
+ totals.cacheReadInputTokens += usage.cacheReadInputTokens ?? 0;
109
+ if (typeof node.costUsd === "number" && Number.isFinite(node.costUsd)) totalCostUsd = (totalCostUsd ?? 0) + node.costUsd;
110
+ const runtime = node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : "-";
111
+ const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
112
+ const detail = node.gate?.summary ?? node.error?.message ?? (node.blockedBy?.length ? node.blockedBy.join(", ") : null) ?? (typeof node.result === "string" && node.result.trim() ? node.result.trim() : node.phase ?? "-");
113
+ // The advisory scope finding leads the note, as it does in STATUS.md.
114
+ const note = scopeFindingsNote(node.scopeFindings)
115
+ ? `${scopeFindingsNote(node.scopeFindings)} · ${detail}`
116
+ : `${detail} · phase ${planNode?.phase ?? "-"} · ${node.invocations?.at(-1)?.continuationMode ?? "fresh"}`;
117
+ lines.push(row([
118
+ MARK[node.status] ?? "[?]",
119
+ node.id,
120
+ node.status,
121
+ node.attempt ?? 0,
122
+ node.revisions ?? 0,
123
+ runtime,
124
+ compactTokens(usage.inputTokens),
125
+ compactTokens(usage.outputTokens),
126
+ compactTokens(usage.cacheReadInputTokens),
127
+ compactCost(node.costUsd),
128
+ note,
129
+ ]));
130
+ }
131
+ const roles = roleCosts(nodes);
132
+ lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)}`);
133
+ return `${lines.join("\n")}\n`;
134
+ }
135
+ /**
136
+ * Consolidated terminal-state handoff: one bounded JSON snapshot in the run
137
+ * dir so a triage session never loads full run state. Nodes stay the source
138
+ * of truth; this file is a snapshot of the moment the run finished. Written
139
+ * when any node ended non-done; removed when a later resume drives the run
140
+ * fully done, so a stale snapshot cannot outlive the state it described.
141
+ *
142
+ * @param {string} runDir
143
+ * @param {ValidatedContract} contract
144
+ * @param {Map<string, NodeSnapshot>} states
145
+ */
146
+ export function writeFindingsArtifact(runDir, contract, states) {
147
+ const failing = [...states.values()].filter((state) => state.status !== "done");
148
+ const path = join(runDir, "findings.json");
149
+ if (!failing.length) {
150
+ try { unlinkSync(path); } catch (error) {
151
+ if (errorCode(error) !== "ENOENT") throw error;
152
+ }
153
+ return;
154
+ }
155
+ const counts = new Map();
156
+ for (const state of states.values()) counts.set(state.status, (counts.get(state.status) ?? 0) + 1);
157
+ writeJsonAtomic(path, {
158
+ schemaVersion: 1,
159
+ run: contract.id,
160
+ goal: contract.goal,
161
+ summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
162
+ nodes: failing.map((state) => ({
163
+ id: state.id,
164
+ status: state.status,
165
+ attempt: state.attempt,
166
+ revisions: state.revisions,
167
+ error: state.error,
168
+ gate: state.gate,
169
+ ...(state.blockedBy?.length ? { blockedBy: state.blockedBy } : {}),
170
+ ...missingContextOf(state),
171
+ ...unexpectedPathsOf(state),
172
+ })),
173
+ });
174
+ }
175
+ /**
176
+ * @param {NodeSnapshot} state
177
+ * @returns {{missingContext?: string[]}}
178
+ */
179
+ function missingContextOf(state) {
180
+ const result = /** @type {{missingContext?: unknown}|null} */ (state.result);
181
+ if (result && Array.isArray(result.missingContext) && result.missingContext.length) {
182
+ return { missingContext: result.missingContext.map(String) };
183
+ }
184
+ return {};
185
+ }
186
+ /**
187
+ * An `unexpected_write` failure is only actionable with the offending paths,
188
+ * and the bounded error message truncates them. Carry a bounded list into the
189
+ * artifact so triage never has to open the node file.
190
+ *
191
+ * @param {NodeSnapshot} state
192
+ * @returns {{unexpectedPaths?: string[]}}
193
+ */
194
+ function unexpectedPathsOf(state) {
195
+ const scope = /** @type {{unexpectedPaths?: unknown}|null|undefined} */ (state.scope);
196
+ if (scope && Array.isArray(scope.unexpectedPaths) && scope.unexpectedPaths.length) {
197
+ return { unexpectedPaths: scope.unexpectedPaths.slice(0, 16).map(String) };
198
+ }
199
+ return {};
200
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Presentation of a metrics projection (TECH-SPEC lean section 6).
3
+ *
4
+ * `metrics.mjs` measures a campaign; this module is the only place that
5
+ * decides how a measurement is printed. Both forms render the whole indicator
6
+ * set and nothing else.
7
+ *
8
+ * The report is bounded by construction — one header line plus one line per
9
+ * indicator, with grouped values elided past `MAX_GROUPS` — so a campaign with
10
+ * many lanes or runtimes prints in the same space as one with few. The
11
+ * dependency runs one way: the projector imports the renderers, and nothing
12
+ * here reads a filesystem path or a projection's inputs.
13
+ */
14
+
15
+ /** @typedef {import("../campaign/metrics.mjs").CampaignMetrics} CampaignMetrics */
16
+ /** @typedef {import("../campaign/metrics.mjs").MetricsSources} MetricsSources */
17
+
18
+ /** Schema of the `--json` form; bumped when a consumer would have to change. */
19
+ const METRICS_SCHEMA_VERSION = 2;
20
+ /** Indicators whose value is a duration in seconds; the rest are ratios, counts or tokens. */
21
+ const SECONDS_INDICATORS = new Set(["wallClockSec"]);
22
+ /** Groups printed per grouped indicator before the line is elided; the report stays bounded. */
23
+ const MAX_GROUPS = 6;
24
+ /** The runtime-by-kind breakdown doubles the key length of every other grouped indicator, so it elides sooner. */
25
+ /** @type {Record<string, number>} */
26
+ const MAX_GROUPS_BY_INDICATOR = { usageTokensByKindByRuntime: 2 };
27
+ /** Large token counts compact to a short suffix in the two token indicators; every other grouped value prints as-is. */
28
+ const COMPACT_TOKEN_INDICATORS = new Set(["usageTokensByKind", "usageTokensByKindByRuntime"]);
29
+ const NAME_WIDTH = 28;
30
+ const SECONDS_PER_HOUR = 3600;
31
+ /** Hours print at the four decimals the projector rounds its own values to. */
32
+ const HOURS_PRECISION = 10_000;
33
+
34
+ /**
35
+ * The bounded human report: one line per indicator, always the full set.
36
+ *
37
+ * @param {MetricsSources} sources
38
+ * @param {CampaignMetrics} metrics
39
+ * @returns {string}
40
+ */
41
+ export function renderMetricsReport(sources, metrics) {
42
+ const lines = [
43
+ `[metrics] ${sources.campaignId} · ${sources.runIds.length} runs · ${sources.events.length} events · ${Object.keys(metrics).length} indicators`,
44
+ ];
45
+ for (const [name, indicator] of Object.entries(metrics)) {
46
+ const records = indicator.count === 1 ? "1 record" : `${indicator.count} records`;
47
+ const unknown = typeof (/** @type {{unknownCount?: number}} */ (indicator).unknownCount) === "number"
48
+ ? ` · ${(/** @type {{unknownCount: number}} */ (indicator)).unknownCount} unknown`
49
+ : "";
50
+ lines.push(`${name.padEnd(NAME_WIDTH)} ${indicator.direction.padEnd(11)} ${formatValue(name, indicator.value).padEnd(24)} · ${records}${unknown}`);
51
+ }
52
+ return `${lines.join("\n")}\n`;
53
+ }
54
+
55
+ /**
56
+ * The machine-readable form, one JSON line like `preflight --json`.
57
+ *
58
+ * @param {MetricsSources} sources
59
+ * @param {CampaignMetrics} metrics
60
+ * @returns {string}
61
+ */
62
+ export function renderMetricsJson(sources, metrics) {
63
+ return `${JSON.stringify({
64
+ schemaVersion: METRICS_SCHEMA_VERSION,
65
+ campaignId: sources.campaignId,
66
+ runs: sources.runIds.length,
67
+ events: sources.events.length,
68
+ indicators: metrics,
69
+ })}\n`;
70
+ }
71
+
72
+ /**
73
+ * @param {string} name
74
+ * @param {number|Record<string, number>|null} value
75
+ * @returns {string}
76
+ */
77
+ function formatValue(name, value) {
78
+ if (value === null) return "no record";
79
+ if (typeof value === "number") return SECONDS_INDICATORS.has(name) ? formatSeconds(value) : String(value);
80
+ const maxGroups = MAX_GROUPS_BY_INDICATOR[name] ?? MAX_GROUPS;
81
+ const compact = COMPACT_TOKEN_INDICATORS.has(name);
82
+ const groups = Object.entries(value);
83
+ const shown = groups.slice(0, maxGroups).map(([group, measurement]) => `${group}=${compact ? compactCount(measurement) : measurement}`);
84
+ if (groups.length > shown.length) shown.push(`+${groups.length - shown.length} more`);
85
+ return shown.join(" ");
86
+ }
87
+
88
+ /** @param {number} value @returns {string} */
89
+ function compactCount(value) {
90
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
91
+ if (value >= 1_000) return `${Math.round(value / 1_000)}k`;
92
+ return String(value);
93
+ }
94
+
95
+ /** @param {number} seconds @returns {string} */
96
+ function formatSeconds(seconds) {
97
+ const hours = seconds / SECONDS_PER_HOUR;
98
+ return hours >= 1 ? `${seconds}s (${Math.round(hours * HOURS_PRECISION) / HOURS_PRECISION}h)` : `${seconds}s`;
99
+ }