faberun 0.15.0 → 0.17.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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The session transports: the channel that wakes the harness session an
3
+ * operator is sitting in, rather than a phone.
4
+ *
5
+ * A controller launched from inside a Claude Code or Codex session inherits
6
+ * that session's own address. Claude Code exports `CLAUDE_CODE_MESSAGING_SOCKET`
7
+ * (a Unix socket, the session's inbox) and `CLAUDE_CODE_MESSAGING_TOKEN` to
8
+ * every Bash command and hook it runs; Codex exports `CODEX_THREAD_ID` and
9
+ * accepts `codex queue --thread <id> --message <text>` for a running thread.
10
+ * Posting the rendered message there is what `canWake: true` means: the
11
+ * session gets a turn and reads the same text the phone gets, so the two
12
+ * never differ. It is a module apart from `index.mjs` because that module is
13
+ * the dispatcher and the receipt log; this one knows two wire protocols and
14
+ * nothing about receipts.
15
+ *
16
+ * Measured 2026-09-21 against a live Claude Code 2.1.269 session: the inbox
17
+ * accepts `{"type":"auth","token"}` and then
18
+ * `{"type":"user","message":{"role":"user","content"}}`, one JSON object per
19
+ * line, answers nothing, and delivered the line to the session mid-turn; the
20
+ * session closes a connection that has not sent a complete line within 30 s.
21
+ * The token is what lets the session verify a poster whose process has
22
+ * already exited (macOS cannot check process ancestry after exit), so it is
23
+ * always sent when present and never logged.
24
+ *
25
+ * Opt-in by `FABERUN_NOTIFY_SESSION`, never by detection alone: a test suite
26
+ * running inside a session would otherwise wake it on every fixture's
27
+ * terminal node, the hazard `FABERUN_NOTIFY_BIN` already has and guards
28
+ * against. The seat sets the variable to `auto` on the window it launches, so
29
+ * everything under a seat inherits the opt-in and nothing else does.
30
+ */
31
+ import { spawn as defaultSpawn } from "node:child_process";
32
+ import { createConnection as defaultConnect } from "node:net";
33
+ import { errorMessage } from "../util.mjs";
34
+
35
+ export const NOTIFY_SESSION_ENV = "FABERUN_NOTIFY_SESSION";
36
+ export const CLAUDE_SOCKET_ENV = "CLAUDE_CODE_MESSAGING_SOCKET";
37
+ export const CLAUDE_TOKEN_ENV = "CLAUDE_CODE_MESSAGING_TOKEN";
38
+ export const CODEX_THREAD_ENV = "CODEX_THREAD_ID";
39
+
40
+ /**
41
+ * Measured 2026-09-21: one post to a live inbox completed well under a
42
+ * second, and Claude Code's own peer sender gives up at 5 s; the same budget
43
+ * `index.mjs` gives an external transport.
44
+ */
45
+ export const SESSION_DELIVERY_TIMEOUT_MS = 5_000;
46
+
47
+ /** The values the variable accepts besides `codex:<thread>`. */
48
+ const SETTINGS = new Set(["off", "auto", "claude", "codex"]);
49
+
50
+ /** @typedef {Record<string, unknown>} JsonObject */
51
+ /** @typedef {{kind: "claude", id: "claude-session", socketPath: string, token: string|null}} ClaudeTarget */
52
+ /** @typedef {{kind: "codex", id: "codex-session", thread: string}} CodexTarget */
53
+ /** @typedef {ClaudeTarget|CodexTarget} SessionTarget */
54
+ /** @typedef {{type: string, summary?: unknown, campaignId?: unknown, runId?: unknown, nodeId?: unknown}} SessionEvent */
55
+ /** @typedef {{ok: boolean, error?: string}} SessionDelivery */
56
+ /** @typedef {{once(event: string, listener: (...args: any[]) => void): unknown, kill(signal?: any): unknown, stderr?: {on(event: string, listener: (chunk: string|Buffer) => void): unknown}|null}} SpawnedChild */
57
+ /** @typedef {(command: string, args?: any, options?: any) => SpawnedChild} SpawnFunction */
58
+ /** @typedef {{once(event: string, listener: (...args: any[]) => void): unknown, end(data: string, callback?: () => void): unknown, destroy(): unknown}} SessionSocket */
59
+ /** @typedef {(path: string) => SessionSocket} ConnectFunction */
60
+
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.
68
+ *
69
+ * @param {NodeJS.ProcessEnv} [env]
70
+ * @returns {SessionTarget[]}
71
+ */
72
+ export function resolveSessionTargets(env = process.env) {
73
+ const setting = (env[NOTIFY_SESSION_ENV] ?? "").trim();
74
+ if (!setting || setting === "off") return [];
75
+ /** @type {SessionTarget[]} */
76
+ 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 });
84
+ }
85
+ return targets;
86
+ }
87
+
88
+ /**
89
+ * Why the setting names no session, in one sentence for `doctor` and
90
+ * `--wake`; `null` when it is unset, `off`, or resolves to at least one.
91
+ *
92
+ * @param {NodeJS.ProcessEnv} [env]
93
+ * @returns {string|null}
94
+ */
95
+ 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>`;
100
+ }
101
+ if (resolveSessionTargets(env).length) return null;
102
+ 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
+ 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
+ return `${NOTIFY_SESSION_ENV}=auto found neither ${CLAUDE_SOCKET_ENV} nor ${CODEX_THREAD_ENV}: no harness session to wake`;
106
+ }
107
+
108
+ /**
109
+ * What `campaign watch --wake` and `doctor` say about waking a session.
110
+ *
111
+ * @param {NodeJS.ProcessEnv} [env]
112
+ * @returns {string}
113
+ */
114
+ export function sessionWakeNotice(env = process.env) {
115
+ const targets = resolveSessionTargets(env);
116
+ if (targets.length) {
117
+ return `session transport ${targets.map((target) => target.id).join(" + ")} wakes the launching harness session (canWake: true)`;
118
+ }
119
+ const problem = sessionSettingProblem(env);
120
+ if (problem) return `${problem}; no harness session is woken`;
121
+ return `${NOTIFY_SESSION_ENV} unset: no harness session is woken (a seat window sets it to auto)`;
122
+ }
123
+
124
+ /**
125
+ * The text a session reads: the rendered summary, whose first line already
126
+ * names the sender; a counters-only fallback for an event that reaches a
127
+ * transport unrendered.
128
+ *
129
+ * @param {SessionEvent} event
130
+ * @returns {string}
131
+ */
132
+ function messageText(event) {
133
+ if (typeof event.summary === "string" && event.summary.trim()) return event.summary;
134
+ return ["🐦 faberun", event.type, event.campaignId, event.runId, event.nodeId].filter((part) => typeof part === "string" && part).join(" · ");
135
+ }
136
+
137
+ /**
138
+ * @param {{connect?: ConnectFunction, timeoutMs?: number}} [options]
139
+ * @returns {{id: "claude-session", capabilities: {canPush: boolean, canWake: boolean, canRenderAmbient: boolean}, deliver(event: SessionEvent, target: ClaudeTarget): Promise<SessionDelivery>}}
140
+ */
141
+ export function createClaudeSessionNotifier({ connect = /** @type {ConnectFunction} */ (defaultConnect), timeoutMs = SESSION_DELIVERY_TIMEOUT_MS } = {}) {
142
+ return {
143
+ id: "claude-session",
144
+ capabilities: { canPush: false, canWake: true, canRenderAmbient: false },
145
+ deliver(event, target) {
146
+ return new Promise((resolve) => {
147
+ /** @type {string[]} */
148
+ const lines = [];
149
+ if (target.token) lines.push(JSON.stringify({ type: "auth", token: target.token }));
150
+ lines.push(JSON.stringify({ type: "user", message: { role: "user", content: messageText(event) } }));
151
+ let socket;
152
+ try {
153
+ socket = connect(target.socketPath);
154
+ } catch (error) {
155
+ resolve({ ok: false, error: errorMessage(error) });
156
+ return;
157
+ }
158
+ let settled = false;
159
+ /** @param {SessionDelivery} result */
160
+ const finish = (result) => {
161
+ if (settled) return;
162
+ settled = true;
163
+ clearTimeout(timer);
164
+ resolve(result);
165
+ };
166
+ const timer = setTimeout(() => {
167
+ socket.destroy();
168
+ finish({ ok: false, error: `session inbox ${target.socketPath} did not take the message within ${timeoutMs}ms` });
169
+ }, timeoutMs);
170
+ socket.once("error", (error) => finish({ ok: false, error: errorMessage(error) }));
171
+ // The inbox answers nothing, so a flushed write and a clean close is
172
+ // the whole evidence of delivery there is.
173
+ socket.once("connect", () => socket.end(`${lines.join("\n")}\n`, () => finish({ ok: true })));
174
+ });
175
+ },
176
+ };
177
+ }
178
+
179
+ /**
180
+ * @param {{spawn?: SpawnFunction, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
181
+ * @returns {{id: "codex-session", capabilities: {canPush: boolean, canWake: boolean, canRenderAmbient: boolean}, deliver(event: SessionEvent, target: CodexTarget): Promise<SessionDelivery>}}
182
+ */
183
+ export function createCodexSessionNotifier({ spawn = /** @type {SpawnFunction} */ (defaultSpawn), timeoutMs = SESSION_DELIVERY_TIMEOUT_MS, env = process.env } = {}) {
184
+ return {
185
+ id: "codex-session",
186
+ capabilities: { canPush: false, canWake: true, canRenderAmbient: false },
187
+ deliver(event, target) {
188
+ return new Promise((resolve) => {
189
+ const executable = env.FABERUN_CODEX_BIN ?? "codex";
190
+ let child;
191
+ try {
192
+ child = spawn(executable, ["queue", "--thread", target.thread, "--message", messageText(event)], { stdio: ["ignore", "ignore", "pipe"], env });
193
+ } catch (error) {
194
+ resolve({ ok: false, error: errorMessage(error) });
195
+ return;
196
+ }
197
+ let settled = false;
198
+ let stderr = "";
199
+ /** @param {SessionDelivery} result */
200
+ const finish = (result) => {
201
+ if (settled) return;
202
+ settled = true;
203
+ clearTimeout(timer);
204
+ resolve(result);
205
+ };
206
+ const timer = setTimeout(() => {
207
+ try {
208
+ child.kill("SIGTERM");
209
+ } catch {
210
+ // ESRCH: the child already exited before the timeout kill; finish still resolves.
211
+ }
212
+ finish({ ok: false, error: `codex queue did not return within ${timeoutMs}ms` });
213
+ }, timeoutMs);
214
+ child.stderr?.on("data", (chunk) => {
215
+ stderr = `${stderr}${chunk}`.slice(-1024);
216
+ });
217
+ child.once("error", (error) => finish({ ok: false, error: errorMessage(error) }));
218
+ child.once("exit", (code) => finish(code === 0 ? { ok: true } : { ok: false, error: stderr.trim() || `codex queue exited ${code}` }));
219
+ });
220
+ },
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Deliver one event to every resolved session, each through its own adapter,
226
+ * and report per target: the receipt names which session took the message.
227
+ *
228
+ * @param {SessionEvent} event
229
+ * @param {SessionTarget[]} targets
230
+ * @param {{connect?: ConnectFunction, spawn?: SpawnFunction, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
231
+ * @returns {Promise<{id: string, ok: boolean, error?: string}[]>}
232
+ */
233
+ export function deliverToSessions(event, targets, options = {}) {
234
+ return Promise.all(targets.map(async (target) => {
235
+ const result = target.kind === "claude"
236
+ ? await createClaudeSessionNotifier(options).deliver(event, target)
237
+ : await createCodexSessionNotifier(options).deliver(event, target);
238
+ return { id: target.id, ...result };
239
+ }));
240
+ }
@@ -607,6 +607,17 @@ export function unresolvedFindings(findings, previousPlan, revisedPlan) {
607
607
  * not make — a removed node's writes were reviewed as a removal, not as a
608
608
  * silent shrink.
609
609
  *
610
+ * Membership is judged against the whole revised plan, not against the node
611
+ * that used to hold the path. A revise that splits one node in two and hands
612
+ * a file to the new sibling has not dropped that file: it is still declared,
613
+ * still reviewable, and the graph change is visible in the plan. Measured
614
+ * 2026-09-21 on durable-state-integrity phase 1, where a per-node test made
615
+ * exactly that move a critical and contested a sound plan — the draft's only
616
+ * node wrote src/repo/worktree.mjs and src/engine/cancel.mjs, and the revise
617
+ * layered them into worktree-preserve-ref-verb and
618
+ * cancel-preserves-integrated-heads, which is the decomposition this
619
+ * repository's own layering asks for.
620
+ *
610
621
  * @param {PlanOutput|null} previousPlan the plan the revise revised, null when the draft never validated
611
622
  * @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
612
623
  * @returns {PlanFindingOutput[]}
@@ -614,6 +625,7 @@ export function unresolvedFindings(findings, previousPlan, revisedPlan) {
614
625
  export function droppedWriteFindings(previousPlan, revisedPlan) {
615
626
  if (!previousPlan || !revisedPlan) return [];
616
627
  const before = new Map(previousPlan.nodes.map((node) => [node.id, new Set(node.writeFiles)]));
628
+ const stillDeclared = new Set(revisedPlan.nodes.flatMap((node) => node.writeFiles));
617
629
  /** @type {PlanFindingOutput[]} */
618
630
  const findings = [];
619
631
  for (const node of revisedPlan.nodes) {
@@ -621,13 +633,13 @@ export function droppedWriteFindings(previousPlan, revisedPlan) {
621
633
  if (!previousWrites) continue;
622
634
  let dropped = 0;
623
635
  for (const path of previousWrites) {
624
- if (node.writeFiles.includes(path)) continue;
636
+ if (stillDeclared.has(path)) continue;
625
637
  dropped += 1;
626
638
  findings.push({
627
639
  id: `dropped-write-${node.id}-${dropped}`,
628
640
  severity: "critical",
629
641
  nodeId: node.id,
630
- text: `Node ${node.id} no longer declares ${path} in writeFiles, which the plan this revise revised did declare. Declare it again: the resolution to a scope-closure finding is to declare or acknowledge the dragged-along file, never to drop a write the node needs — a smaller write set clears the same finding while leaving the worker unable to do the work.`,
642
+ text: `Node ${node.id} no longer declares ${path} in writeFiles, which the plan this revise revised did declare, and no other node in the revised plan declares it either. Declare it again on whichever node owns the work: the resolution to a scope-closure finding is to declare or acknowledge the dragged-along file, never to drop a write the node needs — a smaller write set clears the same finding while leaving the worker unable to do the work. Moving the file to another node is a resolution; removing it from the plan is not.`,
631
643
  });
632
644
  }
633
645
  }
@@ -19,6 +19,7 @@ import { existsSync, readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { timeVerificationCommands } from "../host/preflight.mjs";
21
21
  import { NOTIFY_BIN_ENV } from "../notify/index.mjs";
22
+ import { NOTIFY_SESSION_ENV } from "../notify/session.mjs";
22
23
  import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
23
24
 
24
25
  /** @typedef {import("./spec.mjs").SpecRequirement} SpecRequirement */
@@ -45,6 +46,7 @@ const MEASURE_OUTPUT_CAP_BYTES = 4096;
45
46
  */
46
47
  const MEASURE_SIDE_EFFECT_ENV_KEYS = [
47
48
  NOTIFY_BIN_ENV,
49
+ NOTIFY_SESSION_ENV,
48
50
  "FABERUN_CODEX_BIN",
49
51
  "FABERUN_CLAUDE_BIN",
50
52
  "FABERUN_AGY_BIN",
@@ -14,8 +14,12 @@
14
14
  * about the work that most needs it. The block now renders the phase-2
15
15
  * `runOutcome` per linked run: `parked` with its nodes, their error codes and
16
16
  * the exact `resume` command; `succeeded` as one line; plus the most recent
17
- * campaign-level `attention` entry from `.runs/inbox.jsonl`. It is bounded,
18
- * because every session pays for it in its first tokens.
17
+ * `attention` entry from `.runs/inbox.jsonl`. An attention belongs to one
18
+ * campaign or none: an explicit `campaignId` decides, a null one is resolved
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
21
+ * of under every campaign at once. It is bounded, because every session pays
22
+ * for it in its first tokens.
19
23
  */
20
24
 
21
25
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -47,21 +51,45 @@ export function renderAgentSignalBlock(runsDir) {
47
51
  const lines = [];
48
52
  /** @type {Set<string>} */
49
53
  const linked = new Set();
50
- const { campaigns } = discoverCampaigns(runsDir);
51
- for (const { campaign } of campaigns.filter(({ campaign }) => campaign.status !== "closed")) {
54
+ const active = discoverCampaigns(runsDir).campaigns.filter(({ campaign }) => campaign.status !== "closed");
55
+ const ownerByRun = runOwnerIndex(active);
56
+ for (const { campaign } of active) {
52
57
  lines.push(`- faberun campaign \`${campaign.id}\`: active — read \`.runs/campaigns/${campaign.id}/${HANDOFF_FILE}\``);
