faberun 0.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
28
28
  "typecheck": "tsc",
29
- "test": "node --test test/*.test.mjs test/*/*.test.mjs",
29
+ "test": "node --test --import ./test/scoped-home.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
30
30
  "docs": "node src/cli/manual.mjs --write",
31
31
  "docs:check": "node src/cli/manual.mjs --check",
32
32
  "prepare": "husky"
@@ -8,7 +8,7 @@
8
8
  * the journal's own `eventId` so two sessions cannot consume each other's place.
9
9
  */
10
10
  import { JOURNAL_FILE, JOURNAL_TEXT_BYTES, JOURNAL_WATCH_CURSOR_DIR, JOURNAL_WATCH_CURSOR_SCHEMA_VERSION } from "./layout.mjs";
11
- import { boundedText, collapseLines } from "../util.mjs";
11
+ import { collapseLines } from "../util.mjs";
12
12
  import { campaignIdOf } from "./record.mjs";
13
13
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { randomUUID } from "node:crypto";
@@ -382,6 +382,11 @@ function normalizeEntry(entry) {
382
382
  return /** @type {JournalEntry} */ (normalized);
383
383
  }
384
384
  /**
385
+ * Collapse to one line and enforce the byte cap by refusing, not truncating:
386
+ * the journal is the record of what was written, so a note that does not fit
387
+ * is the author's to cut -- a silently shortened entry lies about its own
388
+ * write. Readers of already-stored text never pass through here.
389
+ *
385
390
  * @param {unknown} value
386
391
  * @param {string} label
387
392
  * @param {number} maxBytes
@@ -391,5 +396,9 @@ export function normalizeText(value, label, maxBytes = JOURNAL_TEXT_BYTES) {
391
396
  requireText(value, label);
392
397
  const collapsed = collapseLines(value);
393
398
  if (!collapsed) throw new TypeError(`${label} must not be blank`);
394
- return boundedText(collapsed, maxBytes);
399
+ const bytes = Buffer.byteLength(collapsed, "utf8");
400
+ if (bytes > maxBytes) {
401
+ throw new TypeError(`${label} is ${bytes} bytes, over the ${maxBytes}-byte cap; cut ${bytes - maxBytes} bytes and retry`);
402
+ }
403
+ return collapsed;
395
404
  }
@@ -30,6 +30,7 @@
30
30
  import { existsSync, readFileSync, statSync, openSync, closeSync, readSync, writeSync } from "node:fs";
31
31
  import { dirname } from "node:path";
32
32
  import { spawn } from "node:child_process";
33
+ import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
33
34
 
34
35
  /** @typedef {{executable: string, args: string[], cwd: string, promptTransport: "stdin"|"argv", harness: string, env: Record<string, string|null>|null, stdoutPath: string, stderrPath: string}} GateConfig */
35
36
 
@@ -155,9 +156,13 @@ function childEnv() {
155
156
  if (value === null) delete merged[key];
156
157
  else merged[key] = value;
157
158
  }
158
- // Worker providers are not a notification surface: strip the controller-only
159
- // transport after the harness overlay so no harness can reintroduce it.
160
- delete merged.FABERUN_NOTIFY_BIN;
159
+ // Worker providers are not a notification surface: strip every controller-only
160
+ // transport after the harness overlay so no harness can reintroduce one. The
161
+ // list lives in `notify/index.mjs`, not here: this line once named
162
+ // FABERUN_NOTIFY_BIN alone, and on 2026-09-21 a worker that inherited
163
+ // FABERUN_NOTIFY_SESSION ran this repository's suite, whose fixture
164
+ // controllers woke the operator's live session seven times in minutes.
165
+ for (const name of NOTIFY_ENV_NAMES) delete merged[name];
161
166
  return merged;
162
167
  }
163
168
 
@@ -135,6 +135,74 @@ function proveVerification(id, proof, recorded) {
135
135
  return { id, kind: "verification", ref: proof.ref, pass: entry.passed === true, detail };
136
136
  }
137
137
 
