faberun 0.17.1 → 0.18.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -111,7 +111,10 @@ the contract is frozen with a digest, and the phone's middle ground is a note.
111
111
 
112
112
  On `node.terminal`, `run.terminal` and `attention` the controller renders one
113
113
  message from persisted state, in the operator's own language (detected from
114
- the campaign goal, journal notes and node objectives; English otherwise):
114
+ the campaign goal, journal notes and node objectives, or `FABERUN_NOTIFY_LANG`;
115
+ English otherwise). Only `run.terminal`, `attention` and `advisory` leave by
116
+ default -- a phase settling and a person being needed -- while a node settling
117
+ gets a `filtered` receipt; `FABERUN_NOTIFY_EVENTS` widens or narrows the list:
115
118
  line one is the outcome (`✅ <node> · done in 8m · $0.09`, `🏁 run 15 · <name>
116
119
  · 2/2 done`, or `👀 <node> needs you · <error>`), then asked / done / proof
117
120
  for a node, what every node delivered for a run, or why / asked / do for
@@ -123,12 +126,16 @@ same text to every bound transport at once, appending one receipt
123
126
  `<run-dir>/notify.jsonl`. Delivery is lossy: **exactly one attempt**, no retry,
124
127
  no backoff. `FABERUN_NOTIFY_BIN` names an executable called with the event as
125
128
  JSON on stdin (`os-macos` selects the bundled `osascript` adapter); it pushes to
126
- a person, `canWake: false`. `FABERUN_NOTIFY_SESSION=auto` wakes the harness
127
- session the controller was launched from, `canWake: true`: the Claude Code
128
- inbox socket and the Codex thread the environment names. A seat window sets it;
129
- nothing else does, so a test suite never wakes a session. **On an inbound
130
- `🐦 faberun` message**: `✅` or `🏁` with nothing waiting, answer in one line
131
- and keep waiting; `👀`, act on the `do` command it names. A
129
+ a person, `canWake: false`. `FABERUN_NOTIFY_SESSION` wakes harness sessions,
130
+ `canWake: true`: `auto` is the Claude Code inbox and Codex thread the
131
+ environment names; `claude:<socket>` adds another session's inbox, so the
132
+ operator's own interactive session hears a run a background session launched
133
+ (`echo $CLAUDE_CODE_MESSAGING_SOCKET` in that session gives the path). A seat
134
+ window sets `auto`; nothing else does, so a test suite never wakes a session.
135
+ **On an inbound `🐦 faberun` message**: repeat it to the operator verbatim,
136
+ then one line on what you do next -- the message exists to inform the person,
137
+ not you, and it reaches them only through your reply; `👀`, act on the `do`
138
+ command it names. A
132
139
  resume never re-sends a notification already recorded for the same node, attempt
133
140
  and outcome. No transport is a default: `doctor`, `preflight` and the foreground
134
141
  launch warn when both variables are empty, and `--wake` names what will wake.
@@ -46,7 +46,9 @@ const DONE_STATUSES = new Set(["done", "no-op"]);
46
46
  /** Gate review that blocks the node on a failing verdict (TECH-SPEC lean, rule 2). */
47
47
  const BLOCKING_REVIEW = "blocking";
48
48
  /** A receipt this settled: delivered, no transport bound, or the retry budget spent. */
49
- const SETTLED_NOTIFY_STATUSES = new Set(["delivered", "no_transport"]);
49
+ // `filtered` is a decided outcome too: the event type was kept out of every
50
+ // transport on purpose (`FABERUN_NOTIFY_EVENTS`), not lost.
51
+ const SETTLED_NOTIFY_STATUSES = new Set(["delivered", "no_transport", "filtered"]);
50
52
  /** Target latency for a terminal/attention event to carry a settled receipt (TECH-SPEC section 6). */
51
53
  const NOTIFY_TARGET_SEC = 60;
52
54
  const SECONDS_PER_HOUR = 3600;
@@ -26,7 +26,7 @@ import { DISCOVERY_RUNTIME_DEFINITIONS, discoverRuntimes } from "../engine/runti
26
26
  import { errorMessage } from "../util.mjs";
27
27
  import { boundedGitSync } from "../repo/worktree.mjs";