53
58
  for (const runId of campaign.linkedRunIds) {
54
59
  linked.add(runId);
55
60
  lines.push(...runSignalLines(runsDir, runId));
56
61
  }
57
- const attention = campaignAttentionLine(runsDir, campaign);
62
+ const attention = campaignAttentionLine(runsDir, campaign, ownerByRun);
58
63
  if (attention) lines.push(attention);
59
64
  }
60
65
  for (const line of activeRunLines(runsDir, linked)) lines.push(line);
66
+ const orphan = orphanAttentionLine(runsDir, ownerByRun);
67
+ if (orphan) lines.push(orphan);
61
68
  if (!lines.length) return "";
62
69
  return `${SIGNAL_START}\n${HEADER}\n\n${boundLines(lines).join("\n")}\n${SIGNAL_END}`;
63
70
  }
64
71
 
72
+ /**
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.
78
+ *
79
+ * @param {{campaign: import("../campaign/index.mjs").Campaign}[]} active
80
+ * @returns {Map<string, string>}
81
+ */
82
+ function runOwnerIndex(active) {
83
+ /** @type {Map<string, string>} */
84
+ const ownerByRun = new Map();
85
+ for (const { campaign } of active) {
86
+ for (const runId of campaign.linkedRunIds) {
87
+ if (!ownerByRun.has(runId)) ownerByRun.set(runId, campaign.id);
88
+ }
89
+ }
90
+ return ownerByRun;
91
+ }
92
+
65
93
  /**
66
94
  * One linked run's outcome as block lines. `runProgress` folds the phase-2
67
95
  * `runOutcome`, so a parked run is rendered by its own declared nodes rather
@@ -132,16 +160,17 @@ function activeRunLines(runsDir, linked) {
132
160
  }
133
161
 
134
162
  /**
135
- * The most recent campaign-level attention: an inbox entry when one exists,
136
- * otherwise the durable record on the campaign itself.
163
+ * The most recent attention this campaign owns: an inbox entry when one
164
+ * exists, otherwise the durable record on the campaign itself.
137
165
  *
138
166
  * @param {string} runsDir
139
167
  * @param {import("../campaign/index.mjs").Campaign} campaign
168
+ * @param {Map<string, string>} ownerByRun
140
169
  * @returns {string|null}
141
170
  */
