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.
- package/package.json +2 -2
- package/skills/faberun/references/operations.md +22 -11
- package/src/campaign/journal.mjs +11 -2
- package/src/engine/cancel.mjs +20 -6
- package/src/engine/gate.mjs +8 -3
- package/src/engine/judge-gate.mjs +88 -1
- package/src/harnesses/index.mjs +5 -1
- package/src/host/preflight.mjs +5 -3
- package/src/notify/index.mjs +89 -32
- package/src/notify/session.mjs +240 -0
- package/src/plan/pipeline.mjs +14 -2
- package/src/plan/repo-facts.mjs +2 -0
- package/src/repo/signal.mjs +76 -9
- package/src/repo/worktree.mjs +28 -0
- package/src/report/locale.mjs +164 -0
- package/src/report/message.mjs +602 -0
- package/src/report/progress.mjs +33 -153
- package/src/seat/tmux.mjs +12 -3
|
@@ -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
|
+
}
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -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 (
|
|
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
|
}
|
package/src/plan/repo-facts.mjs
CHANGED
|
@@ -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",
|
package/src/repo/signal.mjs
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
51
|
-
|
|
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
|
|
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
|
|
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}
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -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
|
+
}
|