28
28
  import { routeRuntime } from "../contract/runtime.mjs";
29
- import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
29
+ import { NOTIFY_BIN_ENV, deliverableEventTypes, noTransportWarning, notifySettingProblems } from "../notify/index.mjs";
30
30
  import { NOTIFY_SESSION_ENV, sessionWakeNotice } from "../notify/session.mjs";
31
31
  import { findExecutable } from "./platform.mjs";
32
32
  import { colorLevel, statusToken } from "../cli/brand.mjs";
@@ -209,10 +209,13 @@ export function environmentPreflight(options) {
209
209
  * @returns {EnvCheck}
210
210
  */
211
211
  export function notifyTransportCheck(env = process.env) {
212
+ const problems = notifySettingProblems(env);
213
+ if (problems.length) return fail("notify transport", problems.join("; "), true);
212
214
  const warning = noTransportWarning(env);
213
215
  if (warning) return fail("notify transport", warning, true);
214
216
  const external = env[NOTIFY_BIN_ENV] ? `${NOTIFY_BIN_ENV}=${env[NOTIFY_BIN_ENV]}` : `${NOTIFY_BIN_ENV} unset`;
215
- return pass("notify transport", `${external} · ${sessionWakeNotice(env)}`);
217
+ const events = [...deliverableEventTypes(env)].join(",");
218
+ return pass("notify transport", `${external} · ${sessionWakeNotice(env)} · events: ${events}`);
216
219
  }
217
220
 
218
221
  /** @param {EnvReport} report @returns {EnvCheck[]} the checks that block a dispatch */
@@ -86,7 +86,59 @@ const MACOS_TRANSPORT = "os-macos";
86
86
  * fixtures its worker ran. `withoutNotifyEnv` is the boundary every child
87
87
  * crosses; `test/setup.mjs` neutralises the same names inside the suite.
88
88
  */
89
- export const NOTIFY_ENV_NAMES = Object.freeze([NOTIFY_BIN_ENV, NOTIFY_SESSION_ENV]);
89
+ export const NOTIFY_EVENTS_ENV = "FABERUN_NOTIFY_EVENTS";
90
+ export const NOTIFY_LANG_ENV = "FABERUN_NOTIFY_LANG";
91
+ export const NOTIFY_ENV_NAMES = Object.freeze([NOTIFY_BIN_ENV, NOTIFY_SESSION_ENV, NOTIFY_EVENTS_ENV, NOTIFY_LANG_ENV]);
92
+
93
+ /** Every event type the dispatcher can be asked to deliver. */
94
+ export const NOTIFY_EVENT_TYPES = Object.freeze(["node.terminal", "run.terminal", "attention", "advisory"]);
95
+
96
+ /**
97
+ * What leaves the controller when `FABERUN_NOTIFY_EVENTS` is unset: a phase
98
+ * settling, a node that waits on a person, and an advisory threshold the
99
+ * operator declared. A node settling stays in `notify.jsonl` as a `filtered`
100
+ * receipt. The operator's own words, 2026-09-22, after a run of two nodes
101
+ * produced four wake-ups: "só fechamento de fase e atenção acordam, nó
102
+ * individual fica no log" -- and before that, after the phone flood, "só
103
+ * milestones e fechamentos de fase". Measured on the campaign that prompted
104
+ * it: five phases of two or three nodes would be about 20 messages with every
105
+ * event, about 7 with these.
106
+ */
107
+ export const DEFAULT_NOTIFY_EVENTS = Object.freeze(["run.terminal", "attention", "advisory"]);
108
+
109
+ /** The two languages the message renders its wording in; `FABERUN_NOTIFY_LANG` may name either. */
110
+ export const NOTIFY_LANGUAGES = Object.freeze(["en", "pt"]);
111
+
112
+ /**
113
+ * The event types the environment lets out, as a set. Unknown items are left
114
+ * out here and reported by `notifySettingProblems`; an empty value is the
115
+ * default, never "nothing".
116
+ *
117
+ * @param {NodeJS.ProcessEnv} [env]
118
+ * @returns {Set<string>}
119
+ */
120
+ export function deliverableEventTypes(env = process.env) {
121
+ const items = (env[NOTIFY_EVENTS_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
122
+ return new Set(items.length ? items.filter((item) => NOTIFY_EVENT_TYPES.includes(item)) : DEFAULT_NOTIFY_EVENTS);
123
+ }
124
+
125
+ /**
126
+ * Every notify setting the environment gets wrong, one sentence each, for
127
+ * `doctor` and `preflight`. Empty when everything parses.
128
+ *
129
+ * @param {NodeJS.ProcessEnv} [env]
130
+ * @returns {string[]}
131
+ */
132
+ export function notifySettingProblems(env = process.env) {
133
+ const problems = [];
134
+ const events = (env[NOTIFY_EVENTS_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
135
+ for (const item of events) {
136
+ if (!NOTIFY_EVENT_TYPES.includes(item)) problems.push(`${NOTIFY_EVENTS_ENV} item "${item}" is not one of ${NOTIFY_EVENT_TYPES.join(", ")}`);
137
+ }
138
+ const lang = (env[NOTIFY_LANG_ENV] ?? "").trim();
139
+ if (lang && !NOTIFY_LANGUAGES.includes(lang)) problems.push(`${NOTIFY_LANG_ENV}=${lang} is not one of ${NOTIFY_LANGUAGES.join(", ")}`);
140
+ return problems;
141
+ }
90
142
 
91
143
  /**
92
144
  * @param {NodeJS.ProcessEnv} env
@@ -156,7 +208,7 @@ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is c
156
208
  /** @typedef {Record<string, unknown>} JsonObject */
157
209
  /** @typedef {{schemaVersion: number, eventId: string, at: string, type: string, campaignId: string|null, runId: string|null, nodeId: string|null, status: string|null, errorCode: string|null, dedupeKey: string, summary: string}} InboxEntry */
158
210
  /** @typedef {{type: string, dedupeKey: string, summary: string, at?: string, campaignId?: string|null, runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null}} InboxEvent */
159
- /** @typedef {{type: "node.terminal"|"run.terminal"|"attention", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string}} NotifyEvent */
211
+ /** @typedef {{type: "node.terminal"|"run.terminal"|"attention"|"advisory", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string}} NotifyEvent */
160
212
  /** @typedef {{id: string, ok: boolean, error?: string}} TransportOutcome one transport's own outcome, named so the receipt says which took the message */
161
213
  /** @typedef {{ok: boolean, error?: string, noTransport?: boolean, transports?: TransportOutcome[]}} DeliveryResult */
162
214
 
@@ -503,14 +555,22 @@ export class NotifyQueue {
503
555
  // one), so every delivery carries a stable id derived from the dedupe key.
504
556
  const eventId = enriched.eventId
505
557
  ?? createHash("sha256").update(enriched.dedupeKey ?? JSON.stringify(enriched)).digest("hex");
558
+ // An event type the environment keeps out of every transport is still a
559
+ // receipt -- `filtered`, with the rendered summary -- so the log says what
560
+ // happened to it, and the resume's dedupe sees it as already handled.
561
+ const filtered = !deliverableEventTypes().has(enriched.type);
506
562
  /** @type {DeliveryResult} */
507
563
  let result;
508
- try {
509
- result = await this.deliver({ ...enriched, summary, eventId });
510
- } catch (error) {
511
- // A transport that rejects is a failed delivery, not a controller fault:
512
- // the receipt is still appended and the failure is dropped like any other.
513
- result = { ok: false, error: errorMessage(error) };
564
+ if (filtered) {
565
+ result = { ok: false, transports: [] };
566
+ } else {
567
+ try {
568
+ result = await this.deliver({ ...enriched, summary, eventId });
569
+ } catch (error) {
570
+ // A transport that rejects is a failed delivery, not a controller fault:
571
+ // the receipt is still appended and the failure is dropped like any other.
572
+ result = { ok: false, error: errorMessage(error) };
573
+ }
514
574
  }
515
575
  /** @type {JsonObject} */
516
576
  const receipt = {
@@ -525,13 +585,13 @@ export class NotifyQueue {
525
585
  dedupeKey: enriched.dedupeKey ?? null,
526
586
  summary,
527
587
  attempt: 1,
528
- status: result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
588
+ status: filtered ? "filtered" : result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
529
589
  // One entry per bound transport, so a receipt that says `delivered`
530
590
  // also says whether the phone, the session, or both took the message.
531
591
  transports: result.transports ?? [],
532
592
  at: new Date(this.now()).toISOString(),
533
593
  };
534
- if (!result.ok && !result.noTransport) receipt.error = result.error ?? null;
594
+ if (!filtered && !result.ok && !result.noTransport) receipt.error = result.error ?? null;
535
595
  appendFileSync(join(this.runDir, NOTIFY_LOG_FILE), `${JSON.stringify(receipt)}\n`);
536
596
  }
537
597
  }
@@ -44,7 +44,7 @@ export const CODEX_THREAD_ENV = "CODEX_THREAD_ID";
44
44
  */
45
45
  export const SESSION_DELIVERY_TIMEOUT_MS = 5_000;
46
46
 
47
- /** The values the variable accepts besides `codex:<thread>`. */
47
+ /** The bare words an item of the setting may be; `codex:<thread>` and `claude:<socket>` carry an address. */
48
48
  const SETTINGS = new Set(["off", "auto", "claude", "codex"]);
49
49
 
50
50
  /** @typedef {Record<string, unknown>} JsonObject */
@@ -59,32 +59,66 @@ const SETTINGS = new Set(["off", "auto", "claude", "codex"]);
59
59
  /** @typedef {(path: string) => SessionSocket} ConnectFunction */
60
60
 
61
61
  /**
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.
62
+ * The sessions the variable and the environment together name. The value is
63
+ * a comma-separated list; each item is one of:
64
+ *
65
+ * auto every session whose address this process inherited --
66
+ * a Codex thread opened from a Claude Code shell inherits
67
+ * both, and both are supervising
68
+ * claude the inherited Claude Code inbox alone
69
+ * codex the inherited Codex thread alone
70
+ * codex:<thread> a Codex thread by id or name
71
+ * claude:<socket> a Claude Code inbox by socket path -- the operator's
72
+ * own interactive session, which did not launch the run
73
+ * and would otherwise never hear of it (the session that
74
+ * launches a campaign is often a background one nobody
75
+ * reads); the token travels only to the inherited inbox,
76
+ * since it belongs to that session and no other
77
+ * off nothing, whatever else the list says
78
+ *
79
+ * An item whose address is absent resolves to nothing; the doctor reports
80
+ * why through `sessionSettingProblem`. This function never throws, because it
81
+ * backs a lossy dispatcher. Duplicates collapse: `auto,claude:<own socket>`
82
+ * is one target.
68
83
  *
69
84
  * @param {NodeJS.ProcessEnv} [env]
70
85
  * @returns {SessionTarget[]}
71
86
  */
72
87
  export function resolveSessionTargets(env = process.env) {
73
- const setting = (env[NOTIFY_SESSION_ENV] ?? "").trim();
74
- if (!setting || setting === "off") return [];
88
+ const items = settingItems(env);
89
+ if (!items.length || items.includes("off")) return [];
75
90
  /** @type {SessionTarget[]} */
76
91
  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 });
92
+ /** @param {SessionTarget} target */
93
+ const add = (target) => {
94
+ const address = target.kind === "claude" ? target.socketPath : target.thread;
95
+ if (!targets.some((known) => known.kind === target.kind && (known.kind === "claude" ? known.socketPath : known.thread) === address)) targets.push(target);
96
+ };
97
+ const inheritedSocket = env[CLAUDE_SOCKET_ENV];
98
+ const inheritedThread = env[CODEX_THREAD_ENV];
99
+ for (const item of items) {
100
+ if ((item === "auto" || item === "claude") && inheritedSocket) {
101
+ add({ kind: "claude", id: "claude-session", socketPath: inheritedSocket, token: env[CLAUDE_TOKEN_ENV] || null });
102
+ }
103
+ if ((item === "auto" || item === "codex") && inheritedThread) {
104
+ add({ kind: "codex", id: "codex-session", thread: inheritedThread });
105
+ }
106
+ if (item.startsWith("codex:") && item.slice("codex:".length).trim()) {
107
+ add({ kind: "codex", id: "codex-session", thread: item.slice("codex:".length).trim() });
108
+ }
109
+ if (item.startsWith("claude:") && item.slice("claude:".length).trim()) {
110
+ const socketPath = item.slice("claude:".length).trim();
111
+ add({ kind: "claude", id: "claude-session", socketPath, token: socketPath === inheritedSocket ? env[CLAUDE_TOKEN_ENV] || null : null });
112
+ }
84
113
  }
85
114
  return targets;
86
115
  }
87
116
 
117
+ /** @param {NodeJS.ProcessEnv} env @returns {string[]} the non-empty items of the setting */
118
+ function settingItems(env) {
119
+ return (env[NOTIFY_SESSION_ENV] ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
120
+ }
121
+
88
122
  /**
89
123
  * Why the setting names no session, in one sentence for `doctor` and
90
124
  * `--wake`; `null` when it is unset, `off`, or resolves to at least one.
@@ -93,15 +127,16 @@ export function resolveSessionTargets(env = process.env) {
93
127
  * @returns {string|null}
94
128
  */
95
129
  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>`;
130
+ const items = settingItems(env);
131
+ if (!items.length || items.includes("off")) return null;
132
+ const unknown = items.find((item) => !SETTINGS.has(item) && !(item.startsWith("codex:") && item.length > "codex:".length) && !(item.startsWith("claude:") && item.length > "claude:".length));
133
+ if (unknown !== undefined) {
134
+ return `${NOTIFY_SESSION_ENV} item "${unknown}" is not one of off, auto, claude, codex, codex:<thread>, claude:<socket>`;
100
135
  }
101
136
  if (resolveSessionTargets(env).length) return null;
137
+ const [setting] = items;
102
138
  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
139
  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
140
  return `${NOTIFY_SESSION_ENV}=auto found neither ${CLAUDE_SOCKET_ENV} nor ${CODEX_THREAD_ENV}: no harness session to wake`;
106
141
  }
107
142
 
@@ -228,13 +263,13 @@ export function createCodexSessionNotifier({ spawn = /** @type {SpawnFunction} *
228
263
  * @param {SessionEvent} event
229
264
  * @param {SessionTarget[]} targets
230
265
  * @param {{connect?: ConnectFunction, spawn?: SpawnFunction, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
231
- * @returns {Promise<{id: string, ok: boolean, error?: string}[]>}
266
+ * @returns {Promise<{id: string, address: string, ok: boolean, error?: string}[]>}
232
267
  */
233
268
  export function deliverToSessions(event, targets, options = {}) {
234
269
  return Promise.all(targets.map(async (target) => {
235
270
  const result = target.kind === "claude"
236
271
  ? await createClaudeSessionNotifier(options).deliver(event, target)
237
272
  : await createCodexSessionNotifier(options).deliver(event, target);
238
- return { id: target.id, ...result };
273
+ return { id: target.id, address: target.kind === "claude" ? target.socketPath : target.thread, ...result };
239
274
  }));
240
275
  }
@@ -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.
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { renderStatusJson } from "./render.mjs";
26
26
  import { buildCampaignProgress, remainingEstimateMs } from "./progress.mjs";
27
- import { detectLanguage, labelsFor } from "./locale.mjs";
27
+ import { chooseLanguage, labelsFor } from "./locale.mjs";
28
28
  import { readNodeSnapshot } from "../run/node-store.mjs";
29
29
  import { campaignDir } from "../campaign/layout.mjs";
30
30
  import { readJournal } from "../campaign/journal.mjs";
@@ -106,7 +106,7 @@ export function renderRunProgress(runDir, event) {
106
106
  const objectives = readObjectives(runDir);
107
107
  const snapshots = new Map(payload.nodes.map((node) => [node.id, readSnapshotSafe(runDir, node.id)]));
108
108
  const campaign = campaignSummary(runsDir, payload.campaignId);
109
- const label = labelsFor(detectLanguage([campaign?.goal], journalTexts(runsDir, payload.campaignId), [...objectives.values()]));
109
+ const label = labelsFor(chooseLanguage(process.env, [campaign?.goal], journalTexts(runsDir, payload.campaignId), [...objectives.values()]));
110
110
  const subject = subjectNode(payload.nodes, event.nodeId ?? null);
111
111
  /** @type {View} */
112
112
  const view = { runDir, runId: event.runId ?? basename(runDir), payload, objectives, snapshots, campaign, label, subject };