faberun 0.10.0 → 0.12.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 (60) hide show
  1. package/README.md +6 -0
  2. package/integrations/claude-code/statusline.sh +31 -2
  3. package/package.json +2 -2
  4. package/skills/faberun/references/spec-format.md +9 -0
  5. package/src/campaign/chain.mjs +2 -1
  6. package/src/campaign/index.mjs +102 -1
  7. package/src/campaign/metrics.mjs +2 -2
  8. package/src/cli/brand.mjs +2 -0
  9. package/src/cli/campaign-contract.mjs +55 -0
  10. package/src/cli/campaign.mjs +48 -13
  11. package/src/cli/init.mjs +8 -2
  12. package/src/cli/launch.mjs +2 -1
  13. package/src/cli/plan.mjs +3 -2
  14. package/src/cli/project.mjs +150 -0
  15. package/src/cli/skills.mjs +2 -3
  16. package/src/cli/update.mjs +7 -10
  17. package/src/cli.mjs +30 -4
  18. package/src/contract/index.mjs +44 -0
  19. package/src/contract/task-packet.mjs +12 -3
  20. package/src/engine/cancel.mjs +13 -0
  21. package/src/engine/dispatch.mjs +20 -2
  22. package/src/engine/lifecycle.mjs +38 -61
  23. package/src/engine/live-preflight.mjs +2 -1
  24. package/src/engine/notify-queue.mjs +9 -1
  25. package/src/engine/pricing-seed.json +543 -0
  26. package/src/engine/pricing-seed.mjs +54 -0
  27. package/src/engine/process.mjs +8 -1
  28. package/src/engine/result-file.mjs +2 -1
  29. package/src/engine/resume.mjs +6 -2
  30. package/src/engine/run-identity.mjs +9 -2
  31. package/src/engine/scheduler.mjs +40 -6
  32. package/src/engine/settle-judge.mjs +141 -0
  33. package/src/engine/settle.mjs +1 -1
  34. package/src/harnesses/protocol.mjs +46 -10
  35. package/src/harnesses/replay/bin.mjs +10 -1
  36. package/src/host/home.mjs +10 -3
  37. package/src/host/platform.mjs +111 -0
  38. package/src/host/preflight.mjs +43 -21
  39. package/src/host/projects.mjs +155 -0
  40. package/src/notify/index.mjs +88 -10
  41. package/src/plan/pipeline.mjs +32 -9
  42. package/src/plan/repo-facts.mjs +80 -2
  43. package/src/plan/spec.mjs +2 -1
  44. package/src/repo/declared-paths.mjs +7 -1
  45. package/src/repo/integrate.mjs +1 -1
  46. package/src/repo/scope-closure.mjs +2 -1
  47. package/src/repo/signal.mjs +24 -7
  48. package/src/repo/source-identity.mjs +7 -0
  49. package/src/repo/workspace.mjs +12 -2
  50. package/src/repo/worktree.mjs +89 -22
  51. package/src/report/next.mjs +66 -6
  52. package/src/report/progress.mjs +724 -0
  53. package/src/report/render.mjs +3 -3
  54. package/src/run/migrate.mjs +278 -0
  55. package/src/run/paths.mjs +189 -0
  56. package/src/seat/index.mjs +2 -1
  57. package/src/web/app.css +170 -0
  58. package/src/web/app.mjs +690 -0
  59. package/src/web/index.html +38 -283
  60. package/src/web/server.mjs +197 -182
@@ -22,6 +22,7 @@ import { errorCode } from "../util.mjs";
22
22
  import { execFileSync } from "node:child_process";
23
23
  import { fail, isContained } from "../util.mjs";
24
24
  import { normalizeManagedSignalBlock } from "./signal-block.mjs";
25
+ import { RUNS_DIR_NAME } from "../run/paths.mjs";
25
26
 
26
27
  /** A `node_modules` directory at any depth, matched as a whole path segment. */