142
- function campaignAttentionLine(runsDir, campaign) {
171
+ function campaignAttentionLine(runsDir, campaign, ownerByRun) {
143
172
  const latest = readInbox(runsDir)
144
- .filter((entry) => entry.type === "attention" && (entry.campaignId === campaign.id || entry.campaignId === null))
173
+ .filter((entry) => entry.type === "attention" && attentionBelongsTo(entry, campaign.id, ownerByRun))
145
174
  .at(-1);
146
175
  if (latest) return ` - attention: ${boundedAttention(latest.summary)}`;
147
176
  if (campaign.attention && typeof campaign.attention.message === "string") {
@@ -151,6 +180,44 @@ function campaignAttentionLine(runsDir, campaign) {
151
180
  return null;
152
181
  }
153
182
 
183
+ /**
184
+ * An attention belongs to one campaign or none. An explicit `campaignId` is
185
+ * authoritative; a null one is resolved from the entry's `runId` through the
186
+ * run-owner index. Measured 2026-09-21: all 12 attention entries in the live
187
+ * inbox carry null, so the old `campaignId === null` fallback attributed an
188
+ * orphan to every campaign at once, permanently. An entry whose run resolves
189
+ * to no active campaign belongs to none and is surfaced once at run level by
190
+ * `orphanAttentionLine`, not dropped.
191
+ *
192
+ * @param {import("../notify/index.mjs").InboxEntry} entry
193
+ * @param {string} campaignId
194
+ * @param {Map<string, string>} ownerByRun
195
+ * @returns {boolean}
196
+ */
197
+ function attentionBelongsTo(entry, campaignId, ownerByRun) {
198
+ if (entry.campaignId !== null) return entry.campaignId === campaignId;
199
+ return entry.runId !== null && ownerByRun.get(entry.runId) === campaignId;
200
+ }
201
+
202
+ /**
203
+ * The most recent attention no campaign owns, as one run-level line. Dropping
204
+ * it would trade the old wrong report (every campaign) for a missing one, and
205
+ * the run-level section of the block is where campaign-less work already
206
+ * lives.
207
+ *
208
+ * @param {string} runsDir
209
+ * @param {Map<string, string>} ownerByRun
210
+ * @returns {string|null}
211
+ */
212
+ function orphanAttentionLine(runsDir, ownerByRun) {
213
+ const latest = readInbox(runsDir)
214
+ .filter((entry) => entry.type === "attention" && entry.campaignId === null && (entry.runId === null || !ownerByRun.has(entry.runId)))
215
+ .at(-1);
216
+ if (!latest) return null;
217
+ const subject = latest.runId ? `run \`${latest.runId}\`` : "an entry with no run";
218
+ return `- attention: ${subject} resolves to no campaign — ${boundedAttention(latest.summary)}`;
219
+ }
220
+
154
221
  /**
155
222
  * @param {import("../engine/supervise.mjs").OutcomeNode} node
156
223
  * @returns {string}
@@ -104,6 +104,11 @@ export function candidateRefName(runId) {
104
104
  return `refs/faberun/${runId}/candidate`;
105
105
  }
106
106
 
107
+ /** @param {string} runId @param {string} nodeId @returns {string} */
108
+ export function preservedRefName(runId, nodeId) {
109
+ return `refs/faberun/${runId}/preserved/${nodeId}`;
110
+ }
111
+
107
112
  /** @param {string} runId @param {string} nodeId @param {number} attempt @returns {string} */
108
113
  function attemptBranchName(runId, nodeId, attempt) {
109
114
  return `faberun/${runId}/${nodeId}/${attempt}`;
@@ -168,6 +173,29 @@ export function createRunRef(repo, runId, head) {
168
173
  return ref;
169
174
  }
170
175
 
176
+ /**
177
+ * Keep one node's integrated commit reachable after cancel releases the run
178
+ * ref and the attempt branches: without a ref of its own the commit is
179
+ * garbage the next `git gc` collects, and the persisted snapshot alone cannot
180
+ * bring a pruned object back.
181
+ *
182
+ * Idempotent by the same contract `deleteRef` and `removeWorktree` honour:
183
+ * cancel can legitimately run twice (a retry after a partial first pass, or
184
+ * the operator repeating it), so a preserved ref that already names this
185
+ * exact commit is done, not a conflict — the second creation neither fails
186
+ * nor moves the target. A ref left at a different sha moves to the commit
187
+ * handed in now.
188
+ *
189
+ * @param {string} repo @param {string} runId @param {string} nodeId @param {string|null|undefined} sha @returns {string}
190
+ */
191
+ export function createPreservedRef(repo, runId, nodeId, sha) {
192
+ if (!sha) throw Object.assign(new Error("a preserved ref needs an integrated commit"), { code: "git_head_required" });
193
+ const ref = preservedRefName(runId, nodeId);
194
+ if (gitHead(repo, ref) === sha) return ref;
195
+ runGit(["-C", repo, "update-ref", ref, sha]);
196
+ return ref;
197
+ }
198
+
171
199
  /**
172
200
  * @param {{repo: string, runDir: string, runId: string, nodeId: string, attempt: number, base?: string, declaredReads?: string[]}} args
173
201
  * `base` cuts the new branch from a sealed sha instead of the run ref tip —
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The language of a notification, and the labels it is written in.
3
+ *
4
+ * The controller never sees the operator's conversation with a harness; it
5
+ * sees what the operator wrote for faberun: the campaign goal, the journal
6
+ * notes and the objectives the plan gave each node. Those are the operator's
7
+ * prompts to this product, and a message that answers them in the same
8
+ * language reads as a reply rather than a log line. Detection is a count of
9
+ * function words in the two languages this product's operators write in;
10
+ * anything else, or nothing to read, is English. Quoted content -- the
11
+ * worker's own words, a judge finding -- keeps the language it was written
12
+ * in, because translating a quotation would make it stop being one.
13
+ *
14
+ * It is a module apart from `message.mjs` so the layout and the wording can
15
+ * change independently: a new label is one line here, a new line is one
16
+ * function there.
17
+ */
18
+
19
+ /** @typedef {"en"|"pt"} Language */
20
+
21
+ // `a`, `do` and `no` are left out on purpose: each is also an English word,
22
+ // and one article in an English sentence must not read as Portuguese.
23
+ const PORTUGUESE = new Set(["o", "e", "de", "da", "na", "que", "não", "nao", "para", "com", "uma", "um", "em", "os", "as", "dos", "das", "é", "se", "por", "mais", "como", "mas", "ao", "ou", "quando", "já", "também", "ser", "sem", "até", "cada", "nunca", "está", "são", "foi", "pelo", "pela", "nos", "nas", "seu", "sua", "isso", "esse", "essa", "ele", "ela"]);
24
+ const ENGLISH = new Set(["the", "and", "of", "to", "in", "is", "for", "with", "that", "on", "as", "are", "this", "it", "be", "by", "from", "or", "an", "not", "at", "when", "every", "never", "before", "after", "its", "into", "than", "which", "while", "each", "was", "has", "have"]);
25
+
26
+ /**
27
+ * The language of the first group of samples that carries any function word
28
+ * at all, English when none does. Groups are passed in order of authorship:
29
+ * the campaign goal is the operator's own sentence, the journal notes are the
30
+ * orchestrating session's, the node objectives are the planner's -- and a
31
+ * plan written by a model in English must not outvote the person who typed
32
+ * the goal in Portuguese (measured 2026-09-21: two node objectives held 40
33
+ * English function words against a Portuguese goal's 19).
34
+ *
35
+ * @param {...readonly (string|null|undefined)[]} groups
36
+ * @returns {Language}
37
+ */
38
+ export function detectLanguage(...groups) {
39
+ for (const samples of groups) {
40
+ let portuguese = 0;
41
+ let english = 0;
42
+ for (const sample of samples) {
43
+ if (typeof sample !== "string") continue;
44
+ for (const word of sample.toLowerCase().split(/[^\p{L}]+/u)) {
45
+ if (PORTUGUESE.has(word)) portuguese += 1;
46
+ else if (ENGLISH.has(word)) english += 1;
47
+ }
48
+ }
49
+ if (portuguese + english > 0) return portuguese > english ? "pt" : "en";
50
+ }
51
+ return "en";
52
+ }
53
+
54
+ /**
55
+ * Every phrase the message renders, per language. Node ids, run ids, model
56
+ * names and quoted text are never here: they are data, not wording.
57
+ *
58
+ * @type {Record<Language, Record<string, string>>}
59
+ */
60
+ const LABELS = {
61
+ en: {
62
+ doneIn: "done in",
63
+ running: "running",
64
+ pending: "pending",
65
+ state: "state",
66
+ nothingDelivered: "nothing delivered",
67
+ "no-op": "no-op after",
68
+ failedAfter: "failed after",
69
+ blockedAfter: "blocked after",
70
+ exhaustedAfter: "exhausted after",
71
+ stalledAfter: "stalled after",
72
+ canceledAfter: "canceled after",
73
+ needsYou: "needs you",
74
+ attempt: "attempt",
75
+ run: "run",
76
+ done: "done",
77
+ asked: "asked",
78
+ did: "done",
79
+ proof: "proof",
80
+ delivered: "delivered",
81
+ why: "why",
82
+ do: "do",
83
+ judge: "judge",
84
+ missing: "missing",
85
+ error: "error",
86
+ checksGreen: "checks green",
87
+ checksOf: "checks passed",
88
+ judgePass: "judge pass",
89
+ judgePasses: "judge passes",
90
+ judgeRejected: "judge rejected",
91
+ noJudge: "no judge",
92
+ revisions: "revisions",
93
+ nodes: "nodes",
94
+ next: "next",
95
+ waitingOnYou: "waiting on you",
96
+ phases: "phases",
97
+ cache: "cache",
98
+ in: "in",
99
+ out: "out",
100
+ nextCommand: "next",
101
+ available: "available",
102
+ runUpdate: "run",
103
+ noSummary: "(no summary recorded)",
104
+ noObjective: "(no objective recorded)",
105
+ more: "more",
106
+ complete: "complete",
107
+ },
108
+ pt: {
109
+ doneIn: "concluído em",
110
+ running: "rodando",
111
+ pending: "pendente",
112
+ state: "estado",
113
+ nothingDelivered: "nada entregue",
114
+ "no-op": "sem mudança após",
115
+ failedAfter: "falhou após",
116
+ blockedAfter: "bloqueado após",
117
+ exhaustedAfter: "esgotado após",
118
+ stalledAfter: "travou após",
119
+ canceledAfter: "cancelado após",
120
+ needsYou: "precisa de você",
121
+ attempt: "tentativa",
122
+ run: "run",
123
+ done: "concluídos",
124
+ asked: "pedido",
125
+ did: "feito",
126
+ proof: "prova",
127
+ delivered: "entregue",
128
+ why: "motivo",
129
+ do: "ação",
130
+ judge: "juiz",
131
+ missing: "faltou",
132
+ error: "erro",
133
+ checksGreen: "checagens verdes",
134
+ checksOf: "checagens passaram",
135
+ judgePass: "juiz aprovou",
136
+ judgePasses: "aprovações do juiz",
137
+ judgeRejected: "juiz rejeitou",
138
+ noJudge: "sem juiz",
139
+ revisions: "revisões",
140
+ nodes: "nós",
141
+ next: "próximo",
142
+ waitingOnYou: "esperando você",
143
+ phases: "fases",
144
+ cache: "cache",
145
+ in: "entrada",
146
+ out: "saída",
147
+ nextCommand: "próximo passo",
148
+ available: "disponível",
149
+ runUpdate: "rode",
150
+ noSummary: "(sem resumo registrado)",
151
+ noObjective: "(sem objetivo registrado)",
152
+ more: "mais",
153
+ complete: "completa",
154
+ },
155
+ };
156
+
157
+ /**
158
+ * @param {Language} language
159
+ * @returns {(key: string) => string}
160
+ */
161
+ export function labelsFor(language) {
162
+ const table = LABELS[language];
163
+ return (key) => table[key] ?? LABELS.en[key] ?? key;
164
+ }