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.
- package/integrations/claude-code/statusline.sh +12 -2
- package/package.json +2 -2
- package/skills/faberun/references/operations.md +14 -7
- package/skills/init-agentkit/scripts/install-agentkit.sh +10 -2
- package/src/campaign/chain.mjs +5 -4
- package/src/campaign/metrics.mjs +3 -1
- package/src/cli/launch.mjs +10 -1
- package/src/cli.mjs +10 -4
- package/src/contract/index.mjs +16 -3
- package/src/contract/task-packet.mjs +13 -1
- package/src/engine/bulk-read.mjs +7 -1
- package/src/engine/gate.mjs +17 -3
- package/src/engine/judge-gate.mjs +2 -2
- package/src/engine/live-preflight.mjs +43 -10
- package/src/engine/live-silence.mjs +49 -0
- package/src/engine/process-identity.mjs +12 -1
- package/src/engine/process.mjs +2 -1
- package/src/engine/run-command.mjs +8 -5
- package/src/engine/run-identity.mjs +213 -14
- package/src/engine/runtime-discovery.mjs +26 -5
- package/src/engine/scheduler.mjs +1 -1
- package/src/engine/supervise.mjs +2 -1
- package/src/harnesses/catalogue.mjs +8 -1
- package/src/harnesses/dsh/runner.mjs +6 -1
- package/src/harnesses/index.mjs +35 -7
- package/src/host/platform.mjs +204 -1
- package/src/host/preflight.mjs +142 -7
- package/src/notify/index.mjs +77 -11
- package/src/notify/session.mjs +64 -24
- package/src/plan/pipeline.mjs +5 -1
- package/src/plan/preflight.mjs +77 -0
- package/src/repo/declared-paths.mjs +87 -6
- package/src/repo/signal.mjs +56 -21
- package/src/repo/workspace.mjs +3 -2
- package/src/repo/worktree.mjs +2 -1
- package/src/report/locale.mjs +20 -0
- package/src/report/message.mjs +2 -2
- package/src/report/next.mjs +15 -1
- package/src/run/availability.mjs +138 -0
- package/src/run/disk-gc.mjs +16 -2
- package/src/run/lock.mjs +8 -0
- package/src/run/paths.mjs +13 -0
- package/src/seat/allowance.mjs +4 -1
- package/src/seat/tmux.mjs +9 -1
package/src/notify/session.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
74
|
-
if (!
|
|
89
|
+
const items = settingItems(env);
|
|
90
|
+
if (!items.length || items.includes("off")) return [];
|
|
75
91
|
/** @type {SessionTarget[]} */
|
|
76
92
|
const targets = [];
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
97
|
-
if (!
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/repo/signal.mjs
CHANGED
|
@@ -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
|
|
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
|
|
55
|
-
|
|
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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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}[]}
|
|
102
|
+
* @param {{campaign: import("../campaign/index.mjs").Campaign}[]} campaigns
|
|
80
103
|
* @returns {Map<string, string>}
|
|
81
104
|
*/
|
|
82
|
-
function runOwnerIndex(
|
|
105
|
+
function runOwnerIndex(campaigns) {
|
|
83
106
|
/** @type {Map<string, string>} */
|
|
84
107
|
const ownerByRun = new Map();
|
|
85
|
-
for (const { campaign } of
|
|
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.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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
|
|
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
|
-
//
|
|
120
|
-
// active work, not parked work, so
|
|
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
|
|
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
|
-
|
|
184
|
+
const classified = classifyRunProgress(progress);
|
|
185
|
+
if (classified === "succeeded" || classified === "canceled") continue;
|
|
151
186
|
const resume = `node src/cli.mjs resume ${runDir}`;
|
|
152
|
-
if (
|
|
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 {
|
package/src/repo/workspace.mjs
CHANGED
|
@@ -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,
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -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,
|
package/src/report/locale.mjs
CHANGED
|
@@ -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.
|