faberun 0.27.0 → 0.28.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.
Files changed (73) hide show
  1. package/README.md +9 -3
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +5 -0
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +67 -7
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +5 -1
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +15 -9
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +29 -0
  45. package/src/harnesses/claude/index.mjs +98 -1
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +14 -0
  48. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  49. package/src/harnesses/fx/index.mjs +13 -0
  50. package/src/harnesses/index.mjs +21 -10
  51. package/src/harnesses/replay/index.mjs +12 -0
  52. package/src/harnesses/zcode/index.mjs +19 -0
  53. package/src/host/config.mjs +8 -2
  54. package/src/host/environment-report.mjs +85 -0
  55. package/src/host/home.mjs +27 -0
  56. package/src/host/install-registry.mjs +511 -0
  57. package/src/host/preflight.mjs +32 -9
  58. package/src/host/uninstall-roots.mjs +196 -0
  59. package/src/plan/proof-check.mjs +15 -0
  60. package/src/plan/repo-facts.mjs +95 -11
  61. package/src/plan/rounds.mjs +27 -0
  62. package/src/repo/declared-paths.mjs +74 -6
  63. package/src/repo/integrate.mjs +1 -1
  64. package/src/repo/scope-closure.mjs +59 -0
  65. package/src/repo/signal-block.mjs +26 -0
  66. package/src/repo/signal.mjs +9 -24
  67. package/src/repo/workspace.mjs +189 -14
  68. package/src/report/campaign-brief.mjs +4 -0
  69. package/src/report/message.mjs +37 -6
  70. package/src/report/progress.mjs +13 -2
  71. package/src/run/paths.mjs +14 -1
  72. package/src/run/usage.mjs +66 -1
  73. package/src/util.mjs +18 -2
@@ -3,6 +3,20 @@ import { fileURLToPath } from "node:url";
3
3
  import { parseVersion } from "../protocol.mjs";
4
4
  import { normalizeRunnerTranscript, runnerEnvironmentOverlay, withSchema } from "../runner-transcript.mjs";
5
5
 
6
+ /**
7
+ * The environment names the dsh adapter reads to authenticate and configure the
8
+ * harness: the provider credential the dsh CLI resolves for the declared
9
+ * provider and the binary override `executable()` honours. The environment
10
+ * overlay's `GIT_CONFIG_*` scrub is a generic pass over ambient keys, not a
11
+ * declared dependency. Values never travel here.
12
+ *
13
+ * @type {readonly string[]}
14
+ */
15
+ export const declaredEnvironment = Object.freeze([
16
+ "DEEPSEEK_API_KEY",
17
+ "FABERUN_DSH_BIN",
18
+ ]);
19
+
6
20
  const HERE = dirname(fileURLToPath(import.meta.url));
7
21
 