138
+ /**
139
+ * A command proof that declares a node:test filter (`--test-name-pattern`,
140
+ * `--test-skip-pattern`) is judged on more than its exit code, because the
141
+ * runner exits 0 whether its filter selected anything or not. The proof runs
142
+ * with the TAP reporter selected through `NODE_OPTIONS` and is refused when the
143
+ * output carries TAP's zero-plan line. Measured 2026-09-21 on node v26.8.1: a
144
+ * filter that matches emits `1..0` zero times, a filter that matches nothing
145
+ * emits it exactly once, a run with no filter emits it zero times, and an empty
146
+ * suite under a matching filter emits no nested zero plan. The default reporter
147
+ * cannot make the distinction -- both cases print identical counters, because
148
+ * the tick is the file rather than a test. The reporter goes through the
149
+ * environment because appending `--test-reporter=tap` to the command string is
150
+ * a no-op whenever a test file precedes it: node reads its own options
151
+ * left to right, so the flag lands among the script's arguments and the runner
152
+ * never sees it.
153
+ */
154
+ const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
155
+ const TAP_ZERO_PLAN = /^1\.\.0$/mu;
156
+
157
+ /**
158
+ * The environment a filtered proof runs under: the ambient environment with
159
+ * the TAP reporter selected through `NODE_OPTIONS`, minus `NODE_TEST_CONTEXT`.
160
+ * That marker belongs to whichever test runner spawned this process; a nested
161
+ * `node --test` that inherits it stays a runner child and emits no TAP at all
162
+ * (measured 2026-09-21: with the marker the zero-plan line never appears,
163
+ * without it exactly once) -- and a proof is judged as its own top-level run,
164
+ * not as the suite's child.
165
+ *
166
+ * @returns {NodeJS.ProcessEnv}
167
+ */
168
+ function envForFilteredProof() {
169
+ const { NODE_TEST_CONTEXT: _outer, NODE_OPTIONS: existing, ...ambient } = process.env;
170
+ return { ...ambient, NODE_OPTIONS: existing ? `${existing} --test-reporter=tap` : "--test-reporter=tap" };
171
+ }
172
+
173
+ /**
174
+ * The node:test filters a command string declares, in argv order, as flag and
175
+ * value. Presence alone changes behaviour (the appended reporter and the
176
+ * zero-plan look-up); the value is read for the refusal detail alone, which is
177
+ * why a whitespace split is close enough even though the command runs through
178
+ * a shell.
179
+ *
180
+ * @param {string} ref
181
+ * @returns {Array<{flag: string, value: string}>}
182
+ */
183
+ function declaredTestFilters(ref) {
184
+ const tokens = ref.split(/\s+/u).filter(Boolean);
185
+ /** @type {Array<{flag: string, value: string}>} */
186
+ const filters = [];
187
+ for (const [index, token] of tokens.entries()) {
188
+ for (const flag of TEST_FILTER_FLAGS) {
189
+ if (token.startsWith(`${flag}=`)) filters.push({ flag, value: unquote(token.slice(flag.length + 1)) });
190
+ else if (token === flag) filters.push({ flag, value: unquote(tokens[index + 1] ?? "") });
191
+ }
192
+ }
193
+ return filters;
194
+ }
195
+
196
+ /** @param {string} value @returns {string} */
197
+ function unquote(value) {
198
+ return value.replace(/^['"]|['"]$/gu, "");
199
+ }
200
+
201
+ /** @param {Array<{flag: string, value: string}>} filters @returns {string} */
202
+ function filterNames(filters) {
203
+ return filters.map(({ flag, value }) => `${flag} "${value}"`).join(", ");
204
+ }
205
+
138
206
  /**
139
207
  * @param {string} id
140
208
  * @param {DefinitionOfDoneProof} proof
@@ -144,11 +212,18 @@ function proveVerification(id, proof, recorded) {
144
212
  */
145
213
  async function proveCommand(id, proof, cwd, timeoutMs) {
146
214
  const ref = proof.ref;
215
+ const filters = declaredTestFilters(ref);
147
216
  return new Promise((settle) => {
148
217
  // Detached on POSIX so the shell leads its own process group: `shell: true`
149
218
  // means the timeout must kill the group, not the shell, or the command the
150
219
  // shell started keeps running and keeps the result pending forever.
151
- const child = spawn(ref, { cwd, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
220
+ const child = spawn(ref, {
221
+ cwd,
222
+ shell: true,
223
+ detached: process.platform !== "win32",
224
+ stdio: ["ignore", "pipe", "pipe"],
225
+ ...(filters.length ? { env: envForFilteredProof() } : {}),
226
+ });
152
227
  let stdout = "";
153
228
  let stderr = "";
154
229
  let settled = false;
@@ -178,6 +253,18 @@ async function proveCommand(id, proof, cwd, timeoutMs) {
178
253
  finish({ id, kind: "command", ref, pass: false, detail: boundedText(error.message) });
179
254
  });
180
255
  child.on("close", (code, signal) => {
256
+ // Its own detail, not an ordinary command failure: the command succeeded,
257
+ // so what failed is that the declared filter selected nothing to prove.
258
+ if (code === 0 && signal === null && filters.length > 0 && TAP_ZERO_PLAN.test(stdout)) {
259
+ finish({
260
+ id,
261
+ kind: "command",
262
+ ref,
263
+ pass: false,
264
+ detail: boundedText(`${filterNames(filters)} selected no test: exit 0 over a TAP plan of 1..0, so the proof measured nothing`),
265
+ });
266
+ return;
267
+ }
181
268
  const detail = signal !== null ? `killed by ${signal}` : `exit ${code ?? "?"}`;
182
269
  const pass = code === 0 && signal === null;
183
270
  finish({ id, kind: "command", ref, pass, detail: pass ? detail : boundedText(`${detail}: ${(stderr || stdout).trim()}`) });
@@ -6,6 +6,7 @@ import { dshHarness } from "./dsh/index.mjs";
6
6
  import { zcodeHarness } from "./zcode/index.mjs";
7
7
  import { execJsonlHarness } from "./exec-jsonl/index.mjs";
8
8
  import { replayHarness } from "./replay/index.mjs";
9
+ import { withoutNotifyEnv } from "../notify/index.mjs";
9
10
 
10
11
  /** Current wire-contract version for runner protocol artifacts. */
11
12
  export const PROTOCOL_SCHEMA_VERSION = 3;
@@ -451,7 +452,10 @@ export function probeRuntime(runtime, options = {}) {
451
452
  try {
452
453
  child = spawn(executable, args, {
453
454
  cwd: options.cwd,
454
- env: process.env,
455
+ // A worker or judge never delivers a notification; the controller does.
456
+ // In this repository a worker runs the test suite, whose fixture
457
+ // controllers would otherwise inherit a live transport and deliver.
458
+ env: withoutNotifyEnv(process.env),
455
459
  stdio: ["ignore", "pipe", "pipe"],
456
460
  });
457
461
  } catch (error) {
@@ -50,7 +50,7 @@ import { createHash } from "node:crypto";
50
50
  import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
51
51
  import { join } from "node:path";
52
52
  import { createMacosNotifier } from "./os-macos.mjs";
53
- import { deliverToSessions, resolveSessionTargets, sessionWakeNotice } from "./session.mjs";
53
+ import { NOTIFY_SESSION_ENV, deliverToSessions, resolveSessionTargets, sessionWakeNotice } from "./session.mjs";
54
54
  import { errorMessage } from "../util.mjs";
55
55
 
56
56
  /**
@@ -74,6 +74,29 @@ function loadProgressModule() {
74
74
 
75
75
  export const NOTIFY_BIN_ENV = "FABERUN_NOTIFY_BIN";
76
76
  const MACOS_TRANSPORT = "os-macos";
77
+
78
+ /**
79
+ * Every variable that binds a notification transport. The controller is the
80
+ * only process that delivers: a worker, a judge or a verification command
81
+ * that inherits these would notify on the controller's behalf -- and in this
82
+ * repository, whose workers run its own test suite, every fixture controller
83
+ * the suite spawns would deliver its terminal events for real. Measured
84
+ * 2026-09-21: a run launched with `FABERUN_NOTIFY_SESSION=auto` woke the
85
+ * operator's session seven times in minutes from `test/repo/base-ref.test.mjs`
86
+ * fixtures its worker ran. `withoutNotifyEnv` is the boundary every child
87
+ * crosses; `test/setup.mjs` neutralises the same names inside the suite.
88
+ */
89
+ export const NOTIFY_ENV_NAMES = Object.freeze([NOTIFY_BIN_ENV, NOTIFY_SESSION_ENV]);
90
+
91
+ /**
92
+ * @param {NodeJS.ProcessEnv} env
93
+ * @returns {NodeJS.ProcessEnv} a copy with every notify transport unbound
94
+ */
95
+ export function withoutNotifyEnv(env) {
96
+ const copy = { ...env };
97
+ for (const name of NOTIFY_ENV_NAMES) delete copy[name];
98
+ return copy;
99
+ }
77
100
  export const NOTIFY_LOG_FILE = "notify.jsonl";
78
101
  /**
79
102
  * The bounded retry budget the dispatcher used to spend before giving up.
@@ -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}