faberun 0.17.2 → 0.19.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 (44) hide show
  1. package/integrations/claude-code/statusline.sh +12 -2
  2. package/package.json +2 -2
  3. package/skills/faberun/references/operations.md +14 -7
  4. package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
  5. package/src/campaign/chain.mjs +5 -4
  6. package/src/campaign/metrics.mjs +3 -1
  7. package/src/cli/launch.mjs +10 -1
  8. package/src/cli.mjs +10 -4
  9. package/src/contract/index.mjs +16 -3
  10. package/src/contract/task-packet.mjs +13 -1
  11. package/src/engine/bulk-read.mjs +7 -1
  12. package/src/engine/gate.mjs +17 -3
  13. package/src/engine/judge-gate.mjs +2 -2
  14. package/src/engine/live-preflight.mjs +43 -10
  15. package/src/engine/live-silence.mjs +49 -0
  16. package/src/engine/process-identity.mjs +12 -1
  17. package/src/engine/process.mjs +2 -1
  18. package/src/engine/run-command.mjs +8 -5
  19. package/src/engine/run-identity.mjs +213 -14
  20. package/src/engine/runtime-discovery.mjs +26 -5
  21. package/src/engine/scheduler.mjs +1 -1
  22. package/src/engine/supervise.mjs +2 -1
  23. package/src/harnesses/catalogue.mjs +8 -1
  24. package/src/harnesses/dsh/runner.mjs +6 -1
  25. package/src/harnesses/index.mjs +35 -7
  26. package/src/host/platform.mjs +204 -1
  27. package/src/host/preflight.mjs +142 -7
  28. package/src/notify/index.mjs +77 -11
  29. package/src/notify/session.mjs +64 -24
  30. package/src/plan/pipeline.mjs +5 -1
  31. package/src/plan/preflight.mjs +77 -0
  32. package/src/repo/declared-paths.mjs +87 -6
  33. package/src/repo/signal.mjs +56 -21
  34. package/src/repo/workspace.mjs +3 -2
  35. package/src/repo/worktree.mjs +2 -1
  36. package/src/report/locale.mjs +20 -0
  37. package/src/report/message.mjs +2 -2
  38. package/src/report/next.mjs +15 -1
  39. package/src/run/availability.mjs +138 -0
  40. package/src/run/disk-gc.mjs +16 -2
  41. package/src/run/lock.mjs +8 -0
  42. package/src/run/paths.mjs +13 -0
  43. package/src/seat/allowance.mjs +4 -1
  44. package/src/seat/tmux.mjs +9 -1
@@ -31,6 +31,7 @@
31
31
  import { spawn as defaultSpawn } from "node:child_process";
32
32
  import { createConnection as defaultConnect } from "node:net";
33
33
  import { errorMessage } from "../util.mjs";
34
+ import { spawnInvocation } from "../host/platform.mjs";
34
35
 
35
36
  export const NOTIFY_SESSION_ENV = "FABERUN_NOTIFY_SESSION";
36
37
  export const CLAUDE_SOCKET_ENV = "CLAUDE_CODE_MESSAGING_SOCKET";
@@ -44,7 +45,7 @@ export const CODEX_THREAD_ENV = "CODEX_THREAD_ID";
44
45
  */
45
46
  export const SESSION_DELIVERY_TIMEOUT_MS = 5_000;
46
47
 
47
- /** The values the variable accepts besides `codex:<thread>`. */
48
+ /** The bare words an item of the setting may be; `codex:<thread>` and `claude:<socket>` carry an address. */
48
49
  const SETTINGS = new Set(["off", "auto", "claude", "codex"]);
49
50
 
50
51
  /** @typedef {Record<string, unknown>} JsonObject */
@@ -59,32 +60,66 @@ const SETTINGS = new Set(["off", "auto", "claude", "codex"]);
59
60
  /** @typedef {(path: string) => SessionSocket} ConnectFunction */
60
61
 
61
62
  /**
62
- * The sessions the variable and the environment together name. `auto` takes
63
- * every session whose address is present -- a Codex thread opened from a
64
- * Claude Code shell inherits both, and both are supervising. An explicit
65
- * `claude` or `codex` whose address is absent resolves to nothing; the
66
- * doctor reports why through `sessionSettingProblem`, this function never
67
- * throws, because it backs a lossy dispatcher.
63
+ * The sessions the variable and the environment together name. The value is
64
+ * a comma-separated list; each item is one of:
65
+ *
66
+ * auto every session whose address this process inherited --
67
+ * a Codex thread opened from a Claude Code shell inherits
68
+ * both, and both are supervising
69
+ * claude the inherited Claude Code inbox alone
70
+ * codex the inherited Codex thread alone
71
+ * codex:<thread> a Codex thread by id or name
72
+ * claude:<socket> a Claude Code inbox by socket path -- the operator's
73
+ * own interactive session, which did not launch the run
74
+ * and would otherwise never hear of it (the session that
75
+ * launches a campaign is often a background one nobody
76
+ * reads); the token travels only to the inherited inbox,
77
+ * since it belongs to that session and no other
78
+ * off nothing, whatever else the list says
79
+ *
80
+ * An item whose address is absent resolves to nothing; the doctor reports
81
+ * why through `sessionSettingProblem`. This function never throws, because it
82
+ * backs a lossy dispatcher. Duplicates collapse: `auto,claude:<own socket>`
83
+ * is one target.
68
84
  *
69
85
  * @param {NodeJS.ProcessEnv} [env]
70
86
  * @returns {SessionTarget[]}
71
87
  */
