faberun 0.26.3 → 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 (88) hide show
  1. package/README.md +11 -4
  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 +6 -1
  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 +68 -8
  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 +6 -2
  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 +61 -13
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +54 -2
  45. package/src/harnesses/claude/index.mjs +177 -2
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +18 -116
  48. package/src/harnesses/dsh/runner.mjs +1 -17
  49. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  50. package/src/harnesses/fx/home.mjs +57 -0
  51. package/src/harnesses/fx/index.mjs +137 -0
  52. package/src/harnesses/fx/native/build.zig +20 -0
  53. package/src/harnesses/fx/native/build.zig.zon +7 -0
  54. package/src/harnesses/fx/native/home.zig +85 -0
  55. package/src/harnesses/fx/native/main.zig +503 -0
  56. package/src/harnesses/fx/native/permissions.zig +51 -0
  57. package/src/harnesses/fx/native/relay.zig +253 -0
  58. package/src/harnesses/fx/native/usage.zig +112 -0
  59. package/src/harnesses/fx/permissions.mjs +35 -0
  60. package/src/harnesses/fx/runner.mjs +350 -0
  61. package/src/harnesses/fx/usage-proxy.mjs +176 -0
  62. package/src/harnesses/index.mjs +29 -14
  63. package/src/harnesses/replay/index.mjs +12 -0
  64. package/src/harnesses/runner-io.mjs +25 -0
  65. package/src/harnesses/runner-transcript.mjs +127 -0
  66. package/src/harnesses/session-metrics.mjs +38 -13
  67. package/src/harnesses/zcode/index.mjs +19 -0
  68. package/src/host/config.mjs +8 -2
  69. package/src/host/environment-report.mjs +85 -0
  70. package/src/host/home.mjs +27 -0
  71. package/src/host/install-registry.mjs +511 -0
  72. package/src/host/preflight.mjs +34 -10
  73. package/src/host/uninstall-roots.mjs +196 -0
  74. package/src/plan/proof-check.mjs +15 -0
  75. package/src/plan/repo-facts.mjs +95 -11
  76. package/src/plan/rounds.mjs +27 -0
  77. package/src/repo/declared-paths.mjs +74 -6
  78. package/src/repo/integrate.mjs +1 -1
  79. package/src/repo/scope-closure.mjs +59 -0
  80. package/src/repo/signal-block.mjs +26 -0
  81. package/src/repo/signal.mjs +9 -24
  82. package/src/repo/workspace.mjs +189 -14
  83. package/src/report/campaign-brief.mjs +4 -0
  84. package/src/report/message.mjs +37 -6
  85. package/src/report/progress.mjs +13 -2
  86. package/src/run/paths.mjs +14 -1
  87. package/src/run/usage.mjs +66 -1
  88. package/src/util.mjs +18 -2
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The fx harness adapter: capabilities, the command that starts Faberun's own
3
+ * fx client (`runner.mjs`), and the envelope folded from its transcript. The
4
+ * client, not the `fx` binary, is what the gate spawns.
5
+ */
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { parseVersion } from "../protocol.mjs";
11
+ import { normalizeRunnerTranscript, runnerEnvironmentOverlay, withSchema } from "../runner-transcript.mjs";
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
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+
28
+ /**
29
+ * fx has no one-shot surface that streams a turn or reports its cache split,
30
+ * so the adapter's real provider is this client: it speaks `fx acp`, answers
31
+ * fx's permission requests, and relays the provider traffic through a
32
+ * loopback meter. The `fx` binary named by `executable()` is the harness the
33
+ * client drives, and probing it is what preflight means by "the provider
34
+ * binary exists".
35
+ */
36
+ const RUNNER = join(HERE, "runner.mjs");
37
+
38
+ /**
39
+ * The same client in Zig (`native/`), built by `zig build` in that directory.
40
+ * Measured 2026-09-24 over three parseDuration turns: 15-16 MB resident
41
+ * against 83 MB for `runner.mjs`, with the same transcript. Until a release
42
+ * ships the binary, a checkout that has not built it runs `runner.mjs`.
43
+ */
44
+ const NATIVE_RUNNER = join(HERE, "native", "zig-out", "bin", process.platform === "win32" ? "faberun-fx-runner.exe" : "faberun-fx-runner");
45
+
46
+ /**
47
+ * Measured 2026-09-24 on the parseDuration fixture on one macOS machine: this
48
+ * runner peaked at 83 MB resident and `fx acp` at 9 MB, against 310-370 MB for
49
+ * `dsh --profile headless` alone, with the same model and a passing suite. The
50
+ * harness exists for parallel campaigns on DeepSeek, where that difference is
51
+ * the worker count a machine can hold.
52
+ *
53
+ * @type {import("../index.mjs").HarnessAdapter}
54
+ */
55
+ export const fxHarness = {
56
+ capabilities: {
57
+ // The schema rides in the prompt and the verdict is extracted from the
58
+ // final message, as dsh does; nothing in the wire enforces it.
59
+ structuredOutput: true,
60
+ promptTransport: "stdin",
61
+ // `runtime.sandbox` is decided per ACP permission request by
62
+ // `permissions.mjs`: file mutations by path, shell commands allowed.
63
+ sandbox: true,
64
+ permissions: false,
65
+ // `fx acp` can resume a session, but the runner builds a throwaway HOME
66
+ // per turn and fx stores sessions there; no id outlives the turn.
67
+ continuation: false,
68
+ tokenBudget: false,
69
+ costBudget: false,
70
+ usage: true,
71
+ cost: false,
72
+ toolPolicy: false,
73
+ // Measured 2026-09-24: one parseDuration turn streamed 7 `tool_call`
74
+ // updates as they happened; the runner forwards each as `fx.tool`.
75
+ streamsOutput: true,
76
+ // Not measured for fx.
77
+ signalsProcesses: null,
78
+ },
79
+
80
+ permissionExecution: {
81
+ field: "sandbox",
82
+ executingModes: ["read-only", "workspace-write", "danger-full-access"],
83
+ defaultMode: "workspace-write",
84
+ readOnlyModes: ["read-only"],
85
+ },
86
+
87
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
88
+ executable(runtime) {
89
+ return process.env.FABERUN_FX_BIN ?? runtime.executable ?? "fx";
90
+ },
91
+
92
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string[]} */
93
+ versionArgs(runtime) {
94
+ return runtime.versionArgs ?? ["--version"];
95
+ },
96
+
97
+ parseVersion,
98
+
99
+ /**
100
+ * @param {import("../index.mjs").HarnessRuntime} runtime
101
+ * @param {string} prompt
102
+ * @param {import("../index.mjs").CommandOptions} options
103
+ * @returns {import("../index.mjs").HarnessCommand}
104
+ */
105
+ command(runtime, prompt, options = {}) {
106
+ const args = ["--fx", this.executable(runtime), "--model", runtime.model];
107
+ if (runtime.sandbox) args.push("--sandbox", runtime.sandbox);
108
+ const config = runtime.config ?? {};
109
+ if (config.provider === "codex") args.push("--provider", "codex");
110
+ if (typeof config.base_url === "string" && config.base_url) args.push("--base-url", config.base_url);
111
+ if (typeof config["api_key.env_key"] === "string" && config["api_key.env_key"]) args.push("--key-env", config["api_key.env_key"]);
112
+ if (typeof config.context_window === "number") args.push("--context-window", String(config.context_window));
113
+ if (typeof config.max_output_tokens === "number") args.push("--max-output-tokens", String(config.max_output_tokens));
114
+ const native = existsSync(NATIVE_RUNNER);
115
+ return {
116
+ executable: native ? NATIVE_RUNNER : process.execPath,
117
+ args: native ? args : [RUNNER, ...args],
118
+ promptTransport: "stdin",
119
+ input: withSchema(prompt, options.schema),
120
+ env: runnerEnvironmentOverlay(process.env),
121
+ };
122
+ },
123
+
124
+ /**
125
+ * @param {string} stdout
126
+ * @param {number|null} exitCode
127
+ * @param {string|null} signal
128
+ * @param {import("../index.mjs").NormalizeOptions} [options]
129
+ * @returns {import("../index.mjs").ProviderEnvelope}
130
+ */
131
+ normalize(stdout, exitCode, signal, options = {}) {
132
+ return normalizeRunnerTranscript("fx", stdout, exitCode, signal, options);
133
+ },
134
+ };
135
+
136
+ export const harness = fxHarness;
137
+ export default fxHarness;
@@ -0,0 +1,20 @@
1
+ //! Builds `faberun-fx-runner`, the native fx client the fx adapter spawns.
2
+ //! `zig build` installs it to `zig-out/bin/`; `zig build test` runs the unit
3
+ //! tests of every module.
4
+
5
+ const std = @import("std");
6
+
7
+ pub fn build(b: *std.Build) void {
8
+ const target = b.standardTargetOptions(.{});
9
+ // ReleaseSafe unless told otherwise: the runner ships to operators, and
10
+ // Debug is 4x the binary for no runtime check ReleaseSafe drops.
11
+ const optimize = b.option(std.builtin.OptimizeMode, "optimize", "Optimization mode (default ReleaseSafe)") orelse .ReleaseSafe;
12
+ const module = b.createModule(.{
13
+ .root_source_file = b.path("main.zig"),
14
+ .target = target,
15
+ .optimize = optimize,
16
+ });
17
+ b.installArtifact(b.addExecutable(.{ .name = "faberun-fx-runner", .root_module = module }));
18
+ const tests = b.addTest(.{ .root_module = module });
19
+ b.step("test", "Run the runner's unit tests").dependOn(&b.addRunArtifact(tests).step);
20
+ }
@@ -0,0 +1,7 @@
1
+ .{
2
+ .name = .faberun_fx_runner,
3
+ .version = "0.1.0",
4
+ .fingerprint = 0x899586834bb2a4a8,
5
+ .minimum_zig_version = "0.16.0",
6
+ .paths = .{ "build.zig", "build.zig.zon", "main.zig", "home.zig", "permissions.zig", "relay.zig", "usage.zig" },
7
+ }
@@ -0,0 +1,85 @@
1
+ //! The HOME an fx worker runs under. fx reads provider connections only from
2
+ //! `$HOME/.fx/settings.json` and has no flag or variable that names another
3
+ //! settings file, so pointing a worker at its own usage relay means giving it
4
+ //! its own HOME. The same boundary closes the packet: fx loads every skill it
5
+ //! finds under `~/.fx`, `~/.claude`, `~/.codex`, `~/.agents` and
6
+ //! `~/.config/opencode` (fx 0.0.11 `skill_runtime.zig`), none of which a closed
7
+ //! task packet asked for.
8
+ //!
9
+ //! Everything else in the operator's HOME is linked in, because the worker's
10
+ //! shell inherits this HOME: git identity, npm and toolchain caches, and
11
+ //! version managers keep resolving to the real files.
12
+
13
+ const std = @import("std");
14
+ const Io = std.Io;
15
+
16
+ /// Top-level HOME entries fx scans for skills or reads as its own profile.
17
+ const withheld = [_][]const u8{ ".fx", ".claude", ".codex", ".agents" };
18
+ /// Entries under `~/.config` fx scans for skills.
19
+ const withheld_config = [_][]const u8{"opencode"};
20
+
21
+ fn isWithheld(name: []const u8, list: []const []const u8) bool {
22
+ for (list) |entry| if (std.mem.eql(u8, entry, name)) return true;
23
+ return false;
24
+ }
25
+
26
+ /// Links every entry of `from` into `to` except `list` and `rebuilt`.
27
+ fn linkEntries(arena: std.mem.Allocator, io: Io, from: []const u8, to: []const u8, list: []const []const u8, rebuilt: ?[]const u8) !void {
28
+ var dir = Io.Dir.openDirAbsolute(io, from, .{ .iterate = true }) catch |err| switch (err) {
29
+ // An operator without `~/.config` has nothing to link from it.
30
+ error.FileNotFound => return,
31
+ else => return err,
32
+ };
33
+ defer dir.close(io);
34
+ var entries = dir.iterate();
35
+ while (try entries.next(io)) |entry| {
36
+ if (isWithheld(entry.name, list)) continue;
37
+ if (rebuilt) |name| if (std.mem.eql(u8, entry.name, name)) continue;
38
+ const target = try std.fs.path.join(arena, &.{ from, entry.name });
39
+ const link = try std.fs.path.join(arena, &.{ to, entry.name });
40
+ try Io.Dir.symLinkAbsolute(io, target, link, .{});
41
+ }
42
+ }
43
+
44
+ /// Fill `home`, an empty directory the caller owns and removes, with links to
45
+ /// `real_home` and the fx settings document `settings`.
46
+ pub fn prepare(arena: std.mem.Allocator, io: Io, real_home: []const u8, home: []const u8, settings: []const u8) !void {
47
+ try linkEntries(arena, io, real_home, home, &withheld, ".config");
48
+ const config = try std.fs.path.join(arena, &.{ home, ".config" });
49
+ try Io.Dir.cwd().createDirPath(io, config);
50
+ try linkEntries(arena, io, try std.fs.path.join(arena, &.{ real_home, ".config" }), config, &withheld_config, null);
51
+ // fx on Linux refuses a profile directory others can read
52
+ // (`private_state_permissions_unsupported`); macOS did not check.
53
+ const fx_dir = try std.fs.path.join(arena, &.{ home, ".fx" });
54
+ _ = try Io.Dir.cwd().createDirPathStatus(io, fx_dir, .fromMode(0o700));
55
+ var dir = try Io.Dir.openDirAbsolute(io, fx_dir, .{});
56
+ defer dir.close(io);
57
+ try dir.writeFile(io, .{ .sub_path = "settings.json", .data = settings, .flags = .{ .permissions = .fromMode(0o600) } });
58
+ }
59
+
60
+ test "the worker HOME withholds every skill root and links the rest" {
61
+ const io = std.testing.io;
62
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
63
+ defer arena_state.deinit();
64
+ const arena = arena_state.allocator();
65
+ var tmp = std.testing.tmpDir(.{});
66
+ defer tmp.cleanup();
67
+ var root_buf: [4096]u8 = undefined;
68
+ const root = root_buf[0..try tmp.dir.realPath(io, &root_buf)];
69
+ for ([_][]const u8{ "real/.fx", "real/.claude", "real/.codex", "real/.agents", "real/.npm", "real/.config/opencode", "real/.config/git", "home" }) |sub| {
70
+ try tmp.dir.createDirPath(io, sub);
71
+ }
72
+ const real = try std.fs.path.join(arena, &.{ root, "real" });
73
+ const home = try std.fs.path.join(arena, &.{ root, "home" });
74
+ try prepare(arena, io, real, home, "{\"provider\":\"faberun\"}\n");
75
+ for ([_][]const u8{ "home/.claude", "home/.codex", "home/.agents", "home/.config/opencode" }) |sub| {
76
+ try std.testing.expectError(error.FileNotFound, tmp.dir.access(io, sub, .{}));
77
+ }
78
+ var link_buf: [4096]u8 = undefined;
79
+ const npm = link_buf[0..try tmp.dir.readLink(io, "home/.npm", &link_buf)];
80
+ try std.testing.expectEqualStrings(try std.fs.path.join(arena, &.{ real, ".npm" }), npm);
81
+ const git = link_buf[0..try tmp.dir.readLink(io, "home/.config/git", &link_buf)];
82
+ try std.testing.expectEqualStrings(try std.fs.path.join(arena, &.{ real, ".config", "git" }), git);
83
+ const settings = try tmp.dir.readFileAlloc(io, "home/.fx/settings.json", arena, .unlimited);
84
+ try std.testing.expectEqualStrings("{\"provider\":\"faberun\"}\n", settings);
85
+ }