27
28
  const NODE_MODULES_SEGMENT = /(?:^|\/)node_modules(?:\/|$)/u;
@@ -311,7 +312,12 @@ function captureIgnoreSources(root) {
311
312
  throw fail("snapshot_read_error", `cannot inspect workspace directory ${relativeWorkspacePath(root, directory)}: ${error instanceof Error ? error.message : String(error)}`);
312
313
  }
313
314
  for (const entry of entries) {
314
- if (entry.name === ".git" || entry.name === ".runs") continue;
315
+ // The runner's own state and git's own metadata carry no ignore rule a
316
+ // fingerprint here needs to see: `.runs/` is the runner's scratch tree,
317
+ // walked elsewhere for evidence and never for gitignore files, and
318
+ // `.git` is git's own directory, not the workspace git ignores things
319
+ // in.
320
+ if (entry.name === ".git" || entry.name === RUNS_DIR_NAME) continue;
315
321
  // Same exclusions as the entries snapshot (see the task-packet
316
322
  // reference): `node_modules` at any depth, and the agent runtimes'
317
323
  // scratch state at the root (`.claude`, `.codex`). Ignore files inside
@@ -466,7 +472,11 @@ function relevantWorkspacePaths(cwd) {
466
472
  const paths = new Set();
467
473
  for (const value of output.toString("utf8").split("\0")) {
468
474
  if (!value) continue;
469
- if (value === ".runs" || value.startsWith(".runs/")) continue;
475
+ // The runner's own scratch tree, excluded for the same reason as in the
476
+ // ignore-source walk above: it is machine-owned state, not evidence of
477
+ // what the worker changed, and letting it into the snapshot would fail a
478
+ // node on the runner's own bookkeeping rather than on the worker's work.
479
+ if (value === RUNS_DIR_NAME || value.startsWith(`${RUNS_DIR_NAME}/`)) continue;
470
480
  // Same exclusion as the ignore-source walk above: a repository's
471
481
  // gitignore conventionally excludes `node_modules/` as a directory
472
482
  // pattern, which does not match the symlink an attempt worktree links it
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
3
- import { dirname, join } from "node:path";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { RUNS_DIR_NAME, attemptWorktreePath, candidateWorktreePath } from "../run/paths.mjs";
4
5
 
5
6
  /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
6
7
 
@@ -103,26 +104,11 @@ export function candidateRefName(runId) {
103
104
  return `refs/faberun/${runId}/candidate`;
104
105
  }
105
106
 
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
107
  /** @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
117
108
  function attemptBranchName(runId, nodeId, attempt) {
118
109
  return `faberun/${runId}/${nodeId}/${attempt}`;
119
110
  }
120
111
 
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
112
  /** @param {unknown} error @returns {string} */
127
113
  function gitFailureReason(error) {
128
114
  const streams = /** @type {{stderr?: unknown, stdout?: unknown}} */ (error ?? {});
@@ -183,14 +169,16 @@ export function createRunRef(repo, runId, head) {
183
169
  }
184
170
 
185
171
  /**
186
- * @param {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, base?: string}} args
172
+ * @param {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, base?: string, declaredReads?: string[]}} args
187
173
  * `base` cuts the new branch from a sealed sha instead of the run ref tip —
188
174
  * the previous attempt's sealed work, when it left one (TECH-SPEC lean
189
175
  * v0.3 section 3 rule 4). Omitted, it falls back to the run ref tip as
190
- * before.
176
+ * before. `declaredReads` names the packet's readFiles: entries git does
177
+ * not track are carried into the fresh worktree, since a checkout carries
178
+ * only tracked content and the worker reads the packet's reads from here.
191
179
  * @returns {AttemptWorktree}
192
180
  */
193
- export function createAttemptWorktree({ repo, runDir, runId, nodeId, attempt, base }) {
181
+ export function createAttemptWorktree({ repo, runDir, runId, nodeId, attempt, base, declaredReads }) {
194
182
  const runRefSha = gitHead(repo, runRefName(runId));
195
183
  if (!runRefSha) throw Object.assign(new Error(`integration ref is unavailable for ${runId}`), { code: "run_ref_missing" });
196
184
  const path = attemptWorktreePath(runDir, runId, nodeId, attempt);
@@ -206,6 +194,7 @@ export function createAttemptWorktree({ repo, runDir, runId, nodeId, attempt, ba
206
194
  runGit(["-C", repo, "worktree", "add", path, "-b", branch, base ?? runRefName(runId)]);
207
195
  }
208
196
  prepareWorktreeEnvironment(repo, path);
197
+ carryDeclaredReads(repo, path, declaredReads ?? []);
209
198
  return { status: "ready", path, branch, commit: gitHead(path), baseSha: base ?? runRefSha };
210
199
  }
211
200
 
@@ -242,6 +231,67 @@ function isSymlink(path) {
242
231
  }
243
232
  }
244
233
 
234
+ /**
235
+ * Carry a packet's declared reads that git does not track from the repository
236
+ * checkout into a freshly created attempt worktree. Validation checks
237
+ * readFiles against the repository, but the worker reads them from the
238
+ * worktree, and a worktree carries only tracked content: an ignored or
239
+ * untracked declared read — the plan pipeline's gitignored repo-facts relay is
240
+ * the live case — would be absent from the very packet that names it.
241
+ *
242
+ * A tracked file is never copied, so a dirty working copy cannot leak into a
243
+ * clean attempt: the worktree's own checkout is the only source for those. A
244
+ * path already present in the worktree is left alone, so recreating one never
245
+ * clobbers what an attempt left there.
246
+ *
247
+ * @param {string} repo @param {string} worktree @param {string[]} readFiles @returns {void}
248
+ */
249
+ function carryDeclaredReads(repo, worktree, readFiles) {
250
+ const repoRoot = resolve(repo);
251
+ const realRepoRoot = realpathSync(repoRoot);
252
+ for (const read of readFiles) {
253
+ if (typeof read !== "string" || !read || isAbsolute(read)) continue;
254
+ const source = resolve(repoRoot, read);
255
+ let real;
256
+ try {
257
+ if (!statSync(source).isFile()) continue;
258
+ real = realpathSync(source);
259
+ } catch {
260
+ // Absent or unreadable — including a broken symlink inside the path:
261
+ // nothing exists to carry, and a declared read may legitimately be
262
+ // produced later or removed since authoring.
263
+ continue;
264
+ }
265
+ // Containment inline: task-packet.mjs owns pathInside privately, and a
266
+ // second top-level body would fail the duplicate-body gate.
267
+ const rel = relative(realRepoRoot, real);
268
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue;
269
+ if (gitTracks(repo, read)) continue;
270
+ const target = join(worktree, read);
271
+ if (existsSync(target) || isSymlink(target)) continue;
272
+ mkdirSync(dirname(target), { recursive: true });
273
+ // A copy, never a symlink back at the checkout: the attempt's grounding
274
+ // input is frozen at creation, exactly like the tracked content around it.
275
+ writeFileSync(target, readFileSync(source));
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Whether git lists the path in the index, i.e. a fresh worktree's checkout
281
+ * already brings it on its own.
282
+ *
283
+ * @param {string} repo @param {string} read @returns {boolean}
284
+ */
285
+ function gitTracks(repo, read) {
286
+ try {
287
+ return git(repo, ["ls-files", "--", read]).length > 0;
288
+ } catch {
289
+ // git could not answer (locked index, unusable repository): fail toward
290
+ // "tracked", because never copying a tracked file outranks carrying one.
291
+ return true;
292
+ }
293
+ }
294
+
245
295
  /**
246
296
  * @param {{repo: string, path: string, baseSha: string|null, runId: string, nodeId: string, attempt: number}} args
247
297
  * @returns {SealedAttempt}
@@ -260,12 +310,12 @@ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
260
310
  // entry is that link would look dirty, stage it, unstage it, and commit an
261
311
  // empty change set — which exits 1 and turns every retry of an
262
312
  // already-sealed attempt into a hard failure.
263
- const dirty = git(path, ["status", "--porcelain=v1", "--", ".", ":(exclude).runs", ":(exclude)node_modules"]);
313
+ const dirty = git(path, ["status", "--porcelain=v1", "--", ".", `:(exclude)${RUNS_DIR_NAME}`, ":(exclude)node_modules"]);
264
314
  if (dirty) {
265
315
  runGit(["-C", path, "add", "-A", "--", "."]);
266
316
  // node_modules is linked into the worktree as a symlink, which `node_modules/`
267
317
  // 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"]);
318
+ runGit(["-C", path, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", RUNS_DIR_NAME, "node_modules"]);
269
319
  runGit([
270
320
  "-C", path,
271
321
  "-c", "user.email=runner@example.test",
@@ -339,6 +389,23 @@ export function removeWorktree(repo, path) {
339
389
  }
340
390
  }
341
391
 
392
+ /**
393
+ * Release one node's attempt worktree and the branch name it was checked out
394
+ * on. Git refuses to delete a branch that is still checked out anywhere, so
395
+ * the worktree goes first; both halves already tolerate an artefact that is
396
+ * already gone, which is what makes calling this on an accepted (worktree
397
+ * already `removed`) or an already-released attempt harmless.
398
+ *
399
+ * @param {string} repo
400
+ * @param {string|null|undefined} path
401
+ * @param {string} branch
402
+ * @returns {void}
403
+ */
404
+ export function releaseAttemptWorktree(repo, path, branch) {
405
+ removeWorktree(repo, path);
406
+ deleteRef(repo, `refs/heads/${branch}`);
407
+ }
408
+
342
409
  /** @param {string} repo @param {string} runDir @param {string} runId @returns {void} */
343
410
  export function cleanupCandidate(repo, runDir, runId) {
344
411
  removeWorktree(repo, candidateWorktreePath(runDir, runId));
@@ -14,6 +14,7 @@
14
14
  * never through `loadRun`/`validateNodeSnapshot`: a torn snapshot is exactly
15
15
  * the case this command must survive and report, not crash on.
16
16
  */
17
+ import { readFileSync } from "node:fs";
17
18
  import { join } from "node:path";
18
19
  import { discoverCampaigns } from "../campaign/index.mjs";
19
20
  import { readJournalForDedupe } from "../campaign/journal.mjs";
@@ -23,7 +24,17 @@ import { errorMessage } from "../util.mjs";
23
24
 
24
25
  /** @typedef {Record<string, unknown>} JsonObject */
25
26
  /** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
26
- /** @typedef {{campaign: string, rank: number, reason: string, command: string, runnable: boolean}} NextItem */
27
+ /**
28
+ * `message`, when present, is the newest already-rendered progress line for
29
+ * the campaign (read back from a linked run's own `notify.jsonl`, never
30
+ * re-rendered here). Every per-node/per-run candidate constructor below
31
+ * leaves it unset; `campaignItem` attaches it once, to whichever candidate
32
+ * wins, so there is exactly one place that decides it rather than every
33
+ * constructor repeating the read. It is for the human text only --
34
+ * `renderNextJson` keeps carrying structure, not prose, so it omits this
35
+ * field.
36
+ * @typedef {{campaign: string, rank: number, reason: string, command: string, runnable: boolean, message?: string|null}} NextItem
37
+ */
27
38
 
28
39
  /** Node states that are still in flight, versus every settled terminal state. */
29
40
  const IN_PROGRESS = new Set(["pending", "running"]);
@@ -49,6 +60,7 @@ export function computeNextItems(runsDir, cwd) {
49
60
  reason: `campaign ${entry.id} unreadable: ${errorMessage(entry.error)} (${entry.path})`,
50
61
  command: "",
51
62
  runnable: false,
63
+ message: null,
52
64
  });
53
65
  }
54
66
  for (const entry of campaigns) {
@@ -139,21 +151,60 @@ function campaignItem(entry, runsDir, cwd) {
139
151
  }
140
152
  }
141
153
 
154
+ const message = newestCampaignMessage(runsDir, campaign);
142
155
  const best = candidates.reduce(
143
156
  (found, candidate) => (!found || candidate.rank < found.rank ? candidate : found),
144
157
  /** @type {NextItem|null} */ (null),
145
158
  );
146
- if (best) return best;
147
- if (!inProgress) return closureItem(campaign, entry.path, cwd);
159
+ if (best) return { ...best, message };
160
+ if (!inProgress) return { ...closureItem(campaign, entry.path, cwd), message };
148
161
  return {
149
162
  campaign: campaign.id,
150
163
  rank: 6,
151
164
  reason: `run ${liveRunId ?? campaign.linkedRunIds[0] ?? ""} live; nothing to do`,
152
165
  command: "",
153
166
  runnable: false,
167
+ message,
154
168
  };
155
169
  }
156
170
 
171
+ /**
172
+ * The newest already-rendered notification text across every run this
173
+ * campaign links, read straight from each run's own `notify.jsonl` receipts
174
+ * -- the durable, already-rendered text `NotifyQueue.enqueue` wrote, not a
175
+ * fresh render. `next` is a second audience for that same string, the way a
176
+ * bound transport is: reading it back here can never disagree with what the
177
+ * transport already received, because nothing here re-renders it.
178
+ *
179
+ * @param {string} runsDir
180
+ * @param {Campaign} campaign
181
+ * @returns {string|null}
182
+ */
183
+ function newestCampaignMessage(runsDir, campaign) {
184
+ /** @type {{at: string, summary: string}[]} */
185
+ const receipts = [];
186
+ for (const runId of campaign.linkedRunIds) {
187
+ let text;
188
+ try {
189
+ text = readFileSync(join(runsDir, runId, "notify.jsonl"), "utf8");
190
+ } catch {
191
+ continue;
192
+ }
193
+ for (const line of text.split("\n")) {
194
+ if (!line.trim()) continue;
195
+ let entry;
196
+ try {
197
+ entry = JSON.parse(line);
198
+ } catch {
199
+ continue;
200
+ }
201
+ if (entry && typeof entry.summary === "string" && typeof entry.at === "string") receipts.push({ at: entry.at, summary: entry.summary });
202
+ }
203
+ }
204
+ const newest = receipts.sort((left, right) => left.at.localeCompare(right.at)).at(-1);
205
+ return newest ? newest.summary : null;
206
+ }
207
+
157
208
  /**
158
209
  * A live controller is the exact inverse of the stale test: a lock that is
159
210
  * absent, `{invalid: true}` (unparsable), stale, or unreadable is not live.
@@ -363,11 +414,20 @@ function closureItem(campaign, campaignPath, cwd) {
363
414
  };
364
415
  }
365
416
 
366
- /** @param {NextItem} item @returns {string} */
417
+ /**
418
+ * The item's own line, plus its newest progress message indented on the line
419
+ * after it when one exists -- the same string `next --json` deliberately
420
+ * omits, since prose has no place in that structured payload.
421
+ *
422
+ * @param {NextItem} item
423
+ * @returns {string}
424
+ */
367
425
  function renderLine(item) {
368
426
  const prefix = `${item.campaign}: ${item.reason}`;
369
- if (!item.command) return prefix;
370
- return `${prefix} · ${item.command}${item.runnable ? "" : " [template]"}`;
427
+ const line = item.command ? `${prefix} · ${item.command}${item.runnable ? "" : " [template]"}` : prefix;
428
+ if (!item.message) return line;
429
+ const indented = item.message.split("\n").map((part) => ` ${part}`).join("\n");
430
+ return `${line}\n${indented}`;
371
431
  }
372
432
 
373
433
  /**