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
@@ -171,7 +171,7 @@ function formatElapsed(node, now) {
171
171
  }
172
172
 
173
173
  /** @param {number} ms @returns {string} */
174
- function formatDuration(ms) {
174
+ export function formatDuration(ms) {
175
175
  const totalSeconds = Math.floor(ms / 1000);
176
176
  const hours = Math.floor(totalSeconds / 3600);
177
177
  const minutes = Math.floor((totalSeconds % 3600) / 60);
@@ -537,7 +537,7 @@ function readNodes(runDir, contract) {
537
537
  * @param {string} runDir
538
538
  * @returns {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}}
539
539
  */
540
- function readRunUsage(runDir) {
540
+ export function readRunUsage(runDir) {
541
541
  const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: /** @type {number|null} */ (null) };
542
542
  const path = join(runDir, "usage.jsonl");
543
543
  if (!existsSync(path)) return totals;
@@ -733,7 +733,7 @@ export function roleCosts(nodes) {
733
733
  * @param {RoleUsage} role
734
734
  * @returns {string}
735
735
  */
736
- function formatRole(role) {
736
+ export function formatRole(role) {
737
737
  if (role.costUsd !== null) return compactCost(role.costUsd);
738
738
  if (role.costProvenance === "none") return "-";
739
739
  return `unpriced (in ${compactTokens(role.inputTokens)} · out ${compactTokens(role.outputTokens)} · cache ${compactTokens(role.cacheReadInputTokens)})`;
@@ -0,0 +1,278 @@
1
+ /**
2
+ * `faberun migrate`: the one command that moves a repository's legacy
3
+ * in-tree runs root into the project's directory under the home (R7).
4
+ *
5
+ * Separate from `run/paths.mjs` because that module resolves where state is
6
+ * read from and must keep answering throughout a migration, while this module
7
+ * owns the move itself, whose entire safety story is ordering. The resolver
8
+ * answers the home side the moment `<home>/projects/<id>/runs` exists, so the
9
+ * copy is never written there: it is staged at the sibling `runs.incoming`,
10
+ * verified, and published by one rename within the project's directory —
11
+ * same filesystem, so atomic. A reader therefore sees either the complete
12
+ * legacy tree, which keeps answering with its warning until the instant the
13
+ * rename lands, or the complete home copy, never a partial authoritative
14
+ * tree; a process killed mid-copy leaves nothing resolvable under the home.
15
+ *
16
+ * The controller lock is keyed by pid and process-start token, not by path,
17
+ * so the check before the copy cannot promise anything about the moment of
18
+ * removal: a resume that begins after that check and before the removal
19
+ * would have its state pulled out from under it. The re-check between the
20
+ * verified copy and the publish is what turns that race into a refusal, and
21
+ * a refused migration leaves the complete original in the tree and nothing
22
+ * authoritative under the home. A kill between the publish and the removal —
23
+ * the only two adjacent calls that touch both trees — is finished, not
24
+ * redone, by the next run: it verifies the published copy still holds every
25
+ * byte the original holds and removes the original.
26
+ *
27
+ * Idempotent by the same shape. A second run after a completed migration
28
+ * finds no legacy root and reports nothing to move — the normal case for an
29
+ * operator rerunning the command to be sure. A re-run after an interrupted
30
+ * one discards the staging copy it finds and copies anew: staging is never
31
+ * merged with, so no combination of two half-copies can ever be published.
32
+ *
33
+ * The target shape `<home>/projects/<id>/runs` and its staging sibling are
34
+ * composed here from `projectsDir` plus names `run/paths.mjs` keeps private;
35
+ * widening that module's surface for one caller is worse than spelling the
36
+ * two literals here, next to this comment.
37
+ */
38
+ import { cpSync, existsSync, lstatSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { campaignsDir } from "../campaign/layout.mjs";
41
+ import { readRemotes } from "../cli/project.mjs";
42
+ import { faberunHome } from "../host/home.mjs";
43
+ import { projectsDir, registerProject } from "../host/projects.mjs";
44
+ import { lockStale, readLock } from "./lock.mjs";
45
+ import { RUNS_DIR_NAME } from "./paths.mjs";
46
+ import { errorCode } from "../util.mjs";
47
+
48
+ /** @typedef {{runDir: string, pid: number|undefined}} LiveLease */
49
+ /** @typedef {{moved: boolean, legacy: string, target: string|null, runs: number, campaigns: number}} MigrateResult */
50
+
51
+ /**
52
+ * `rmSync`'s own retry knobs for a recursive removal, applied to every
53
+ * removal in this module: Node's own docs name a large recursive removal as
54
+ * the case `maxRetries`/`retryDelay` exist for, retrying on EBUSY, EMFILE,
55
+ * ENFILE, ENOTEMPTY and EPERM. Measured 2026-09-19: a bare
56
+ * `rmSync(legacy, { recursive: true })` raised ENOTEMPTY twice running
57
+ * against this repository's own `.runs` — thousands of files across many
58
+ * attempt worktrees and controller snapshots — with no concurrent writer of
59
+ * this module's own; a fresh copy had already verified byte for byte before
60
+ * either attempt, so this was the removal racing something transient
61
+ * (Spotlight, a `.DS_Store` write, an FS event listener), never a sign the
62
+ * data was unsafe to remove.
63
+ */
64
+ const REMOVE_OPTIONS = { recursive: true, maxRetries: 5, retryDelay: 100 };
65
+
66
+ /**
67
+ * Move the legacy runs root at `<cwd>/.runs` into the home layout:
68
+ * `<home>/projects/<project id>/runs` for the project `cwd` registers as,
69
+ * with its git remotes recorded so a later repository move can be
70
+ * reassociated by remote. Fully synchronous, like every reader of run state
71
+ * in this tree.
72
+ *
73
+ * The migration refuses — before anything is written — while any run under
74
+ * the legacy root holds a live controller lock, and refuses again after the
75
+ * copy has verified, before the copy is published and the original removed.
76
+ *
77
+ * `options.leasesOf` replaces the live-lease scan. It exists so the
78
+ * between-copy-and-removal re-check has a named test: a fully synchronous
79
+ * migration cannot be interrupted from outside, so the race is staged from
80
+ * the injected scan instead.
81
+ *
82
+ * @param {string} cwd
83
+ * @param {{home?: string, leasesOf?: (tree: string) => LiveLease[]}} [options]
84
+ * @returns {MigrateResult}
85
+ */
86
+ export function migrateRunState(cwd, options = {}) {
87
+ const home = options.home ?? faberunHome();
88
+ const leasesOf = options.leasesOf ?? liveLeases;
89
+ const legacy = join(cwd, RUNS_DIR_NAME);
90
+ if (!existsSync(legacy)) return { moved: false, legacy, target: null, runs: 0, campaigns: 0 };
91
+ refuseLiveLeases(leasesOf(legacy));
92
+ const project = registerProject(home, cwd, readRemotes(cwd));
93
+ const projectDir = join(projectsDir(home), project.id);
94
+ const target = join(projectDir, "runs");
95
+ // A published copy beside an untouched original is the one kill this
96
+ // ordering cannot route through staging: the previous attempt got through
97
+ // the rename and died before the removal. The copy needs no re-copying —
98
+ // only proof that it still holds every byte the original holds, after
99
+ // which removing the original finishes the move. Paths under the published
100
+ // copy that the original lacks are runs controllers appended after it
101
+ // became authoritative; this branch never writes to the copy, so nothing
102
+ // of theirs is at risk.
103
+ if (existsSync(target)) {
104
+ verifyCopy(legacy, target);
105
+ const runs = countRunDirs(legacy);
106
+ const campaigns = countCampaigns(legacy);
107
+ refuseLiveLeases(leasesOf(legacy));
108
+ rmSync(legacy, REMOVE_OPTIONS);
109
+ return { moved: true, legacy, target, runs, campaigns };
110
+ }
111
+ const staging = join(projectDir, "runs.incoming");
112
+ // A staging left by an interrupted attempt is a partial copy of a tree
113
+ // that may have changed since; it is discarded, never merged with.
114
+ rmSync(staging, { ...REMOVE_OPTIONS, force: true });
115
+ // verbatimSymlinks: the default resolves a copied link's target to an
116
+ // absolute path back into the tree being removed, which would dangle the
117
+ // moment the removal ran; verbatim keeps the literal target, so relative
118
+ // links (the worktrees' dependency symlinks) survive the move.
119
+ cpSync(legacy, staging, { recursive: true, verbatimSymlinks: true });
120
+ verifyCopy(legacy, staging);
121
+ verifyNothingExtra(legacy, staging);
122
+ const runs = countRunDirs(legacy);
123
+ const campaigns = countCampaigns(legacy);
124
+ // A lease found here began after the first check: its controller is still
125
+ // resolving the legacy tree — the copy is not published yet, so there is
126
+ // nothing else to resolve — and the publish would strand it on a tree
127
+ // about to be removed. Refusing leaves the complete original answering and
128
+ // nothing authoritative under the home; the re-run once the holder exits
129
+ // discards the staging, copies anew, and publishes.
130
+ refuseLiveLeases(leasesOf(legacy));
131
+ // The publish is the rename, and the rename is the only thing that ever
132
+ // creates `runs`: from this instant the home side is authoritative and
133
+ // complete, and nothing reads the legacy tree again except this removal.
134
+ renameSync(staging, target);
135
+ rmSync(legacy, REMOVE_OPTIONS);
136
+ return { moved: true, legacy, target, runs, campaigns };
137
+ }
138
+
139
+ /**
140
+ * Every controller lock under `tree` whose holder is still alive. The lock
141
+ * file's name is spelled here because `run/lock.mjs` keeps the constant
142
+ * private and this scan reads a whole tree, not one run directory. Symlinked
143
+ * directories are not descended into: the locks that lease this tree are the
144
+ * ones inside it.
145
+ *
146
+ * @param {string} tree
147
+ * @returns {LiveLease[]}
148
+ */
149
+ function liveLeases(tree) {
150
+ /** @type {LiveLease[]} */
151
+ const leases = [];
152
+ /** @param {string} dir @returns {void} */
153
+ const walk = (dir) => {
154
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
155
+ const path = join(dir, entry.name);
156
+ if (entry.isDirectory()) {
157
+ walk(path);
158
+ } else if (entry.name === "controller.lock") {
159
+ const lock = readLock(dir);
160
+ if (!lockStale(lock)) {
161
+ leases.push({ runDir: dir, pid: lock && !("invalid" in lock) ? lock.pid : undefined });
162
+ }
163
+ }
164
+ }
165
+ };
166
+ walk(tree);
167
+ return leases;
168
+ }
169
+
170
+ /** @param {LiveLease[]} leases @returns {void} */
171
+ function refuseLiveLeases(leases) {
172
+ const lease = leases[0];
173
+ if (lease) {
174
+ throw new Error(`run ${lease.runDir} holds a live controller lock (pid ${lease.pid ?? "unknown"}); stop the run or wait for its controller to exit, then run migrate again`);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * What verify means here: every path under `source` must exist under `copy`
180
+ * with the same content — directories recursively, symlinks by their literal
181
+ * target, everything else byte for byte. That comparison is exactly the
182
+ * guarantee removal needs: once the original is gone the copy is the only
183
+ * holder of this state, so "holds every byte the original held" is the whole
184
+ * of what must be true, and byte-for-byte equality is the strongest check
185
+ * that proves it without trusting the copy step's own bookkeeping.
186
+ *
187
+ * @param {string} source
188
+ * @param {string} copy
189
+ * @returns {void}
190
+ */
191
+ function verifyCopy(source, copy) {
192
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
193
+ const from = join(source, entry.name);
194
+ const to = join(copy, entry.name);
195
+ if (entry.isDirectory()) {
196
+ // lstat, not stat: a copied symlink to a directory must not pass as
197
+ // the directory it points at.
198
+ if (!lstatSync(to, { throwIfNoEntry: false })?.isDirectory()) throw verifyFailure(from, to);
199
+ verifyCopy(from, to);
200
+ } else if (entry.isSymbolicLink()) {
201
+ if (readLinkOrUndefined(to) !== readlinkSync(from)) throw verifyFailure(from, to);
202
+ } else if (readOrUndefined(to)?.equals(readFileSync(from)) !== true) throw verifyFailure(from, to);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * The staging copy must also hold nothing the original lacks. It was created
208
+ * by discarding any earlier staging and copying in one step, so an extra
209
+ * path means the copy step wrote outside its instructions — and the publish
210
+ * would make that invented state authoritative.
211
+ *
212
+ * @param {string} source
213
+ * @param {string} copy
214
+ * @returns {void}
215
+ */
216
+ function verifyNothingExtra(source, copy) {
217
+ for (const entry of readdirSync(copy, { withFileTypes: true })) {
218
+ const inSource = join(source, entry.name);
219
+ const inCopy = join(copy, entry.name);
220
+ if (entry.isDirectory()) {
221
+ if (!lstatSync(inSource, { throwIfNoEntry: false })?.isDirectory()) throw verifyExtraFailure(inCopy);
222
+ verifyNothingExtra(inSource, inCopy);
223
+ } else if (entry.isSymbolicLink()) {
224
+ // readlinkSync, never readFileSync: a worktree's node_modules symlink
225
+ // (prepareWorktreeEnvironment in repo/worktree.mjs) targets a
226
+ // directory, and readFileSync on a symlink dereferences it — measured
227
+ // 2026-09-19, this crashed migrate with EISDIR against this
228
+ // repository's own .runs tree before the branch below existed.
229
+ if (readLinkOrUndefined(inSource) === undefined) throw verifyExtraFailure(inCopy);
230
+ } else if (readOrUndefined(inSource) === undefined) {
231
+ throw verifyExtraFailure(inCopy);
232
+ }
233
+ }
234
+ }
235
+
236
+ /** @param {string} from @param {string} to @returns {Error} */
237
+ function verifyFailure(from, to) {
238
+ return new Error(`migration copy does not verify: ${from} is missing or different at ${to}; nothing was published or removed`);
239
+ }
240
+
241
+ /** @param {string} path @returns {Error} */
242
+ function verifyExtraFailure(path) {
243
+ return new Error(`migration copy does not verify: ${path} is not part of the original; nothing was published or removed`);
244
+ }
245
+
246
+ /** @param {string} path @returns {Buffer|undefined} */
247
+ function readOrUndefined(path) {
248
+ try {
249
+ return readFileSync(path);
250
+ } catch (error) {
251
+ if (errorCode(error) === "ENOENT") return undefined;
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ /** @param {string} path @returns {string|undefined} */
257
+ function readLinkOrUndefined(path) {
258
+ try {
259
+ return readlinkSync(path);
260
+ } catch (error) {
261
+ if (errorCode(error) === "ENOENT") return undefined;
262
+ throw error;
263
+ }
264
+ }
265
+
266
+ /** @param {string} runsDir @returns {number} */
267
+ function countRunDirs(runsDir) {
268
+ return readdirSync(runsDir, { withFileTypes: true })
269
+ .filter((entry) => entry.isDirectory() && existsSync(join(runsDir, entry.name, "contract.json")))
270
+ .length;
271
+ }
272
+
273
+ /** @param {string} runsDir @returns {number} */
274
+ function countCampaigns(runsDir) {
275
+ const campaigns = campaignsDir(runsDir);
276
+ if (!existsSync(campaigns)) return 0;
277
+ return readdirSync(campaigns, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length;
278
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Where a run, a campaign and a worktree tree live on disk, and the name of
3
+ * the runs directory itself.
4
+ *
5
+ * Every one of these shapes used to be spelled out inline, independently, at
6
+ * each call site that needed it, all sharing the literal `".runs"`. A later
7
+ * move or rename of that directory had to find and change every one of them
8
+ * separately; this module is the one place such a change is made instead. It
9
+ * composes `campaign/layout.mjs`, which already owns the campaign directory
10
+ * shape given a runs root, rather than re-deriving it.
11
+ *
12
+ * Since R2 the runs root answers from the operator's home, not the
13
+ * repository: `runsRoot(cwd)` is `<home>/projects/<id>/runs` for the project
14
+ * `cwd` belongs to, so run state survives a repository move and never lands
15
+ * inside the target tree. A repository that still holds its own `.runs` from
16
+ * before the move keeps being answered there until the migration command has
17
+ * run, because answering the home side of a not-yet-migrated repository would
18
+ * split its state across two layouts.
19
+ */
20
+ import { existsSync } from "node:fs";
21
+ import { dirname, join, resolve, sep } from "node:path";
22
+ import { campaignDir, campaignsDir } from "../campaign/layout.mjs";
23
+ import { faberunHome } from "../host/home.mjs";
24
+ import { findProjectByPath, projectsDir, readProject, registerProject } from "../host/projects.mjs";
25
+
26
+ /** The runs directory name, relative to the tree it lives under. */
27
+ export const RUNS_DIR_NAME = ".runs";
28
+
29
+ /** The runs directory name inside a project's directory under the home. */
30
+ const PROJECT_RUNS_DIR_NAME = "runs";
31
+
32
+ /**
33
+ * The runs directory for a working tree: the project's runs directory under
34
+ * `faberunHome()`, or — for a repository that still holds its own `.runs`
35
+ * and has nothing under the home yet — that directory, with a warning.
36
+ *
37
+ * Resolving registers a never-seen path on demand. The alternative is a
38
+ * separate init step before the first run of any repository, which no
39
+ * existing workflow performs; a resolve is how every consumer announces the
40
+ * repository it is working on, so it is also where the registration belongs.
41
+ * The registry write happens once per path: a known project resolves through
42
+ * two small read-only reads (the index, the record).
43
+ *
44
+ * When both layouts exist the home side wins. That is the mid-migration
45
+ * case: the migration command copies the repository's state into the home
46
+ * and removes the original afterwards, so a present home directory is the
47
+ * authoritative copy and the leftover `.runs` is the stale pre-copy one.
48
+ * Letting the repository side win would make a completed migration whose
49
+ * cleanup failed silently answer from state the migration already superseded.
50
+ *
51
+ * @param {string} cwd
52
+ * @returns {string}
53
+ */
54
+ export function runsRoot(cwd) {
55
+ const home = faberunHome();
56
+ const project = findProjectByPath(home, cwd);
57
+ const homeRuns = project ? projectRunsDir(home, project.id) : null;
58
+ if (homeRuns && existsSync(homeRuns)) return homeRuns;
59
+ const legacy = join(cwd, RUNS_DIR_NAME);
60
+ if (existsSync(legacy)) {
61
+ warnLegacyRuns(legacy);
62
+ return legacy;
63
+ }
64
+ const registered = project ?? registerProject(home, cwd);
65
+ return projectRunsDir(home, registered.id);
66
+ }
67
+
68
+ /** @param {string} home @param {string} id @returns {string} */
69
+ function projectRunsDir(home, id) {
70
+ return join(projectsDir(home), id, PROJECT_RUNS_DIR_NAME);
71
+ }
72
+
73
+ let warnedLegacyRuns = false;
74
+
75
+ /**
76
+ * One warning per process, not per resolve: the resolver sits under a hot
77
+ * path, and repeating the same stderr line at every call would bury whatever
78
+ * the command is actually printing.
79
+ *
80
+ * @param {string} legacyPath
81
+ * @returns {void}
82
+ */
83
+ function warnLegacyRuns(legacyPath) {
84
+ if (warnedLegacyRuns) return;
85
+ warnedLegacyRuns = true;
86
+ process.stderr.write(`[warn] runs · ${legacyPath} still holds this repository's runs; move them under the home with \`faberun migrate\`\n`);
87
+ }
88
+
89
+ /**
90
+ * The project id a runs directory under the home layout carries, or null for
91
+ * any other shape — a legacy `<repo>/.runs` answers null because
92
+ * `RUNS_DIR_NAME` is dotted and `PROJECT_RUNS_DIR_NAME` is not.
93
+ *
94
+ * `<home>/projects/<id>/runs` is composed entirely by this module, so the id
95
+ * sits at a fixed position: between the `projects` segment and the runs
96
+ * directory itself. Readers that must name the repository a runs directory
97
+ * belongs to (the AGENTS.md signal block, campaign ledger preservation) parse
98
+ * it here instead of deriving a sibling path by directory arithmetic, which
99
+ * only held while runs lived inside the repository.
100
+ *
101
+ * @param {string} runsDir
102
+ * @returns {string|null}
103
+ */
104
+ export function projectIdForRunsDir(runsDir) {
105
+ const parts = resolve(runsDir).split(sep);
106
+ const id = parts.at(-2);
107
+ if (parts.at(-3) !== "projects" || parts.at(-1) !== PROJECT_RUNS_DIR_NAME || !id) return null;
108
+ return id;
109
+ }
110
+
111
+ /**
112
+ * The repository path a runs directory belongs to, read live from the
113
+ * project registry, or null when the runs directory is not under the home
114
+ * layout or its project has no record. Live, never cached: a project
115
+ * reassociated to a new path must answer its current repository.
116
+ *
117
+ * @param {string} runsDir
118
+ * @returns {string|null}
119
+ */
120
+ export function repositoryForRunsDir(runsDir) {
121
+ const id = projectIdForRunsDir(runsDir);
122
+ if (!id) return null;
123
+ return readProject(faberunHome(), id)?.path ?? null;
124
+ }
125
+
126
+ /**
127
+ * A single run's own directory.
128
+ *
129
+ * @param {string} cwd
130
+ * @param {string} runId
131
+ * @returns {string}
132
+ */
133
+ export function runDirectory(cwd, runId) {
134
+ return join(runsRoot(cwd), runId);
135
+ }
136
+
137
+ /**
138
+ * The campaigns directory beneath a runs root: `<runs>/campaigns`.
139
+ * Composes `campaign/layout.mjs`'s `campaignsDir`, which owns the shape given
140
+ * a runs root.
141
+ *
142
+ * @param {string} cwd
143
+ * @returns {string}
144
+ */
145
+ export function campaignsRoot(cwd) {
146
+ return campaignsDir(runsRoot(cwd));
147
+ }
148
+
149
+ /**
150
+ * A single campaign's own directory: `<runs>/campaigns/<campaignId>`.
151
+ * Composes `campaign/layout.mjs`'s `campaignDir`, which owns the shape given
152
+ * a runs root.
153
+ *
154
+ * @param {string} cwd
155
+ * @param {string} campaignId
156
+ * @returns {string}
157
+ */
158
+ export function campaignTree(cwd, campaignId) {
159
+ return campaignDir(runsRoot(cwd), campaignId);
160
+ }
161
+
162
+ /** @param {string} runDir @param {string} runId @returns {string} */
163
+ function worktreeRoot(runDir, runId) {
164
+ return join(dirname(runDir), "worktrees", runId);
165
+ }
166
+
167
+ /**
168
+ * The isolated worktree for one attempt at one node.
169
+ *
170
+ * @param {string} runDir
171
+ * @param {string} runId
172
+ * @param {string} nodeId
173
+ * @param {number} attempt
174
+ * @returns {string}
175
+ */
176
+ export function attemptWorktreePath(runDir, runId, nodeId, attempt) {
177
+ return join(worktreeRoot(runDir, runId), `${nodeId}.${attempt}`);
178
+ }
179
+
180
+ /**
181
+ * The detached worktree an integration candidate is verified in.
182
+ *
183
+ * @param {string} runDir
184
+ * @param {string} runId
185
+ * @returns {string}
186
+ */
187
+ export function candidateWorktreePath(runDir, runId) {
188
+ return join(worktreeRoot(runDir, runId), ".candidate");
189
+ }
@@ -18,6 +18,7 @@ import { briefFromState, materializeBrief } from "../campaign/brief.mjs";
18
18
  import { readProjectionState } from "../campaign/projection.mjs";
19
19
  import { resolveCampaign } from "../campaign/index.mjs";
20
20
  import { errorMessage } from "../util.mjs";
21
+ import { runsRoot } from "../run/paths.mjs";
21
22
 
22
23
  /** `seat status --json` payload version. */
23
24
  const SEAT_STATUS_SCHEMA_VERSION = 1;
@@ -117,7 +118,7 @@ export function stopSeat(options = {}) {
117
118
  */
118
119
  export function switchSeat(options) {
119
120
  const cwd = resolve(options.cwd ?? ".");
120
- const runsDir = join(cwd, ".runs");
121
+ const runsDir = runsRoot(cwd);
121
122
  let resolved;
122
123
  try {
123
124
  resolved = resolveCampaign(runsDir, options.campaign);
@@ -0,0 +1,170 @@
1
+ :root {
2
+ --fr-terra: #B5522A; --fr-terra-300: #E0875F;
3
+ --fr-argila: #D97B4F; --fr-argila-300: #ECAB86;
4
+ --fr-areia: #F4E9D8;
5
+ --fr-folha: #556B3F; --fr-folha-300: #93AA70;
6
+ --fr-carvao: #1F1F1F;
7
+
8
+ --bg: var(--fr-areia); --surface: #ffffff; --surface-2: #ece3d2; --border: #d8cbb0;
9
+ --text: var(--fr-carvao); --text-2: #4a4437; --text-3: #7a725e;
10
+ --ok: var(--fr-folha); --ok-soft: #e3ead9;
11
+ --warn: var(--fr-argila); --warn-soft: #f7e6da;
12
+ --fail: var(--fr-terra); --fail-soft: #f5ded6;
13
+ --muted: #8a8471; --muted-soft: #e9e4d5;
14
+ --edge: #b8ac8e;
15
+
16
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
17
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
18
+ }
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --bg: var(--fr-carvao); --surface: #262620; --surface-2: #2e2d26; --border: #45433a;
22
+ --text: var(--fr-areia); --text-2: #cfc7b3; --text-3: #a39c88;
23
+ --ok: var(--fr-folha-300); --ok-soft: #33402766;
24
+ --warn: var(--fr-argila-300); --warn-soft: #4a352566;
25
+ --fail: var(--fr-terra-300); --fail-soft: #4a2a1e66;
26
+ --muted: #96906f; --muted-soft: #3a382d;
27
+ --edge: #55523f;
28
+ }
29
+ }
30
+ * { box-sizing: border-box; }
31
+ [hidden] { display: none !important; }
32
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); }
33
+ a { color: var(--fr-terra); }
34
+ button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
35
+ :focus-visible { outline: 2px solid var(--fr-terra); outline-offset: 2px; }
36
+ .mono { font-family: var(--mono); font-size: 12.5px; }
37
+ .dim { color: var(--text-2); }
38
+ .empty { color: var(--text-3); padding: 10px 0; }
39
+
40
+ #top {
41
+ display: flex; align-items: center; gap: 16px; padding: 12px 20px;
42
+ background: var(--surface); border-bottom: 1px solid var(--border);
43
+ }
44
+ #top .brand { font-weight: 700; color: var(--fr-terra); }
45
+ .field { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
46
+ .field select { font: inherit; text-transform: none; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; }
47
+ #top .spacer { flex: 1; }
48
+ #conn { font-size: 12px; color: var(--text-2); display: inline-flex; align-items: center; gap: 6px; }
49
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; }
50
+ .dot.live { background: var(--ok); }
51
+ .dot.lost { background: var(--fail); }
52
+
53
+ #main { max-width: 1180px; margin: 0 auto; padding: 24px 20px 60px; }
54
+ .band { margin-top: 26px; }
55
+ .band > h2 { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-3); margin: 0 0 10px; }
56
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
57
+ .caveat { color: var(--text-2); font-size: 12.5px; margin: 0 0 12px; }
58
+
59
+ /* The opening band: the owner's four questions, answered the way the
60
+ notification answers them. It leads the page at every width. */
61
+ .summaryband { display: flex; flex-direction: column; gap: 10px; }
62
+ .sumhead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
63
+ .sumname { font-weight: 700; font-size: 15px; color: var(--fr-terra); }
64
+ @media (prefers-color-scheme: dark) { .sumname { color: var(--fr-terra-300); } }
65
+ .sumgoal { color: var(--text-2); font-size: 13px; }
66
+ .sumrow { font-size: 13px; }
67
+ .sumrow.progressline { display: flex; align-items: center; gap: 12px; }
68
+ .progresstrack { flex: 1; min-width: 120px; height: 8px; border-radius: 4px; background: var(--surface-2); border: 1px solid var(--border); overflow: hidden; }
69
+ .progressfill { height: 100%; background: var(--ok); }
70
+ .sumcounts { white-space: nowrap; font-variant-numeric: tabular-nums; }
71
+
72
+ /* The collapsed spec map: a reference table a reader opens, styled so the
73
+ closed summary reads as the band heading it replaced. */
74
+ .specdetails > summary { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-3); cursor: pointer; margin: 0 0 10px; display: inline-block; list-style: revert; }
75
+ .specdetails > summary:hover { color: var(--text-2); }
76
+
77
+ .specmap { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
78
+ .specfront h3 { font-size: 12.5px; margin: 0 0 8px; color: var(--fr-terra); }
79
+ .reqrow { border-top: 1px solid var(--border); padding: 8px 0; }
80
+ .reqrow:first-of-type { border-top: none; }
81
+ .reqid { color: var(--text-3); }
82
+ .reqstatement { font-size: 12.5px; margin: 2px 0; }
83
+ .reqphase { font-size: 12px; color: var(--text-2); }
84
+
85
+ /* `display: block` on the svg is load-bearing: a replaced inline element
86
+ (the svg default) inside an `overflow-x: auto` block can be sized as
87
+ though it never overflows, so the container never grows a scrollbar and
88
+ the trailing box (measured: the chain band's Release box) is cut off at
89
+ the viewport edge instead of reachable by scroll. */
90
+ .graphcard { overflow-x: auto; width: 100%; }
91
+ .graphcard svg { display: block; }
92
+ .phasehead { margin: 0 0 10px; font-size: 13px; }
93
+ .phasehead .phasename { font-weight: 650; }
94
+ .phasehead p { margin: 2px 0 0; }
95
+
96
+ svg .edge { stroke: var(--edge); stroke-width: 2; fill: none; }
97
+ svg .node rect, svg .stage rect { stroke: var(--border); stroke-width: 1; fill: var(--muted-soft); cursor: pointer; }
98
+ svg .node text, svg .stage text { fill: var(--text); font-family: var(--sans); font-size: 12px; }
99
+ svg .node text.mono, svg .stage text.mono { font-family: var(--mono); }
100
+ svg .node text.node-status, svg .stage text.stage-state { fill: var(--text-2); font-size: 11px; }
101
+ svg .node.ok rect, svg .stage.done rect { fill: var(--ok-soft); stroke: var(--ok); }
102
+ svg .node.progress rect, svg .stage.active rect { fill: var(--warn-soft); stroke: var(--warn); }
103
+ svg .node.fail rect { fill: var(--fail-soft); stroke: var(--fail); }
104
+ svg .node.muted rect, svg .stage.not_started rect { fill: var(--muted-soft); stroke: var(--muted); }
105
+ svg .stage.current rect { stroke-width: 2.5; }
106
+ svg .node:focus-visible rect, svg .stage:focus-visible rect { outline: 2px solid var(--fr-terra); outline-offset: 1px; }
107
+
108
+ .pill { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 4px; font-size: 12px; font-weight: 650; }
109
+ .pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
110
+ .pill.ok { color: var(--ok); background: var(--ok-soft); }
111
+ .pill.progress { color: var(--warn); background: var(--warn-soft); }
112
+ .pill.warn { color: var(--warn); background: var(--warn-soft); }
113
+ .pill.fail { color: var(--fail); background: var(--fail-soft); }
114
+ .pill.muted { color: var(--muted); background: var(--muted-soft); }
115
+
116
+ .drilldown-head { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; }
117
+ .drilldown-head h3 { margin: 0; font-size: 15px; }
118
+ .drows { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px 16px; margin-bottom: 14px; }
119
+ .drow { display: flex; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--border); padding: 4px 0; font-size: 13px; }
120
+ .dlabel { color: var(--text-3); text-transform: uppercase; font-size: 11px; letter-spacing: 0.03em; }
121
+ .dvalue { font-family: var(--mono); font-size: 12.5px; text-align: right; }
122
+
123
+ .detailblock { margin-top: 14px; }
124
+ .detailblock h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); margin: 0 0 6px; }
125
+ .link { padding: 2px 0; word-break: break-all; }
126
+ .finding { padding: 6px 0; border-bottom: 1px solid var(--border); }
127
+ .finding:last-child { border-bottom: none; }
128
+
129
+ /* The worker transcript, one step per line: what the worker did, not the
130
+ wire format it did it in. The raw file the On-disk block names stays the
131
+ place to read the JSONL itself. */
132
+ .tstep { padding: 5px 0; border-bottom: 1px solid var(--border); font-size: 12.5px; display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; }
133
+ .tstep:last-child { border-bottom: none; }
134
+ .tstep .mono { word-break: break-all; }
135
+ .tstep.error { color: var(--fail); }
136
+ .tstep.message { color: var(--text); }
137
+ .tstep.note { color: var(--text-2); }
138
+ pre { white-space: pre-wrap; word-break: break-word; background: var(--surface-2); border-radius: 8px; padding: 10px 12px; font-family: var(--mono); font-size: 12px; max-height: 360px; overflow: auto; }
139
+
140
+ .timelinelist { display: flex; flex-direction: column; gap: 6px; }
141
+ .trow { display: grid; grid-template-columns: 130px 140px 1fr; gap: 8px; font-size: 12.5px; border-bottom: 1px solid var(--border); padding: 4px 0; }
142
+ .trow .tat { color: var(--text-3); }
143
+ .trow .ttype { color: var(--fr-terra); font-weight: 600; }
144
+
145
+ /* Narrow viewport (phone widths, ~390px and up to a small tablet): the
146
+ header stacks instead of clipping, the spec map collapses to one column,
147
+ and the bands reorder so the summary — the four answers — leads and the
148
+ open phase follows, with the spec map (a static requirement reference)
149
+ last. The markup is the same document at every width; only this media
150
+ query moves things, via `order` on the `#main` flex container below,
151
+ never by emitting different HTML for the two cases. */
152
+ @media (max-width: 600px) {
153
+ #top { flex-wrap: wrap; row-gap: 8px; }
154
+ #top .field { flex: 1 1 100%; }
155
+ #top .field select { width: 100%; }
156
+ #top .spacer { flex: 0 0 0; display: none; }
157
+ #conn { flex: 1 1 100%; justify-content: flex-end; }
158
+
159
+ #main { display: flex; flex-direction: column; }
160
+ #summary { order: 1; }
161
+ #phaseGraph { order: 2; }
162
+ #chain { order: 3; }
163
+ #drilldown { order: 4; }
164
+ #specMap { order: 5; }
165
+
166
+ .specmap { grid-template-columns: 1fr; }
167
+
168
+ .goalclamp { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; cursor: pointer; }
169
+ .goalclamp:focus { -webkit-line-clamp: unset; line-clamp: unset; overflow: visible; }
170
+ }