faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,509 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { getHarness, probeRuntime, registeredHarnesses, resolveVendor } from "./index.mjs";
3
+ import { DISCOVERY_RUNTIME_DEFINITIONS, composeAssignments } from "../engine/runtime-discovery.mjs";
4
+ import { errorMessage } from "../util.mjs";
5
+
6
+ /**
7
+ * Model catalogue report: which models each registered harness can run, the
8
+ * effort levels each accepts, and the worker/judge allocation the discovery
9
+ * law already implies.
10
+ *
11
+ * Only `agy` exposes a catalogue of its own — `agy models`, measured
12
+ * 2026-09-11: one `id<TAB>display name` line per model on stdout, a progress
13
+ * line on stderr, exit 0, no flags — so the report runs it when that binary is
14
+ * present and falls back to the declared entries when it is not. Every other
15
+ * harness's catalogue is declared below. No catalogue here is fetched from a
16
+ * network service of our own: `agy models` is the provider CLI's own surface,
17
+ * and no invocation spends tokens.
18
+ *
19
+ * Determinism: harness order is the canonical constant below, never object or
20
+ * Map iteration order (the registry decides membership only); declared model
21
+ * arrays are canonical; `agy models` output is sorted by id before rendering.
22
+ * No clock and no locale reaches the default report; `--probe` is the one
23
+ * opt-in that reads the host.
24
+ */
25
+
26
+ /** Display order of the registered harnesses: claude, codex, agy, dsh, zcode, exec-jsonl, replay. */
27
+ export const MODEL_HARNESS_ORDER = Object.freeze([
28
+ "claude",
29
+ "codex",
30
+ "agy",
31
+ "dsh",
32
+ "zcode",
33
+ "exec-jsonl",
34
+ "replay",
35
+ ]);
36
+
37
+ const CLAUDE_EFFORTS = Object.freeze(["low", "medium", "high", "max"]);
38
+ const CODEX_EFFORTS = Object.freeze(["low", "medium", "high", "xhigh"]);
39
+ const AGY_EFFORTS = Object.freeze(["low", "medium", "high"]);
40
+ /** agy.mjs collapses both of these onto `high` before `--effort` is built. */
41
+ const AGY_EFFORT_ALIASES = Object.freeze({ max: "high", xhigh: "high" });
42
+ const DSH_EFFORTS = Object.freeze(["off", "low", "high", "max"]);
43
+ /** @type {readonly string[]} */
44
+ const NO_EFFORTS = Object.freeze([]);
45
+
46
+ /** Measured: the harness accepts off/low/high/max and defaults to high; 1M tokens is its default window. */
47
+ const DSH_CONTEXT_WINDOW_TOKENS = 1_000_000;
48
+ /** The two GLM tiers Z.ai serves: the flash model's 200,000-token window and the 5.3 model's 1,048,576. */
49
+ const GLM_CONTEXT_WINDOW_TOKENS = 200_000;
50
+ const GLM_ONE_MILLION_CONTEXT_WINDOW_TOKENS = 1_048_576;
51
+
52
+ /**
53
+ * @typedef {{id: string, contextWindowTokens: number|null, efforts: readonly string[], defaultEffort: string|null, effortInModelId: string|null}} DeclaredModel
54
+ * @typedef {{harness: string, vendor: string|null}} ModelPath
55
+ * @typedef {{id: string, contextWindowTokens: number|null, efforts: string[], defaultEffort: string|null, effortInModelId: string|null, declaredBy: ModelPath[]}} ModelView
56
+ * @typedef {{ok: boolean, available: boolean, reason: string, version: string|null}} ProbeView
57
+ * @typedef {{harness: string, executable: string, vendor: string|null, vendorNote: string|null, catalogue: string, effortTransport: string|null, effortAliases: Record<string, string>, effortNotes: string[], models: ModelView[], probe?: ProbeView}} HarnessView
58
+ * @typedef {{id: string, harness: string, model: string, vendor: string, tier: number|string|null, costRank: number|null}} AllocationRuntime
59
+ * @typedef {{worker: AllocationRuntime, judge: AllocationRuntime, vendorException: string|null, reason: string}} AllocationSuggestion
60
+ * @typedef {{schemaVersion: number, availability: string, harnesses: HarnessView[], suggestion: AllocationSuggestion}} ModelsReport
61
+ */
62
+
63
+ /**
64
+ * Repository-declared catalogues. `exec-jsonl` and `replay` stand in for an
65
+ * arbitrary executable and a recording, so both name no models at all: the
66
+ * runtime declaration supplies the model id.
67
+ *
68
+ * @type {Readonly<Record<string, readonly DeclaredModel[]>>}
69
+ */
70
+ export const DECLARED_MODEL_CATALOGUES = Object.freeze({
71
+ claude: Object.freeze([
72
+ declaredModel("claude-sonnet-5", { efforts: CLAUDE_EFFORTS }),
73
+ declaredModel("claude-opus-5", { efforts: CLAUDE_EFFORTS }),
74
+ declaredModel("claude-sonnet-4-6", { efforts: CLAUDE_EFFORTS }),
75
+ ]),
76
+ codex: Object.freeze([
77
+ declaredModel("gpt-5.6", { efforts: CODEX_EFFORTS }),
78
+ declaredModel("gpt-5.6-luna", { efforts: CODEX_EFFORTS }),
79
+ declaredModel("gpt-5.6-sol", { efforts: CODEX_EFFORTS }),
80
+ ]),
81
+ agy: Object.freeze([
82
+ agyModel("gemini-3.8-flash-high"),
83
+ agyModel("gemini-3.8-flash-medium"),
84
+ agyModel("gemini-3.8-flash-low"),
85
+ agyModel("claude-sonnet-4-6"),
86
+ agyModel("claude-opus-4-6-thinking"),
87
+ agyModel("gpt-oss-120b-medium"),
88
+ ]),
89
+ dsh: Object.freeze([
90
+ declaredModel("deepseek-flash", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: DSH_EFFORTS, defaultEffort: "high" }),
91
+ declaredModel("deepseek-v4-flash", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: DSH_EFFORTS, defaultEffort: "high" }),
92
+ declaredModel("deepseek-v4-pro", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: DSH_EFFORTS, defaultEffort: "high" }),
93
+ declaredModel("deepseek-v4-flash-vision-exp", { contextWindowTokens: DSH_CONTEXT_WINDOW_TOKENS, efforts: DSH_EFFORTS, defaultEffort: "high" }),
94
+ ]),
95
+ zcode: Object.freeze([
96
+ declaredModel("glm-5.3-flash", { contextWindowTokens: GLM_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
97
+ // No `[1m]` row: the suffix is a Claude Code convention the ZCode CLI does
98
+ // not know (zcode.mjs strips it before building `ZCODE_MODEL`), so a second
99
+ // row would name the very same invocation. The CLI resolves the window from
100
+ // the provider, which projected 1M for this model.
101
+ declaredModel("glm-5.3", { contextWindowTokens: GLM_ONE_MILLION_CONTEXT_WINDOW_TOKENS, efforts: NO_EFFORTS }),
102
+ ]),
103
+ "exec-jsonl": Object.freeze([]),
104
+ replay: Object.freeze([]),
105
+ });
106
+
107
+ /** @type {Readonly<Record<string, string>>} */
108
+ const CATALOGUE_SOURCES = Object.freeze({
109
+ claude: "declared",
110
+ codex: "declared",
111
+ dsh: "declared",
112
+ zcode: "declared",
113
+ "exec-jsonl": "runtime-declared",
114
+ replay: "runtime-declared",
115
+ });
116
+
117
+ /**
118
+ * The field each adapter turns `runtime.reasoning` into.
119
+ *
120
+ * @type {Readonly<Record<string, string|null>>}
121
+ */
122
+ const EFFORT_TRANSPORT = Object.freeze({
123
+ claude: "--effort",
124
+ codex: "config.model_reasoning_effort",
125
+ agy: "--effort",
126
+ dsh: "--reasoning (harness reasoningEffort)",
127
+ zcode: null,
128
+ "exec-jsonl": null,
129
+ replay: null,
130
+ });
131
+
132
+ /**
133
+ * Why a harness resolves no vendor: dsh, exec-jsonl, and replay declare none.
134
+ *
135
+ * @type {Readonly<Record<string, string>>}
136
+ */
137
+ const VENDOR_NOTES = Object.freeze({
138
+ dsh: "unresolved: dsh declares no default vendor; the contract names it",
139
+ "exec-jsonl": "unresolved: exec-jsonl declares no default vendor; the contract names it",
140
+ replay: "unresolved: replay declares no default vendor; the contract names it",
141
+ });
142
+
143
+ const AGY_CATALOGUE_TIMEOUT_MS = 10_000;
144
+
145
+ const AVAILABILITY_NOTES = Object.freeze({
146
+ declared: "declared catalogue only; live availability is `doctor`'s report (`--probe` adds per-runtime reachability)",
147
+ probe: "probed per runtime (executable reachability only); `doctor` remains authoritative",
148
+ });
149
+
150
+ const ALLOCATION_LAW = "cheapest declared runtime by tier then costRank executes; the strongest runtime with a different resolved vendor judges";
151
+
152
+ /**
153
+ * @param {string} id
154
+ * @param {{contextWindowTokens?: number, efforts: readonly string[], defaultEffort?: string, effortInModelId?: string}} options
155
+ * @returns {DeclaredModel}
156
+ */
157
+ function declaredModel(id, options) {
158
+ return Object.freeze({
159
+ id,
160
+ contextWindowTokens: options.contextWindowTokens ?? null,
161
+ efforts: options.efforts,
162
+ defaultEffort: options.defaultEffort ?? null,
163
+ effortInModelId: options.effortInModelId ?? null,
164
+ });
165
+ }
166
+
167
+ /**
168
+ * An agy model: the adapter's `--effort` vocabulary applies to every id, and
169
+ * an id ending in -low/-medium/-high fixes that level itself, which is the
170
+ * double specification the report has to show.
171
+ *
172
+ * @param {string} id
173
+ * @param {number|null} [contextWindowTokens]
174
+ * @returns {DeclaredModel}
175
+ */
176
+ function agyModel(id, contextWindowTokens = null) {
177
+ const encoded = /-(low|medium|high)$/u.exec(id)?.[1] ?? null;
178
+ return Object.freeze({
179
+ id,
180
+ contextWindowTokens,
181
+ efforts: AGY_EFFORTS,
182
+ defaultEffort: encoded,
183
+ effortInModelId: encoded,
184
+ });
185
+ }
186
+
187
+ /**
188
+ * @param {{probe?: boolean, cwd?: string}} [options]
189
+ * @returns {Promise<ModelsReport>}
190
+ */
191
+ export async function modelsReport(options = {}) {
192
+ const cliCatalogue = agyCliCatalogue(options.cwd);
193
+ const declaredPaths = declaredPathIndex();
194
+ /** @type {HarnessView[]} */
195
+ const harnesses = [];
196
+ for (const harness of displayOrder()) {
197
+ const declared = DECLARED_MODEL_CATALOGUES[harness] ?? [];
198
+ const entries = harness === "agy" ? cliCatalogue ?? declared : declared;
199
+ const template = { harness, model: entries[0]?.id ?? "runtime-defined" };
200
+ /** @type {HarnessView} */
201
+ const view = {
202
+ harness,
203
+ executable: getHarness(harness).executable(template),
204
+ vendor: resolveVendor(template),
205
+ vendorNote: VENDOR_NOTES[harness] ?? null,
206
+ catalogue: harness === "agy" ? (cliCatalogue ? "agy-cli" : "declared") : CATALOGUE_SOURCES[harness] ?? "runtime-declared",
207
+ effortTransport: EFFORT_TRANSPORT[harness] ?? null,
208
+ effortAliases: harness === "agy" ? { ...AGY_EFFORT_ALIASES } : {},
209
+ effortNotes: harness === "agy"
210
+ ? ["model ids ending in -low/-medium/-high fix the level themselves, and the adapter still passes --effort on top (double specification)"]
211
+ : [],
212
+ models: entries.map((entry) => modelView(harness, entry, declaredPaths)),
213
+ };
214
+ if (options.probe === true) view.probe = probeView(await probeRuntime(template, { cwd: options.cwd }));
215
+ harnesses.push(view);
216
+ }
217
+ return {
218
+ schemaVersion: 1,
219
+ availability: AVAILABILITY_NOTES[options.probe === true ? "probe" : "declared"],
220
+ harnesses,
221
+ suggestion: suggestedAllocation(),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * @param {{json?: boolean, probe?: boolean, cwd?: string}} [options]
227
+ * @returns {Promise<void>}
228
+ */
229
+ export async function modelsCommand(options = {}) {
230
+ const report = await modelsReport(options);
231
+ process.stdout.write(options.json === true ? stableJsonDocument(report) : renderModelsReport(report));
232
+ }
233
+
234
+ /**
235
+ * @param {string} harness
236
+ * @param {DeclaredModel} entry
237
+ * @param {Map<string, ModelPath[]>} declaredPaths
238
+ * @returns {ModelView}
239
+ */
240
+ function modelView(harness, entry, declaredPaths) {
241
+ return {
242
+ id: entry.id,
243
+ contextWindowTokens: entry.contextWindowTokens,
244
+ efforts: [...entry.efforts],
245
+ defaultEffort: entry.defaultEffort,
246
+ effortInModelId: entry.effortInModelId,
247
+ declaredBy: (declaredPaths.get(entry.id) ?? []).filter((path) => path.harness !== harness),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Every declared model id, with the harnesses that declare it and the vendor
253
+ * each of those paths resolves to. Two paths can serve one model under
254
+ * different resolved vendors (agy resells claude-sonnet-4-6, which the claude
255
+ * harness also declares), and the allocation law compares those vendors.
256
+ *
257
+ * @returns {Map<string, ModelPath[]>}
258
+ */
259
+ function declaredPathIndex() {
260
+ /** @type {Map<string, ModelPath[]>} */
261
+ const paths = new Map();
262
+ for (const harness of displayOrder()) {
263
+ const vendor = resolveVendor({ harness });
264
+ for (const entry of DECLARED_MODEL_CATALOGUES[harness] ?? []) {
265
+ const existing = paths.get(entry.id);
266
+ if (existing) existing.push({ harness, vendor });
267
+ else paths.set(entry.id, [{ harness, vendor }]);
268
+ }
269
+ }
270
+ return paths;
271
+ }
272
+
273
+ /**
274
+ * Registered harnesses in canonical display order. The registry supplies
275
+ * membership; a registered harness missing from the canonical constant is
276
+ * appended in codepoint order so the report still covers every adapter.
277
+ *
278
+ * @returns {string[]}
279
+ */
280
+ function displayOrder() {
281
+ const registered = new Set(registeredHarnesses());
282
+ const extra = [...registered].filter((harness) => !MODEL_HARNESS_ORDER.includes(harness)).sort(codepointOrder);
283
+ return [...MODEL_HARNESS_ORDER.filter((harness) => registered.has(harness)), ...extra];
284
+ }
285
+
286
+ /**
287
+ * The provider CLI's own catalogue. An absent binary, a non-zero exit, or an
288
+ * unparsable listing falls back to the declared entries.
289
+ *
290
+ * @param {string|undefined} cwd
291
+ * @returns {DeclaredModel[]|null}
292
+ */
293
+ function agyCliCatalogue(cwd) {
294
+ const executable = getHarness("agy").executable({ harness: "agy", model: "agy-models" });
295
+ const result = spawnSync(executable, ["models"], {
296
+ cwd,
297
+ encoding: "utf8",
298
+ stdio: ["ignore", "pipe", "ignore"],
299
+ timeout: AGY_CATALOGUE_TIMEOUT_MS,
300
+ });
301
+ if (result.error || result.status !== 0) return null;
302
+ const models = parseAgyModels(String(result.stdout ?? ""));
303
+ return models.length ? models : null;
304
+ }
305
+
306
+ /**
307
+ * `agy models` prints one model per line as `id<TAB>display name`; the
308
+ * progress line and anything else without a plausible id is ignored, and the
309
+ * surviving ids are sorted so the CLI's own listing order never reaches the
310
+ * report.
311
+ *
312
+ * @param {string} stdout
313
+ * @returns {DeclaredModel[]}
314
+ */
315
+ export function parseAgyModels(stdout) {
316
+ /** @type {Set<string>} */
317
+ const ids = new Set();
318
+ for (const line of String(stdout).replace(/\[[0-9;]*m/gu, "").split(/\r?\n/u)) {
319
+ const candidate = line.split("\t")[0].trim();
320
+ if (!/^[a-z0-9][a-z0-9._[\]-]*$/iu.test(candidate)) continue;
321
+ ids.add(candidate);
322
+ }
323
+ return [...ids].sort(codepointOrder).map((id) => agyModel(id));
324
+ }
325
+
326
+ /**
327
+ * @param {import("./index.mjs").ProbeResult} result
328
+ * @returns {ProbeView}
329
+ */
330
+ function probeView(result) {
331
+ return {
332
+ ok: result.ok,
333
+ available: result.availability?.available === true,
334
+ reason: result.availability?.reason ?? "provider_unavailable",
335
+ version: result.version,
336
+ };
337
+ }
338
+
339
+ /**
340
+ * The same law `validatedContract` applies when a contract omits `runtimes`:
341
+ * `contract.mjs` feeds `DISCOVERY_RUNTIME_DEFINITIONS` to `composeAssignments`
342
+ * in its declared order, so the same object and the same function are reused
343
+ * here with every runtime declared available. No availability probe feeds the
344
+ * suggestion — live reachability stays `doctor`'s and `--probe`'s business.
345
+ *
346
+ * @returns {AllocationSuggestion}
347
+ */
348
+ function suggestedAllocation() {
349
+ /** @type {Record<string, {harness: string, model: string, vendor: string, tier: number|string, costRank: number}>} */
350
+ const runtimes = {};
351
+ /** @type {Record<string, {available: boolean, exhaustedUntil: string|null, reason: string}>} */
352
+ const availability = {};
353
+ for (const [id, definition] of Object.entries(DISCOVERY_RUNTIME_DEFINITIONS)) {
354
+ const vendor = resolveVendor(definition);
355
+ if (!vendor) throw new Error(`discovery runtime ${id} resolves no vendor`);
356
+ runtimes[id] = { ...definition, vendor };
357
+ availability[id] = { available: true, exhaustedUntil: null, reason: "declared" };
358
+ }
359
+ /** @param {boolean} enabled */
360
+ const node = (enabled) => ({ id: "models", gate: { enabled } });
361
+ try {
362
+ const assignment = composeAssignments({ runtimes, runtimeDefaults: {}, nodes: [node(true)] }, availability).models;
363
+ return allocation(assignment.worker, assignment.judge, null, ALLOCATION_LAW, runtimes);
364
+ } catch (error) {
365
+ // No cross-vendor judge is admissible among the declared discovery
366
+ // runtimes: name the runtime the same law still picks for both roles, and
367
+ // say out loud that the pair is a declared same-vendor exception.
368
+ const assignment = composeAssignments({ runtimes, runtimeDefaults: {}, nodes: [node(false)] }, availability).models;
369
+ const vendor = runtimes[assignment.worker]?.vendor ?? "unresolved";
370
+ return allocation(
371
+ assignment.worker,
372
+ assignment.judge,
373
+ vendor,
374
+ `declared exception (${errorMessage(error)}): the worker runtime also judges`,
375
+ runtimes,
376
+ );
377
+ }
378
+ }
379
+
380
+ /**
381
+ * @param {string} worker
382
+ * @param {string} judge
383
+ * @param {string|null} vendorException
384
+ * @param {string} reason
385
+ * @param {Record<string, {harness: string, model: string, vendor: string, tier: number|string, costRank: number}>} runtimes
386
+ * @returns {AllocationSuggestion}
387
+ */
388
+ function allocation(worker, judge, vendorException, reason, runtimes) {
389
+ return {
390
+ worker: allocationRuntime(worker, runtimes),
391
+ judge: allocationRuntime(judge, runtimes),
392
+ vendorException,
393
+ reason,
394
+ };
395
+ }
396
+
397
+ /**
398
+ * @param {string} id
399
+ * @param {Record<string, {harness: string, model: string, vendor: string, tier: number|string, costRank: number}>} runtimes
400
+ * @returns {AllocationRuntime}
401
+ */
402
+ function allocationRuntime(id, runtimes) {
403
+ const runtime = runtimes[id];
404
+ if (!runtime) throw new Error(`allocation named an unknown runtime ${id}`);
405
+ return {
406
+ id,
407
+ harness: runtime.harness,
408
+ model: runtime.model,
409
+ vendor: runtime.vendor,
410
+ tier: runtime.tier ?? null,
411
+ costRank: runtime.costRank ?? null,
412
+ };
413
+ }
414
+
415
+ /**
416
+ * @param {ModelsReport} report
417
+ * @returns {string}
418
+ */
419
+ export function renderModelsReport(report) {
420
+ const lines = [`models · availability: ${report.availability}`, ""];
421
+ for (const harness of report.harnesses) {
422
+ const vendor = harness.vendor ?? harness.vendorNote ?? "unresolved";
423
+ lines.push(`[${harness.harness}] executable ${harness.executable} · vendor ${vendor} · catalogue ${harness.catalogue}`);
424
+ lines.push(` effort: ${effortTransportLine(harness)}`);
425
+ for (const model of harness.models) lines.push(` ${modelLine(harness, model)}`);
426
+ if (!harness.models.length) lines.push(" models: none declared — the runtime declaration names the model");
427
+ if (harness.probe) lines.push(` probe: ${harness.probe.available ? `reachable${harness.probe.version ? ` (${harness.probe.version})` : ""}` : `unreachable (${harness.probe.reason})`}`);
428
+ lines.push("");
429
+ }
430
+ lines.push("suggested allocation");
431
+ lines.push(` worker: ${allocationLine(report.suggestion.worker)}`);
432
+ lines.push(` judge: ${allocationLine(report.suggestion.judge)}`);
433
+ lines.push(` reason: ${report.suggestion.reason}`);
434
+ lines.push(report.suggestion.vendorException
435
+ ? ` cross-vendor: worker and judge resolve to ${report.suggestion.vendorException} — declared exception`
436
+ : ` cross-vendor: worker vendor ${report.suggestion.worker.vendor} differs from judge vendor ${report.suggestion.judge.vendor}`);
437
+ return `${lines.join("\n").trimEnd()}\n`;
438
+ }
439
+
440
+ /**
441
+ * @param {HarnessView} harness
442
+ * @returns {string}
443
+ */
444
+ function effortTransportLine(harness) {
445
+ if (harness.effortTransport === null) return "none (this harness has no effort flag)";
446
+ const aliases = Object.keys(harness.effortAliases).sort(codepointOrder);
447
+ const aliasText = aliases.length ? ` (${aliases.map((key) => `${key}→${harness.effortAliases[key]}`).join(", ")})` : "";
448
+ const notes = harness.effortNotes.length ? ` · ${harness.effortNotes.join(" · ")}` : "";
449
+ return `${harness.effortTransport}${aliasText}${notes}`;
450
+ }
451
+
452
+ /**
453
+ * @param {HarnessView} harness
454
+ * @param {ModelView} model
455
+ * @returns {string}
456
+ */
457
+ function modelLine(harness, model) {
458
+ const parts = [model.id, `context ${model.contextWindowTokens ?? "unknown"}`, effortLine(model)];
459
+ for (const path of model.declaredBy) parts.push(`also declared by ${path.harness} (vendor ${path.vendor ?? "unresolved"})`);
460
+ if (model.effortInModelId && harness.effortTransport !== null) {
461
+ parts.push(`${harness.effortTransport} is passed on top of the id-encoded level`);
462
+ }
463
+ return parts.join(" · ");
464
+ }
465
+
466
+ /**
467
+ * @param {ModelView} model
468
+ * @returns {string}
469
+ */
470
+ function effortLine(model) {
471
+ if (!model.efforts.length) return "effort none";
472
+ const levels = model.efforts.map((effort) => (effort === model.defaultEffort ? `${effort} (default)` : effort)).join(", ");
473
+ if (model.defaultEffort) return `effort ${levels}${model.effortInModelId ? ", fixed by the model id" : ""}`;
474
+ return `effort ${levels} · default: the harness decides`;
475
+ }
476
+
477
+ /**
478
+ * @param {AllocationRuntime} runtime
479
+ * @returns {string}
480
+ */
481
+ function allocationLine(runtime) {
482
+ const tier = runtime.tier === null ? "" : ` · tier ${runtime.tier}`;
483
+ const costRank = runtime.costRank === null ? "" : ` · costRank ${runtime.costRank}`;
484
+ return `${runtime.id} · harness ${runtime.harness} · model ${runtime.model} · vendor ${runtime.vendor}${tier}${costRank}`;
485
+ }
486
+
487
+ /** @param {string} left @param {string} right @returns {number} */
488
+ function codepointOrder(left, right) {
489
+ return left < right ? -1 : left > right ? 1 : 0;
490
+ }
491
+
492
+ /**
493
+ * JSON with every object's keys in codepoint order, so the same report always
494
+ * serializes to the same bytes.
495
+ *
496
+ * @param {unknown} value
497
+ * @returns {string}
498
+ */
499
+ export function stableJsonDocument(value) {
500
+ return `${JSON.stringify(sortedKeys(value), null, 2)}\n`;
501
+ }
502
+
503
+ /** @param {unknown} value @returns {unknown} */
504
+ function sortedKeys(value) {
505
+ if (Array.isArray(value)) return value.map(sortedKeys);
506
+ if (!value || typeof value !== "object") return value;
507
+ const record = /** @type {Record<string, unknown>} */ (value);
508
+ return Object.fromEntries(Object.keys(record).sort(codepointOrder).map((key) => [key, sortedKeys(record[key])]));
509
+ }
@@ -0,0 +1,90 @@
1
+ import { normalizeClaudeResult, parseVersion } from "../protocol.mjs";
2
+ import { hookSettings } from "../../host/tool-policy-hook.mjs";
3
+
4
+ /** Built-in tools a closed-packet worker needs; every other tool is preamble. */
5
+ export const DEFAULT_CLAUDE_TOOLS = ["Read", "Edit", "Write", "Bash", "Glob", "Grep"];
6
+
7
+ /**
8
+ * Bound the harness preamble of a Claude-compatible CLI: no skills, no MCP
9
+ * servers, no settings files (an explicit `--settings` still applies, so hook
10
+ * enforcement survives) and only the declared built-in tools. Measured on
11
+ * 2026-09-01 against this CLI: 65,170 uncached input tokens per trivial call
12
+ * with the ambient configuration, about 4,300 per turn with these flags.
13
+ * `--bare` would cut further but disables hooks, so it is never used.
14
+ *
15
+ * @param {import("../index.mjs").HarnessRuntime} runtime
16
+ * @returns {string[]}
17
+ */
18
+ function claudePreambleArgs(runtime) {
19
+ return [
20
+ "--disable-slash-commands",
21
+ "--strict-mcp-config",
22
+ "--setting-sources",
23
+ "",
24
+ "--tools",
25
+ (runtime.tools ?? DEFAULT_CLAUDE_TOOLS).join(","),
26
+ ];
27
+ }
28
+
29
+ /**
30
+ * @type {import("../index.mjs").HarnessAdapter}
31
+ */
32
+ export const claudeHarness = {
33
+ capabilities: {
34
+ structuredOutput: true,
35
+ promptTransport: "stdin",
36
+ sandbox: false,
37
+ permissions: true,
38
+ continuation: true,
39
+ tokenBudget: false,
40
+ costBudget: true,
41
+ usage: true,
42
+ cost: true,
43
+ // The Claude-compatible hook surface enforces the tool policy mechanically.
44
+ toolPolicy: true,
45
+ // `--output-format stream-json --verbose` writes one JSON line per event
46
+ // as the turn runs, not one dump at exit.
47
+ streamsOutput: true,
48
+ },
49
+
50
+ // Headless acceptEdits denies Bash; bypassPermissions executes commands.
51
+ permissionExecution: { field: "permissionMode", executingModes: ["bypassPermissions"], defaultMode: "acceptEdits" },
52
+
53
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
54
+ executable(runtime) {
55
+ return process.env.FABERUN_CLAUDE_BIN ?? runtime.executable ?? "claude";
56
+ },
57
+
58
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string[]} */
59
+ versionArgs(runtime) {
60
+ return runtime.versionArgs ?? ["--version"];
61
+ },
62
+
63
+ parseVersion,
64
+
65
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @param {string} prompt @param {import("../index.mjs").CommandOptions} options @returns {import("../index.mjs").HarnessCommand} */
66
+ command(runtime, prompt, options) {
67
+ const continuationId = options.continuationId ?? null;
68
+ const args = [
69
+ "-p",
70
+ ...(continuationId ? ["--resume", continuationId] : []),
71
+ "--model",
72
+ runtime.model,
73
+ "--output-format",
74
+ "stream-json",
75
+ "--verbose",
76
+ "--permission-mode",
77
+ runtime.permissionMode ?? "acceptEdits",
78
+ ...claudePreambleArgs(runtime),
79
+ ];
80
+ if (options.toolPolicy) args.push("--settings", JSON.stringify(hookSettings(options.toolPolicy)));
81
+ if (runtime.reasoning) args.push("--effort", runtime.reasoning);
82
+ if (options.schema) args.push("--json-schema", JSON.stringify(options.schema));
83
+ return { executable: this.executable(runtime), args, promptTransport: "stdin", input: prompt };
84
+ },
85
+
86
+ normalize: normalizeClaudeResult,
87
+ };
88
+
89
+ export const harness = claudeHarness;
90
+ export default claudeHarness;
@@ -0,0 +1,87 @@
1
+ import { normalizeCodexResult, parseVersion, toml } from "../protocol.mjs";
2
+
3
+ /**
4
+ * Bound the Codex harness preamble: a closed-packet worker or a read-only judge
5
+ * needs the shell and patch tools, not browser, computer-use, app or sub-agent
6
+ * tooling, MCP servers, or plugins. `features.code_mode_host` stays enabled:
7
+ * codex-cli 0.152.1 only surfaces commands to OpenAI models through the
8
+ * code-mode host, and without it they make zero tool calls and fabricate
9
+ * answers. Measured on the Sol gate: gpt-5.6-sol with the host disabled used
10
+ * 35,130 input tokens and fabricated its verdict, versus 35,199 with the host
11
+ * enabled and a correct tool-backed verdict; deepseek-v4-flash used 25,795
12
+ * with the host disabled and 25,783 with the host enabled, both correct.
13
+ * These overrides are emitted before the runtime's own `config` entries, so a
14
+ * contract can re-enable any of them.
15
+ */
16
+ export const CODEX_PREAMBLE_OVERRIDES = Object.freeze([
17
+ "features.browser_use=false",
18
+ "features.browser_use_external=false",
19
+ "features.computer_use=false",
20
+ "features.apps=false",
21
+ "features.multi_agent=false",
22
+ "mcp_servers={}",
23
+ "plugins={}",
24
+ ]);
25
+
26
+ /**
27
+ * @type {import("../index.mjs").HarnessAdapter}
28
+ */
29
+ export const codexHarness = {
30
+ capabilities: {
31
+ structuredOutput: true,
32
+ promptTransport: "stdin",
33
+ sandbox: true,
34
+ permissions: false,
35
+ continuation: true,
36
+ tokenBudget: true,
37
+ costBudget: false,
38
+ usage: true,
39
+ cost: false,
40
+ toolPolicy: false,
41
+ // `--json` writes one JSONL event per item/turn as it happens, not one
42
+ // dump at exit.
43
+ streamsOutput: true,
44
+ },
45
+
46
+ // Every sandbox mode executes commands; sandbox only bounds their effects.
47
+ permissionExecution: {
48
+ field: "sandbox",
49
+ executingModes: ["read-only", "workspace-write", "danger-full-access"],
50
+ defaultMode: "workspace-write",
51
+ },
52
+
53
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
54
+ executable(runtime) {
55
+ return process.env.FABERUN_CODEX_BIN ?? runtime.executable ?? "codex";
56
+ },
57
+
58
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string[]} */
59
+ versionArgs(runtime) {
60
+ return runtime.versionArgs ?? ["--version"];
61
+ },
62
+
63
+ parseVersion,
64
+
65
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @param {string} prompt @param {import("../index.mjs").CommandOptions} options @returns {import("../index.mjs").HarnessCommand} */
66
+ command(runtime, prompt, options) {
67
+ const continuationId = options.continuationId ?? null;
68
+ const args = continuationId
69
+ ? ["exec", "resume", "--json"]
70
+ : ["exec"];
71
+ if (!continuationId) args.push("--json", "--sandbox", runtime.sandbox ?? "workspace-write");
72
+ for (const override of CODEX_PREAMBLE_OVERRIDES) args.push("-c", override);
73
+ for (const [key, value] of Object.entries(runtime.config ?? {})) {
74
+ args.push("-c", `${key}=${toml(value)}`);
75
+ }
76
+ args.push("-c", `model=${toml(runtime.model)}`);
77
+ if (runtime.reasoning) args.push("-c", `model_reasoning_effort=${toml(runtime.reasoning)}`);
78
+ if (options.schemaPath) args.push("--output-schema", options.schemaPath);
79
+ if (continuationId) return { executable: this.executable(runtime), args: [...args, continuationId, prompt], promptTransport: "argv", input: null };
80
+ return { executable: this.executable(runtime), args, promptTransport: "stdin", input: prompt };
81
+ },
82
+
83
+ normalize: normalizeCodexResult,
84
+ };
85
+
86
+ export const harness = codexHarness;
87
+ export default codexHarness;