8
22
  /**
@@ -31,6 +31,18 @@ import { finite } from "../../util.mjs";
31
31
 
32
32
  export { HARNESS_OUTPUT_LIMIT_BYTES } from "../protocol.mjs";
33
33
 
34
+ /**
35
+ * The environment names the exec-jsonl adapter reads to authenticate and
36
+ * configure the wrapper: only the binary override `executable()` honours.
37
+ * Credentials belong to the wrapper the contract names, so the adapter reads
38
+ * none of them. Values never travel here.
39
+ *
40
+ * @type {readonly string[]}
41
+ */
42
+ export const declaredEnvironment = Object.freeze([
43
+ "FABERUN_EXEC_JSONL_BIN",
44
+ ]);
45
+
34
46
  export const EXEC_JSONL_PROTOCOL = Object.freeze({
35
47
  schemaVersion: 1,
36
48
  requestType: "run.request",
@@ -10,6 +10,19 @@ import { fileURLToPath } from "node:url";
10
10
  import { parseVersion } from "../protocol.mjs";
11
11
  import { normalizeRunnerTranscript, runnerEnvironmentOverlay, withSchema } from "../runner-transcript.mjs";
12
12
 
13
+ /**
14
+ * The environment names this adapter reads outside the base set and the
15
+ * runtime's own `*.env_key` names: the provider key the client falls back to
16
+ * when a runtime names none, and the binary override `executable()` honours.
17
+ * Values never travel here.
18
+ *
19
+ * @type {readonly string[]}
20
+ */
21
+ export const declaredEnvironment = Object.freeze([
22
+ "DEEPSEEK_API_KEY",
23
+ "FABERUN_FX_BIN",
24
+ ]);
25
+
13
26
  const HERE = dirname(fileURLToPath(import.meta.url));
14
27
 
15
28
  /**
@@ -7,7 +7,7 @@ import { fxHarness } from "./fx/index.mjs";
7
7
  import { zcodeHarness } from "./zcode/index.mjs";
8
8
  import { execJsonlHarness } from "./exec-jsonl/index.mjs";
9
9
  import { replayHarness } from "./replay/index.mjs";
10
- import { withoutNotifyEnv } from "../notify/index.mjs";
10
+ import { passthroughEnvironment, workerEnvironment } from "../engine/worker-env.mjs";
11
11
  import { spawnInvocation } from "../host/platform.mjs";
12
12
 
13
13
  /** Current wire-contract version for runner protocol artifacts. */
@@ -72,7 +72,7 @@ const CAPABILITY_NAMES = new Set([
72
72
  * `config["replay.recording"]` for commands, and dsh requires
73
73
  * `config.provider` for the provider route every attempt runs on.
74
74
  *
75
- * @typedef {{id?: string, harness: string, model: string, reasoning?: string, sandbox?: string, permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, tier?: number|string, vendor?: string}} HarnessRuntime
75
+ * @typedef {{id?: string, harness: string, model: string, reasoning?: string, sandbox?: string, permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, envPassthrough?: string[], tier?: number|string, vendor?: string}} HarnessRuntime
76
76
  */
77
77
 
78
78
  /**
@@ -216,10 +216,14 @@ export function resolveVendor(runtime) {
216
216
 
217
217
  /**
218
218
  * Build one provider invocation. Prompt transport is explicit in the result:
219
- * stdin adapters return `input`, while argv adapters append the prompt. An
220
- * optional `env` overlay is merged over the runner environment at spawn time;
221
- * a null value removes the ambient variable. A caller-supplied `options.env`
222
- * merges over the adapter's own overlay here, once, for every harness.
219
+ * stdin adapters return `input`, while argv adapters append the prompt.
220
+ *
221
+ * The returned `env` is the runtime-specific allowlist overlay (adapter
222
+ * declarations, `*.env_key` names, and the runtime's `envPassthrough`) merged
223
+ * over the adapter's own overlay; a null value removes the ambient variable.
224
+ * The spawn gate unions it with the base operating-system names, so no other
225
+ * controller variable reaches the child. A caller-supplied `options.env`
226
+ * merges over everything here, once, for every harness.
223
227
  *
224
228
  * @param {HarnessRuntime} runtime
225
229
  * @param {string} prompt
@@ -229,6 +233,12 @@ export function resolveVendor(runtime) {
229
233
  export function providerCommand(runtime, prompt, options = {}) {
230
234
  const harness = getHarness(runtime.harness);
231
235
  const command = harness.command(runtime, prompt, options);
236
+ // The runtime-specific allowlist travels to the gate as an overlay. The gate
237
+ // unions it with the base operating-system names and nothing else, so a
238
+ // controller variable no adapter, `*.env_key`, or envPassthrough named never
239
+ // reaches the child. It is deliberately not the whole environment: the
240
+ // caller's overlay stays an overlay, and the base set stays the gate's job.
241
+ command.env = { ...passthroughEnvironment(runtime, undefined, process.env), ...command.env };
232
242
  if (options.env) command.env = { ...command.env, ...options.env };
233
243
  if (command.promptTransport === "argv") {
234
244
  const limit = runtime.maxArgvPromptBytes ?? harness.capabilities.maxArgvPromptBytes;
@@ -507,10 +517,11 @@ export function probeRuntime(runtime, options = {}) {
507
517
  const invocation = spawnInvocation(executable, args, { cwd: options.cwd });
508
518
  child = spawn(invocation.command, invocation.args, {
509
519
  cwd: options.cwd,
510
- // A worker or judge never delivers a notification; the controller does.
511
- // In this repository a worker runs the test suite, whose fixture
512
- // controllers would otherwise inherit a live transport and deliver.
513
- env: withoutNotifyEnv(process.env),
520
+ // A probe is a worker-class spawn: it receives only the base names,
521
+ // the adapter's declared names, the runtime's `*.env_key` names, and
522
+ // any envPassthrough the runtime carries. It never delivers a
523
+ // notification; the controller does.
524
+ env: workerEnvironment(runtime),
514
525
  stdio: ["ignore", "pipe", "pipe"],
515
526
  ...invocation.options,
516
527
  });
@@ -2,6 +2,18 @@ import { resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { parseVersion } from "../protocol.mjs";
4
4
 
5
+ /**
6
+ * The environment names the replay adapter reads to authenticate and configure
7
+ * the recording: only the binary override `executable()` honours. A recording
8
+ * carries no credential of its own, and `replay/bin.mjs` reads no environment
9
+ * name at all. Values never travel here.
10
+ *
11
+ * @type {readonly string[]}
12
+ */
13
+ export const declaredEnvironment = Object.freeze([
14
+ "FABERUN_REPLAY_BIN",
15
+ ]);
16
+
5
17
  /** Envelope statuses a recording may carry. */
6
18
  const REPLAY_STATUSES = Object.freeze(new Set([
7
19
  "done",
@@ -3,6 +3,25 @@ import { homedir } from "node:os";
3
3
  import { delimiter, join } from "node:path";
4
4
  import { normalizeZcodeResult, parseVersion } from "../protocol.mjs";
5
5
 
6
+ /**
7
+ * The environment names the zcode adapter reads to authenticate and configure
8
+ * the CLI: the credential the CLI resolves (`auth_token.env_key`, default
9
+ * `ZAI_API_KEY`, with `ANTHROPIC_AUTH_TOKEN` as its fallback), the app install
10
+ * directory `zcodeBundle()` reads, and the binary override `executable()`
11
+ * honours. The `ZCODE_MODEL`, `ZCODE_BASE_URL`, `ANTHROPIC_API_KEY` and
12
+ * `ZCODE_LOG_*` names this adapter writes into the child environment are
13
+ * configuration it supplies, not dependencies it reads. Values never travel
14
+ * here.
15
+ *
16
+ * @type {readonly string[]}
17
+ */
18
+ export const declaredEnvironment = Object.freeze([
19
+ "ANTHROPIC_AUTH_TOKEN",
20
+ "FABERUN_ZCODE_APP_DIR",
21
+ "FABERUN_ZCODE_BIN",
22
+ "ZAI_API_KEY",
23
+ ]);
24
+
6
25
  /** Default Z.ai Anthropic-compatible endpoint serving GLM models. */
7
26
  const ZCODE_DEFAULT_BASE_URL = "https://api.z.ai/api/anthropic";
8
27
 
@@ -3,7 +3,9 @@
3
3
  * enabled, which runtime is the default worker and judge, the machine's own
4
4
  * ordered `judges` list (R18) -- read only when a contract declares none --
5
5
  * and its ordered `reviewers` list (R19), `faberun plan`'s own machine
6
- * default, read only when `--reviewers` names none.
6
+ * default, read only when `--reviewers` names none. Its `judgeIndependence`
7
+ * (R36) is the machine-wide same-vendor opt-in, read only when a contract
8
+ * declares none.
7
9
  *
8
10
  * It is separate from `host/home.mjs`, which owns the path, because reading a
9
11
  * config is a validation problem and writing it is an atomic-write problem;
@@ -13,8 +15,9 @@
13
15
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
16
  import { dirname } from "node:path";
15
17
  import { configPath, faberunHome } from "./home.mjs";
18
+ import { SAME_VENDOR_REVIEW_MODE } from "../contract/judge-independence.mjs";
16
19
 
17
- /** @typedef {{schemaVersion: 1, harnesses: string[], worker?: string, judge?: string, judges?: string[], reviewers?: string[], updatedAt: string}} UserConfig */
20
+ /** @typedef {{schemaVersion: 1, harnesses: string[], worker?: string, judge?: string, judges?: string[], reviewers?: string[], judgeIndependence?: "same-vendor", updatedAt: string}} UserConfig */
18
21
 
19
22
  /** Paths already reported malformed, so a process that reads twice warns once. */
20
23
  const warned = new Set();
@@ -88,5 +91,8 @@ function isUserConfig(value) {
88
91
  if (record.judge !== undefined && typeof record.judge !== "string") return false;
89
92
  if (record.judges !== undefined && (!Array.isArray(record.judges) || !record.judges.every((id) => typeof id === "string"))) return false;
90
93
  if (record.reviewers !== undefined && (!Array.isArray(record.reviewers) || !record.reviewers.every((id) => typeof id === "string"))) return false;
94
+ // The machine's same-vendor opt-in shares the contract field's single
95
+ // accepted value; anything else is malformed, not silently ignored.
96
+ if (record.judgeIndependence !== undefined && record.judgeIndependence !== SAME_VENDOR_REVIEW_MODE) return false;
91
97
  return typeof record.updatedAt === "string";
92
98
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The `doctor --env` report: which controller variables each runtime's child
3
+ * would carry, which it would not, and which excluded names look like
4
+ * credentials. Only names cross this boundary, never values. Separate from
5
+ * `preflight.mjs` because it is a listing built from the worker environment
6
+ * builder, not a readiness check, and it took that module past its 800-line
7
+ * ceiling (measured 2026-09-27: 840 lines).
8
+ */
9
+ import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
10
+ import { workerEnvironment } from "../engine/worker-env.mjs";
11
+
12
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
13
+ /** @typedef {import("./preflight.mjs").ReachableRuntimes} ReachableRuntimes */
14
+
15
+ /**
16
+ * The name shapes that mark a controller variable as a credential. An excluded
17
+ * name matching one of these is reported as retained: the operator reads that
18
+ * the secret exists on the controller and is kept out of every worker, and
19
+ * never reads its value.
20
+ *
21
+ * `*_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `AWS_*` and `GITHUB_*` are the
22
+ * six shapes R3 names; they are a warning about the name, not a claim about the
23
+ * value, so a `GITHUB_ACTIONS` is retained too.
24
+ */
25
+ const SECRET_ENV_NAME_PATTERNS = Object.freeze([
26
+ /_KEY$/u,
27
+ /_TOKEN$/u,
28
+ /_SECRET$/u,
29
+ /_PASSWORD$/u,
30
+ /^AWS_/u,
31
+ /^GITHUB_/u,
32
+ ]);
33
+
34
+ /** @param {string} name @returns {boolean} */
35
+ export function isSecretEnvName(name) {
36
+ return SECRET_ENV_NAME_PATTERNS.some((pattern) => pattern.test(name));
37
+ }
38
+
39
+ /**
40
+ * @param {Record<string, string|undefined>} source
41
+ * @returns {string[]}
42
+ */
43
+ function presentEnvironmentNames(source) {
44
+ return Object.keys(source).filter((name) => source[name] !== undefined).sort((left, right) => left.localeCompare(right));
45
+ }
46
+
47
+ /**
48
+ * One runtime's side of the `doctor --env` report: the controller variable
49
+ * names it would carry and the names it would not, with every excluded
50
+ * credential-shaped name also listed under `retained`.
51
+ *
52
+ * Values never cross this boundary. The allowlist is computed from the same
53
+ * `workerEnvironment` the dispatch gate spawns with, and only its keys are
54
+ * read; the source is consulted for presence, never copied into the result.
55
+ *
56
+ * @param {{id?: string, harness?: string, config?: Record<string, unknown>, envPassthrough?: string[]}|null|undefined} runtime
57
+ * @param {{envPassthrough?: string[]}|null|undefined} [contract]
58
+ * @param {Record<string, string|undefined>} [source]
59
+ * @returns {{runtime: string, harness: string, passed: string[], excluded: string[], retained: string[]}}
60
+ */
61
+ export function runtimeEnvironmentReport(runtime, contract, source = process.env) {
62
+ const allowed = new Set(Object.keys(workerEnvironment(runtime, contract, source)));
63
+ const present = presentEnvironmentNames(source);
64
+ const passed = present.filter((name) => allowed.has(name));
65
+ const excluded = present.filter((name) => !allowed.has(name));
66
+ const retained = excluded.filter((name) => isSecretEnvName(name));
67
+ return { runtime: runtime?.id ?? "", harness: runtime?.harness ?? "", passed, excluded, retained };
68
+ }
69
+
70
+ /**
71
+ * The per-runtime listings behind `doctor --env`. A contract narrows the report
72
+ * to the reachable runtimes the run committed to; without one the report covers
73
+ * the discovery catalogue, because there is no routed runtime to name.
74
+ *
75
+ * @param {ReachableRuntimes} [runtimes]
76
+ * @param {ValidatedContract} [contract]
77
+ * @param {Record<string, string|undefined>} [source]
78
+ * @returns {{runtime: string, harness: string, passed: string[], excluded: string[], retained: string[]}[]}
79
+ */
80
+ export function environmentListings(runtimes, contract, source = process.env) {
81
+ const entries = runtimes && runtimes.size
82
+ ? [...runtimes.entries()].map(([id, { runtime }]) => ({ ...runtime, id }))
83
+ : Object.entries(DISCOVERY_RUNTIME_DEFINITIONS).map(([id, runtime]) => ({ ...runtime, id }));
84
+ return entries.map((runtime) => runtimeEnvironmentReport(runtime, contract, source));
85
+ }
package/src/host/home.mjs CHANGED
@@ -27,6 +27,33 @@ export function faberunHome(env = process.env) {
27
27
  return join(homedir(), ".faberun");
28
28
  }
29
29
 
30
+ /**
31
+ * The home directory the *injected* environment names, or null when it names
32
+ * none. Unlike `faberunHome`, this never consults `os.homedir()`.
33
+ *
34
+ * The distinction matters for exactly one caller: `faberun uninstall` derives
35
+ * the roots it may delete from the environment it was handed, and
36
+ * `src/host/uninstall-roots.mjs` refuses every removal when that environment
37
+ * names neither root. A fallback to the operator's real home would aim a delete
38
+ * at it when the caller meant to scope the operation to a throwaway one, which
39
+ * is the accident the isolation requirement exists to stop. `HOME` is the POSIX
40
+ * name and `USERPROFILE` the Windows one; `HOMEDRIVE`+`HOMEPATH` is the last
41
+ * spelling `os.homedir()` itself accepts there.
42
+ *
43
+ * @param {NodeJS.ProcessEnv} [env]
44
+ * @returns {string|null}
45
+ */
46
+ export function effectiveHome(env = process.env) {
47
+ for (const name of ["HOME", "USERPROFILE"]) {
48
+ const value = env[name];
49
+ if (typeof value === "string" && value) return value;
50
+ }
51
+ const drive = env.HOMEDRIVE;
52
+ const path = env.HOMEPATH;
53
+ if (typeof drive === "string" && drive && typeof path === "string" && path) return `${drive}${path}`;
54
+ return null;
55
+ }
56
+
30
57
  /** @param {string} home @returns {string} */
31
58
  export function versionsDir(home) {
32
59
  return join(home, "versions");