72
88
  export function resolveSessionTargets(env = process.env) {
73
- const setting = (env[NOTIFY_SESSION_ENV] ?? "").trim();
74
- if (!setting || setting === "off") return [];
89
+ const items = settingItems(env);
90
+ if (!items.length || items.includes("off")) return [];
75
91
  /** @type {SessionTarget[]} */
76
92
  const targets = [];
77
- const socketPath = env[CLAUDE_SOCKET_ENV];
78
- if ((setting === "auto" || setting === "claude") && socketPath) {
79
- targets.push({ kind: "claude", id: "claude-session", socketPath, token: env[CLAUDE_TOKEN_ENV] || null });
80
- }
81
- const thread = setting.startsWith("codex:") ? setting.slice("codex:".length).trim() : env[CODEX_THREAD_ENV];
82
- if ((setting === "auto" || setting === "codex" || setting.startsWith("codex:")) && thread) {
83
- targets.push({ kind: "codex", id: "codex-session", thread });
93
+ /** @param {SessionTarget} target */
94
+ const add = (target) => {
95
+ const address = target.kind === "claude" ? target.socketPath : target.thread;
96
+ if (!targets.some((known) => known.kind === target.kind && (known.kind === "claude" ? known.socketPath : known.thread) === address)) targets.push(target);
97
+ };
98
+ const inheritedSocket = env[CLAUDE_SOCKET_ENV];
99
+ const inheritedThread = env[CODEX_THREAD_ENV];
100
+ for (const item of items) {
101
+ if ((item === "auto" || item === "claude") && inheritedSocket) {
102
+ add({ kind: "claude", id: "claude-session", socketPath: inheritedSocket, token: env[CLAUDE_TOKEN_ENV] || null });
103
+ }
104
+ if ((item === "auto" || item === "codex") && inheritedThread) {
105
+ add({ kind: "codex", id: "codex-session", thread: inheritedThread });
106
+ }
107
+ if (item.startsWith("codex:") && item.slice("codex:".length).trim()) {
108
+ add({ kind: "codex", id: "codex-session", thread: item.slice("codex:".length).trim() });
109
+ }
110
+ if (item.startsWith("claude:") && item.slice("claude:".length).trim()) {
111
+ const socketPath = item.slice("claude:".length).trim();
112
+ add({ kind: "claude", id: "claude-session", socketPath, token: socketPath === inheritedSocket ? env[CLAUDE_TOKEN_ENV] || null : null });
113
+ }
84
114
  }
85
115
  return targets;
86
116
  }
87
117
 
118
+ /** @param {NodeJS.ProcessEnv} env @returns {string[]} the non-empty items of the setting */
119
+ function settingItems(env) {
120
+ return (env[NOTIFY_SESSION_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
121
+ }
122
+
88
123
  /**
89
124
  * Why the setting names no session, in one sentence for `doctor` and
90
125
  * `--wake`; `null` when it is unset, `off`, or resolves to at least one.
@@ -93,15 +128,16 @@ export function resolveSessionTargets(env = process.env) {
93
128
  * @returns {string|null}
94
129
  */
95
130
  export function sessionSettingProblem(env = process.env) {
96
- const setting = (env[NOTIFY_SESSION_ENV] ?? "").trim();
97
- if (!setting || setting === "off") return null;
98
- if (!SETTINGS.has(setting) && !setting.startsWith("codex:")) {
99
- return `${NOTIFY_SESSION_ENV}=${setting} is not one of off, auto, claude, codex, codex:<thread>`;
131
+ const items = settingItems(env);
132
+ if (!items.length || items.includes("off")) return null;
133
+ const unknown = items.find((item) => !SETTINGS.has(item) && !(item.startsWith("codex:") && item.length > "codex:".length) && !(item.startsWith("claude:") && item.length > "claude:".length));
134
+ if (unknown !== undefined) {
135
+ return `${NOTIFY_SESSION_ENV} item "${unknown}" is not one of off, auto, claude, codex, codex:<thread>, claude:<socket>`;
100
136
  }
101
137
  if (resolveSessionTargets(env).length) return null;
138
+ const [setting] = items;
102
139
  if (setting === "claude") return `${NOTIFY_SESSION_ENV}=claude but ${CLAUDE_SOCKET_ENV} is not set: this process was not started from inside a Claude Code session`;
103
140
  if (setting === "codex") return `${NOTIFY_SESSION_ENV}=codex but ${CODEX_THREAD_ENV} is not set: this process was not started from inside a Codex session`;
104
- if (setting.startsWith("codex:")) return `${NOTIFY_SESSION_ENV}=codex: names an empty thread id`;
105
141
  return `${NOTIFY_SESSION_ENV}=auto found neither ${CLAUDE_SOCKET_ENV} nor ${CODEX_THREAD_ENV}: no harness session to wake`;
106
142
  }
107
143
 
@@ -189,7 +225,11 @@ export function createCodexSessionNotifier({ spawn = /** @type {SpawnFunction} *
189
225
  const executable = env.FABERUN_CODEX_BIN ?? "codex";
190
226
  let child;
191
227
  try {
192
- child = spawn(executable, ["queue", "--thread", target.thread, "--message", messageText(event)], { stdio: ["ignore", "ignore", "pipe"], env });
228
+ // The harness CLI, reached the way this platform reaches one: the
229
+ // `codex` a Windows machine has is `codex.cmd`, and a raw spawn of
230
+ // the bare name is ENOENT — a wake that silently never arrives.
231
+ const invocation = spawnInvocation(executable, ["queue", "--thread", target.thread, "--message", messageText(event)]);
232
+ child = spawn(invocation.command, invocation.args, { stdio: ["ignore", "ignore", "pipe"], env, ...invocation.options });
193
233
  } catch (error) {
194
234
  resolve({ ok: false, error: errorMessage(error) });
195
235
  return;
@@ -228,13 +268,13 @@ export function createCodexSessionNotifier({ spawn = /** @type {SpawnFunction} *
228
268
  * @param {SessionEvent} event
229
269
  * @param {SessionTarget[]} targets
230
270
  * @param {{connect?: ConnectFunction, spawn?: SpawnFunction, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
231
- * @returns {Promise<{id: string, ok: boolean, error?: string}[]>}
271
+ * @returns {Promise<{id: string, address: string, ok: boolean, error?: string}[]>}
232
272
  */
233
273
  export function deliverToSessions(event, targets, options = {}) {
234
274
  return Promise.all(targets.map(async (target) => {
235
275
  const result = target.kind === "claude"
236
276
  ? await createClaudeSessionNotifier(options).deliver(event, target)
237
277
  : await createCodexSessionNotifier(options).deliver(event, target);
238
- return { id: target.id, ...result };
278
+ return { id: target.id, address: target.kind === "claude" ? target.socketPath : target.thread, ...result };
239
279
  }));
240
280
  }
@@ -24,6 +24,7 @@ import { campaignCli } from "../cli/campaign.mjs";
24
24
  import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
25
25
  import { stableJson } from "../util.mjs";
26
26
  import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
27
+ import { askPlanningRuntimes, refusePlanningSilence } from "./preflight.mjs";
27
28
  import { parseSpec, validateSpec } from "./spec.mjs";
28
29
  import { collectRepoFacts } from "./repo-facts.mjs";
29
30
  import { RISK_TIERS, buildPlanningContract, validateFindings, validatePlanOutput } from "./template.mjs";
@@ -42,6 +43,7 @@ import { campaignTree, runDirectory } from "../run/paths.mjs";
42
43
  /** @typedef {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[]}} AssembledPlan */
43
44
  /** @typedef {"standard"|"high"|"none"} ApproveBelow */
44
45
  /** @typedef {(contractPath: string, contract: ValidatedContract) => Promise<void>|void} LaunchFn */
46
+ /** @typedef {(runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, cwd: string) => Promise<import("../harnesses/index.mjs").ProbeResult[]>} AskFn */
45
47
  /** @typedef {(runDir: string) => Promise<import("../engine/supervise.mjs").RunProgress>|import("../engine/supervise.mjs").RunProgress} WaitFn */
46
48
  /** @typedef {{status: "frozen", plansDir: string, planPath: string, contractPath: string, approved: boolean, findings: PlanFindingOutput[], warnings: string[]}} FrozenPipelineResult */
47
49
  /** @typedef {{status: "contested", plansDir: string, planPath: string, findings: PlanFindingOutput[], round: number}} ContestedPipelineResult */
@@ -80,7 +82,7 @@ export const DEFAULT_NODE_BUDGET_MS = 600_000;
80
82
  const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
81
83
 
82
84
  /**
83
- * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, runtimes: Record<string, JsonObject>, verification?: VerificationSuites, launch: LaunchFn, wait: WaitFn}} options
85
+ * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, runtimes: Record<string, JsonObject>, verification?: VerificationSuites, launch: LaunchFn, wait: WaitFn, ask?: AskFn}} options
84
86
  * @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
85
87
  */
86
88
  export async function runPlanningPipeline(options) {
@@ -88,6 +90,7 @@ export async function runPlanningPipeline(options) {
88
90
  specPath, campaignId, phase, runtimes, launch, wait,
89
91
  reviewRounds = 2, runtimeDefaults = {}, verification = {},
90
92
  } = options;
93
+ const ask = options.ask ?? askPlanningRuntimes;
91
94
  const approveBelow = /** @type {ApproveBelow} */ (options.approveBelow ?? "standard");
92
95
  if (!APPROVE_BELOW_VALUES.has(approveBelow)) throw new TypeError(`approveBelow must be one of ${[...APPROVE_BELOW_VALUES].join(", ")}`);
93
96
  if (typeof launch !== "function") throw new TypeError("runPlanningPipeline requires a launch seam");
@@ -97,6 +100,7 @@ export async function runPlanningPipeline(options) {
97
100
  const campaignPath = campaignTree(cwd, campaignId);
98
101
  const campaign = readCampaign(campaignPath);
99
102
  if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
103
+ refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
100
104
 
101
105
  const relativeSpecPath = repoRelativePath(cwd, specPath, "specPath");
102
106
  const specText = readFileSync(resolve(cwd, relativeSpecPath), "utf8");
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The ask `faberun plan` makes before its first stage.
3
+ *
4
+ * It lives beside the pipeline rather than inside it because asking is its own
5
+ * concern -- which runtimes a planning run will spend, and what counts as one
6
+ * of them not answering -- and because `pipeline.mjs` sits 45 lines from this
7
+ * tree's 800-line ceiling.
8
+ */
9
+ import { preflightRuntimes } from "../engine/live-preflight.mjs";
10
+ import { liveSilenceCause } from "../engine/live-silence.mjs";
11
+ import { harnessCapabilities } from "../harnesses/index.mjs";
12
+ import { validateRuntime } from "../contract/runtime.mjs";
13
+
14
+ /**
15
+ * The runtimes a planning run will spend, asked once before its first stage.
16
+ *
17
+ * Planning routes two roles across nine stages and does not reach them at the
18
+ * same time: the planner is spent at `draft`, the reviewer not until `review`.
19
+ * Every stage does launch through `runContract`, so the dispatch gate asks --
20
+ * but it asks that stage's own contract, and a planning contract carries no
21
+ * gate (`plan/template.mjs` builds them with `gate: false`), so
22
+ * `reachableRuntimes` never counts the judge role: it only adds one when a
23
+ * node's gate is enabled. The reviewer is therefore reached only as the
24
+ * *worker* of a later stage's contract, which is why a reviewer that never
25
+ * answers used to surface after the draft had already been bought. Naming both
26
+ * runtimes directly is the only ask that reaches them before anything is
27
+ * spent.
28
+ *
29
+ * Phase 2's verdict store makes this free at the stage boundary: what is
30
+ * recorded here is what each stage's own gate reuses instead of asking again.
31
+ *
32
+ * @param {Record<string, Record<string, unknown>>} runtimes the catalogue as the pipeline carries it, validated here per entry
33
+ * @param {{worker?: string, judge?: string}} runtimeDefaults
34
+ * @param {string} cwd
35
+ * @returns {Promise<import("../harnesses/index.mjs").ProbeResult[]>}
36
+ */
37
+ export async function askPlanningRuntimes(runtimes, runtimeDefaults, cwd) {
38
+ /** @type {string[]} */
39
+ const ids = [];
40
+ for (const id of [runtimeDefaults.worker, runtimeDefaults.judge]) {
41
+ if (typeof id === "string" && id.length > 0 && !ids.includes(id)) ids.push(id);
42
+ }
43
+ const entries = ids.flatMap((id) => {
44
+ const raw = runtimes[id];
45
+ // A default naming a runtime the catalogue does not carry is the
46
+ // catalogue loader's refusal to make, not this one's.
47
+ if (raw === undefined) return [];
48
+ const runtime = validateRuntime(id, raw);
49
+ return [{ runtime: { ...runtime, id, capabilities: harnessCapabilities(runtime) } }];
50
+ });
51
+ return entries.length === 0 ? [] : preflightRuntimes(entries, { cwd });
52
+ }
53
+
54
+ /**
55
+ * Refuse before the first stage when a runtime said nothing at all.
56
+ *
57
+ * The rule is the dispatch gate's own, imported rather than restated:
58
+ * `liveSilenceCause` decides what silence is, so planning and dispatch cannot
59
+ * drift into disagreeing about whether an answer was an answer. Silence
60
+ * blocks; any verdict a provider returned -- a quota refusal included -- is an
61
+ * answer, and the run proceeds onto whatever the contract declares.
62
+ *
63
+ * @param {import("../harnesses/index.mjs").ProbeResult[]} checks
64
+ * @param {string} cwd
65
+ * @returns {void}
66
+ */
67
+ export function refusePlanningSilence(checks, cwd) {
68
+ const silent = checks.flatMap((check) => {
69
+ const cause = liveSilenceCause(check);
70
+ return cause === null ? [] : [`runtime ${check.id ?? check.harness} did not answer: ${cause}`];
71
+ });
72
+ if (silent.length === 0) return;
73
+ throw Object.assign(
74
+ new Error(`env_preflight_failed: ${silent.join(" · ")} · planning stays resumable: fix the environment and plan again in ${cwd}`),
75
+ { code: "env_preflight_failed" },
76
+ );
77
+ }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { errorCode, exitStatus } from "../util.mjs";
14
14
  import { execFileSync } from "node:child_process";
15
+ import { gitArguments } from "../host/platform.mjs";
15
16
  import { join, resolve } from "node:path";
16
17
  import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
18
  import { tmpdir } from "node:os";
@@ -70,7 +71,7 @@ function isUnobservableDeclaredPath(cwd, declaredPath, kind) {
70
71
  function gitDeclaredPathState(cwd, path, kind) {
71
72
  const literals = kind === "writeRoots" ? [path, `${path}/`] : [path];
72
73
  try {
73
- execFileSync("git", ["-C", cwd, "ls-files", "--cached", "--error-unmatch", "--", path], {
74
+ execFileSync("git", gitArguments(["-C", cwd, "ls-files", "--cached", "--error-unmatch", "--", path]), {
74
75
  stdio: ["ignore", "ignore", "ignore"],
75
76
  });
76
77
  return "tracked";
@@ -118,7 +119,7 @@ function checkCombinedGitIgnore(cwd, path, extraExclude) {
118
119
  const args = ["-C", cwd, "ls-files", "--others", "--exclude-standard"];
119
120
  if (extraExclude) args.push(`--exclude-from=${extraExclude}`);
120
121
  args.push("-z", "--", path);
121
- const output = execFileSync("git", args, { encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
122
+ const output = execFileSync("git", gitArguments(args), { encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
122
123
  return output.length === 0;
123
124
  } catch {
124
125
  return "unknown";
@@ -140,13 +141,13 @@ function checkMissingCombinedGitIgnore(cwd, path, extraExclude) {
140
141
  if (!extraExclude || standard === "unknown") return standard;
141
142
  const temporaryWorktree = mkdtempSync(join(tmpdir(), "faberun-ignore-check-"));
142
143
  try {
143
- execFileSync("git", ["init", "-q", temporaryWorktree], { stdio: ["ignore", "ignore", "ignore"] });
144
+ execFileSync("git", gitArguments(["init", "-q", temporaryWorktree]), { stdio: ["ignore", "ignore", "ignore"] });
144
145
  const temporaryGit = resolve(temporaryWorktree, ".git");
145
146
  writeFileSync(resolve(temporaryGit, "info", "exclude"), readFileSync(extraExclude), { mode: 0o600 });
146
147
  const args = ["--git-dir", temporaryGit, "--work-tree", temporaryWorktree, "-c", `core.excludesFile=${process.platform === "win32" ? "NUL" : "/dev/null"}`, "check-ignore", "--no-index", "--verbose", "--", path];
147
148
  let customMatched;
148
149
  try {
149
- execFileSync("git", args, { stdio: ["ignore", "ignore", "ignore"] });
150
+ execFileSync("git", gitArguments(args), { stdio: ["ignore", "ignore", "ignore"] });
150
151
  customMatched = true;
151
152
  } catch (error) {
152
153
  if (exitStatus(error) !== 1) return "unknown";
@@ -155,7 +156,7 @@ function checkMissingCombinedGitIgnore(cwd, path, extraExclude) {
155
156
 
156
157
  if (!customMatched) return standard;
157
158
  try {
158
- execFileSync("git", args.toSpliced(-3, 1, "--quiet"), { stdio: ["ignore", "ignore", "ignore"] });
159
+ execFileSync("git", gitArguments(args.toSpliced(-3, 1, "--quiet")), { stdio: ["ignore", "ignore", "ignore"] });
159
160
  return true;
160
161
  } catch (error) {
161
162
  return exitStatus(error) === 1 ? false : "unknown";
@@ -181,7 +182,7 @@ function checkGitIgnore(cwd, path, extraExclude) {
181
182
  if (extraExclude) args.push("-c", `core.excludesFile=${extraExclude}`);
182
183
  args.push("check-ignore", "--no-index", "--quiet", "--", path);
183
184
  try {
184
- execFileSync("git", args, { stdio: ["ignore", "ignore", "ignore"] });
185
+ execFileSync("git", gitArguments(args), { stdio: ["ignore", "ignore", "ignore"] });
185
186
  return true;
186
187
  } catch (error) {
187
188
  return exitStatus(error) === 1 ? false : "unknown";
@@ -224,3 +225,83 @@ function extractCommandTarget(line) {
224
225
  }
225
226
  return null;
226
227
  }
228
+
229
+ /**
230
+ * The `src/` layers a node writes whose mirrored `test/` directory no
231
+ * verification command touches.
232
+ *
233
+ * `test/` mirrors `src/` by directory, and that is an enforced rule of this
234
+ * tree rather than a habit, so "this node writes the engine and nothing runs
235
+ * the engine's tests" is a mechanical question with a mechanical answer.
236
+ *
237
+ * Measured 2026-09-22, and this is why it exists: a node rewrote the dispatch
238
+ * gate in `src/engine/run-identity.mjs`, verified `test/engine/live-gate`,
239
+ * `test/host/preflight` and `test/harnesses/replay-run`, passed every one of
240
+ * them, passed its judge, and broke 56 of the 385 tests in `test/engine/` --
241
+ * the directory its own module lives in. No command it ran opened that
242
+ * directory. The whole contract is searched, not only the node's own
243
+ * commands, because a shared or final command covering the layer is coverage
244
+ * just the same.
245
+ *
246
+ * A test any node of the contract writes does not count, and that distinction
247
+ * is the whole detector. The node above *did* name
248
+ * `test/engine/live-gate.test.mjs` on a command line -- the file it had just
249
+ * created -- and the run's final verification added
250
+ * `test/engine/live-verdict.test.mjs`, which its sibling had just created.
251
+ * Running the tests the campaign is adding proves those tests run, never that
252
+ * the layer still works. Coverage means naming the directory, or a file in it
253
+ * that no node in this contract writes.
254
+ *
255
+ * A warning, never a refusal: a layer can be honestly verified from another
256
+ * directory, and only the author knows. But an author who meant it reads one
257
+ * line, and an author who forgot is handed back a day.
258
+ *
259
+ * @param {ValidatedNode} node
260
+ * @param {number} index
261
+ * @param {string} cwd
262
+ * @param {string[]} contractCommands shared and final verification, already joined
263
+ * @param {Set<string>} contractWrites every path any node of the contract writes
264
+ * @returns {string[]}
265
+ */
266
+ export function mirrorCoverageWarnings(node, index, cwd, contractCommands = [], contractWrites = new Set()) {
267
+ const written = new Set(node.taskPacket.writeFiles ?? []);
268
+ const authored = new Set([...written, ...contractWrites]);
269
+ const tokens = [
270
+ ...node.taskPacket.verification.flatMap((command) => command.argv),
271
+ ...contractCommands.flatMap((line) => line.split(/\s+/u)),
272
+ ].filter((token) => !authored.has(token));
273
+ /** @type {Set<string>} */
274
+ const layers = new Set();
275
+ for (const path of written) {
276
+ const layer = mirroredLayer(path, cwd);
277
+ if (layer) layers.add(layer);
278
+ }
279
+ return [...layers].sort().flatMap((layer) => (
280
+ tokens.some((token) => token.includes(`test/${layer}/`))
281
+ ? []
282
+ : [`nodes[${index}] (${node.id}): writes src/${layer}/ but no verification command runs a test under test/${layer}/ that this contract does not itself write`]
283
+ ));
284
+ }
285
+
286
+ /**
287
+ * The `test/` directory mirroring a written `src/` path, or null when the path
288
+ * is not source or has no mirror. `src/cli.mjs` mirrors to `test/cli/` because
289
+ * the entry point is the layer; `src/util.mjs` has no mirror directory and by
290
+ * the tree's own rule owns no domain, so it names none.
291
+ *
292
+ * @param {string} path
293
+ * @param {string} cwd
294
+ * @returns {string|null}
295
+ */
296
+ function mirroredLayer(path, cwd) {
297
+ const match = /^src\/(?:([^/]+)\/|cli\.mjs$)/u.exec(path);
298
+ if (!match) return null;
299
+ const layer = match[1] ?? "cli";
300
+ try {
301
+ return lstatSync(join(cwd, "test", layer)).isDirectory() ? layer : null;
302
+ } catch {
303
+ // ENOENT: a layer with no mirrored test directory cannot be uncovered by
304
+ // one, so there is nothing to warn about.
305
+ return null;
306
+ }
307
+ }
@@ -17,9 +17,21 @@
17
17
  * `attention` entry from `.runs/inbox.jsonl`. An attention belongs to one
18
18
  * campaign or none: an explicit `campaignId` decides, a null one is resolved
19
19
  * from the entry's `runId` (a run belongs to at most one campaign), and an
20
- * entry whose run no active campaign owns is shown once at run level instead
20
+ * entry whose run no campaign at all owns is shown once at run level instead
21
21
  * of under every campaign at once. It is bounded, because every session pays
22
22
  * for it in its first tokens.
23
+ *
24
+ * `parked` is decided by `classifyRunProgress`, never by reading `runOutcome`
25
+ * here. `reduceRunOutcome` is a reduction over snapshots with no notion of
26
+ * in-flight: a node whose status is `running` is not a success, so it reduces
27
+ * to `parked` while the controller is still working on it. Measured
28
+ * 2026-09-22 on a live run: `runOutcome "parked"`, one node `running`,
29
+ * `controllerAlive true`. The linked-run renderer read that field raw and told
30
+ * a takeover session to `resume` a run that was 23 minutes into its second
31
+ * node. The standalone renderer had the conjunct (`&& state === "done"`) and
32
+ * was right; two copies of one rule, one of them incomplete, so now there is
33
+ * one copy and it lives in `chain.mjs` with the campaign decision it also
34
+ * governs.
23
35
  */
24
36
 
25
37
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -30,6 +42,7 @@ import { readInbox } from "../notify/index.mjs";
30
42
  import { repositoryForRunsDir } from "../run/paths.mjs";
31
43
  import { SIGNAL_END, SIGNAL_START } from "./signal-block.mjs";
32
44
  import { HANDOFF_FILE } from "../campaign/layout.mjs";
45
+ import { classifyRunProgress } from "../campaign/chain.mjs";
33
46
 
34
47
  export { SIGNAL_END, SIGNAL_START } from "./signal-block.mjs";
35
48
 
@@ -51,12 +64,15 @@ export function renderAgentSignalBlock(runsDir) {
51
64
  const lines = [];
52
65
  /** @type {Set<string>} */
53
66
  const linked = new Set();
54
- const active = discoverCampaigns(runsDir).campaigns.filter(({ campaign }) => campaign.status !== "closed");
55
- const ownerByRun = runOwnerIndex(active);
67
+ const discovered = discoverCampaigns(runsDir).campaigns;
68
+ // A run any campaign linked is that campaign's business, open or settled.
69
+ // Only a run no campaign ever named reaches the standalone list.
70
+ for (const { campaign } of discovered) for (const runId of campaign.linkedRunIds) linked.add(runId);
71
+ const active = discovered.filter(({ campaign }) => campaign.status !== "closed");
72
+ const ownerByRun = runOwnerIndex(discovered);
56
73
  for (const { campaign } of active) {
57
74
  lines.push(`- faberun campaign \`${campaign.id}\`: active — read \`.runs/campaigns/${campaign.id}/${HANDOFF_FILE}\``);
58
75
  for (const runId of campaign.linkedRunIds) {
59
- linked.add(runId);
60
76
  lines.push(...runSignalLines(runsDir, runId));
61
77
  }
62
78
  const attention = campaignAttentionLine(runsDir, campaign, ownerByRun);
@@ -70,19 +86,26 @@ export function renderAgentSignalBlock(runsDir) {
70
86
  }
71
87
 
72
88
  /**
73
- * Which active campaign owns each run, read from the campaigns' own
74
- * `linkedRunIds`. A run belongs to at most one campaign, so the first
75
- * campaign naming a run wins; a run no active campaign names is absent from
76
- * the map, and that absence — never a guess — is what makes an inbox entry
77
- * unattributable.
89
+ * Which campaign owns each run, read from the campaigns' own `linkedRunIds`.
90
+ * A run belongs to at most one campaign, so the first campaign naming a run
91
+ * wins; a run no campaign names is absent from the map, and that absence —
92
+ * never a guess — is what makes an inbox entry unattributable.
93
+ *
94
+ * Closed campaigns are indexed too, and that is the point: an attention from
95
+ * a closed campaign's run is owned, not orphaned. Indexing only the active
96
+ * ones made every such attention unattributable, so it was surfaced at run
97
+ * level and stayed there for good. Measured 2026-09-22: the live block
98
+ * carried one from `harden-chain-and-verification`, a campaign closed on
99
+ * 2026-09-16. An owned attention under no active campaign is simply not
100
+ * shown, which is the correct answer for work its campaign already settled.
78
101
  *
79
- * @param {{campaign: import("../campaign/index.mjs").Campaign}[]} active
102
+ * @param {{campaign: import("../campaign/index.mjs").Campaign}[]} campaigns
80
103
  * @returns {Map<string, string>}
81
104
  */
82
- function runOwnerIndex(active) {
105
+ function runOwnerIndex(campaigns) {
83
106
  /** @type {Map<string, string>} */
84
107
  const ownerByRun = new Map();
85
- for (const { campaign } of active) {
108
+ for (const { campaign } of campaigns) {
86
109
  for (const runId of campaign.linkedRunIds) {
87
110
  if (!ownerByRun.has(runId)) ownerByRun.set(runId, campaign.id);
88
111
  }
@@ -91,9 +114,9 @@ function runOwnerIndex(active) {
91
114
  }
92
115
 
93
116
  /**
94
- * One linked run's outcome as block lines. `runProgress` folds the phase-2
95
- * `runOutcome`, so a parked run is rendered by its own declared nodes rather
96
- * than being dropped for having no non-terminal one.
117
+ * One linked run's outcome as block lines. A parked run is rendered by its own
118
+ * declared nodes rather than being dropped for having no non-terminal one --
119
+ * but only once `classifyRunProgress` agrees it settled.
97
120
  *
98
121
  * @param {string} runsDir
99
122
  * @param {string} runId
@@ -106,7 +129,7 @@ function runSignalLines(runsDir, runId) {
106
129
  return [` - run \`${runId}\`: missing — resume \`${resume}\``];
107
130
  }
108
131
  const progress = runProgress(runDir);
109
- switch (progress.runOutcome) {
132
+ switch (classifyRunProgress(progress)) {
110
133
  case "succeeded":
111
134
  return [` - run \`${runId}\`: succeeded (${progress.terminal}/${progress.total} nodes)`];
112
135
  case "canceled":
@@ -116,8 +139,8 @@ function runSignalLines(runsDir, runId) {
116
139
  case "parked":
117
140
  break;
118
141
  default:
119
- // No snapshot yet: the run exists but has not proved an outcome. It is
120
- // active work, not parked work, so it must not claim parked nodes.
142
+ // `unfinished`: either no snapshot yet, or a node still running. Both are
143
+ // active work, not parked work, so neither may claim parked nodes.
121
144
  return [` - run \`${runId}\`: active — read \`.runs/${runId}/STATUS.md\`; \`resume\` or \`supervise\` it`];
122
145
  }
123
146
  const nodes = progress.outcomeNodes ?? [];
@@ -127,9 +150,20 @@ function runSignalLines(runsDir, runId) {
127
150
  }
128
151
 
129
152
  /**
130
- * Standalone runs that no active campaign links. A parked run is included, so
153
+ * Standalone runs that no campaign links at all. A parked run is included, so
131
154
  * a run with no campaign at all still blocks a naive "start fresh".
132
155
  *
156
+ * `linked` spans closed campaigns too. Closing a campaign is the operator
157
+ * saying its work is settled, but it used to *promote* that campaign's parked
158
+ * runs: they stopped being indented children of a campaign and became
159
+ * top-level standalone entries, then stayed there forever. Measured
160
+ * 2026-09-22: 26 closed campaigns in this project, and the block carried
161
+ * seven parked runs from three of them -- adversarial-planner,
162
+ * become-faberun, chain-ergonomics-and-fairness -- against one live run,
163
+ * each with a `resume` command for work a closed campaign had already
164
+ * settled. Every session in the repository paid for those lines in its first
165
+ * tokens and had to decide, one by one, not to act on them.
166
+ *
133
167
  * @param {string} runsDir
134
168
  * @param {Set<string>} linked
135
169
  * @returns {string[]}
@@ -147,9 +181,10 @@ function activeRunLines(runsDir, linked) {
147
181
  // A nodes directory with no committed snapshot is a creation in progress,
148
182
  // not evidence of work; the old renderer left it off and so does this one.
149
183
  if (progress.runOutcome === undefined && progress.total === 0) continue;
150
- if (progress.runOutcome === "succeeded" || progress.runOutcome === "canceled") continue;
184
+ const classified = classifyRunProgress(progress);
185
+ if (classified === "succeeded" || classified === "canceled") continue;
151
186
  const resume = `node src/cli.mjs resume ${runDir}`;
152
- if (progress.runOutcome === "parked" && progress.state === "done") {
187
+ if (classified === "parked") {
153
188
  const nodes = (progress.outcomeNodes ?? []).slice(0, MAX_PARKED_NODES).map(nodeText).join(", ");
154
189
  lines.push(`- faberun run \`${name}\`: parked — ${nodes || "no nodes named"} — resume \`${resume}\``);
155
190
  } else {
@@ -14,6 +14,7 @@
14
14
  * because the verification schema happened to be in the same file.
15
15
  */
16
16
  import { Buffer } from "node:buffer";
17
+ import { gitArguments } from "../host/platform.mjs";
17
18
  import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
18
19
  import { basename, isAbsolute, relative, resolve } from "node:path";
19
20
  import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, statSync } from "node:fs";
@@ -278,7 +279,7 @@ function captureIgnoreSources(root) {
278
279
  /** @param {string} name @param {string} logical @returns {string|null} */
279
280
  const resolveGitPath = (name, logical) => {
280
281
  try {
281
- const value = execFileSync("git", ["-C", root, "rev-parse", "--git-path", name], {
282
+ const value = execFileSync("git", gitArguments(["-C", root, "rev-parse", "--git-path", name]), {
282
283
  encoding: "utf8",
283
284
  stdio: ["ignore", "pipe", "ignore"],
284
285
  }).trim();
@@ -460,7 +461,7 @@ function relevantWorkspacePaths(cwd) {
460
461
  args.push("-z");
461
462
  let output;
462
463
  try {
463
- output = execFileSync("git", args, {
464
+ output = execFileSync("git", gitArguments(args), {
464
465
  cwd,
465
466
  encoding: "buffer",
466
467
  maxBuffer: VERIFICATION_LIMITS.snapshotEntries * (VERIFICATION_LIMITS.snapshotPathBytes + 1) + 1,
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { gitArguments } from "../host/platform.mjs";
2
3
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { RUNS_DIR_NAME, attemptWorktreePath, candidateWorktreePath } from "../run/paths.mjs";
@@ -46,7 +47,7 @@ function gitSyncTimeoutMs(optionMs) {
46
47
  */
47
48
  export function boundedGitSync(args, options = {}) {
48
49
  const timeoutMs = gitSyncTimeoutMs(options.timeoutMs);
49
- const result = spawnSync("git", args, {
50
+ const result = spawnSync("git", gitArguments(args), {
50
51
  encoding: options.encoding ?? "utf8",
51
52
  stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
52
53
  timeout: timeoutMs,
@@ -16,6 +16,8 @@
16
16
  * function there.
17
17
  */
18
18
 
19
+ import { NOTIFY_LANG_ENV } from "../notify/index.mjs";
20
+
19
21
  /** @typedef {"en"|"pt"} Language */
20
22
 
21
23
  // `a`, `do` and `no` are left out on purpose: each is also an English word,
@@ -51,6 +53,24 @@ export function detectLanguage(...groups) {
51
53
  return "en";
52
54
  }
53
55
 
56
+ /**
57
+ * The language the message is written in: `FABERUN_NOTIFY_LANG` when the
58
+ * operator set it to a language this module has (a campaign whose goal an
59
+ * orchestrator wrote in English still belongs to a person who reads
60
+ * Portuguese), else detection over the groups. The override moves the
61
+ * wording only; quoted text keeps the language it was written in, so a
62
+ * Portuguese label may sit beside an English objective -- honest, if uneven.
63
+ *
64
+ * @param {NodeJS.ProcessEnv} env
65
+ * @param {...readonly (string|null|undefined)[]} groups
66
+ * @returns {Language}
67
+ */
68
+ export function chooseLanguage(env, ...groups) {
69
+ const forced = (env[NOTIFY_LANG_ENV] ?? "").trim();
70
+ if (forced === "pt" || forced === "en") return forced;
71
+ return detectLanguage(...groups);
72
+ }
73
+
54
74
  /**
55
75
  * Every phrase the message renders, per language. Node ids, run ids, model
56
76
  * names and quoted text are never here: they are data, not wording.