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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { modelsCommand } from "./harnesses/catalogue.mjs";
|
|
11
|
+
import { bulkReadCommand } from "./engine/bulk-read.mjs";
|
|
12
|
+
import { doctorCommand, environmentPreflight, findExecutable, notifyTransportCheck, reachableRuntimes, timeVerificationCommands } from "./host/preflight.mjs";
|
|
13
|
+
import { packageName, packageVersion } from "./host/package.mjs";
|
|
14
|
+
import { colorLevel, renderBanner, renderUsage, statusToken } from "./cli/brand.mjs";
|
|
15
|
+
import { noTransportWarning } from "./notify/index.mjs";
|
|
16
|
+
import { renderFindings, renderReport, renderReportJson, renderStatus, renderStatusJson } from "./report/render.mjs";
|
|
17
|
+
import { renderNext, renderNextJson } from "./report/next.mjs";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
writeTextAtomic,
|
|
21
|
+
} from "./run/store.mjs";
|
|
22
|
+
import {
|
|
23
|
+
acquire as acquireLock,
|
|
24
|
+
validBootstrapNonce,
|
|
25
|
+
} from "./run/lock.mjs";
|
|
26
|
+
import { renderRunHandoff } from "./campaign/index.mjs";
|
|
27
|
+
import { campaignCli } from "./cli/campaign.mjs";
|
|
28
|
+
import { seatCli } from "./cli/seat.mjs";
|
|
29
|
+
import { initCommand } from "./cli/init.mjs";
|
|
30
|
+
import { setupCommand } from "./cli/setup.mjs";
|
|
31
|
+
import { skillsCli } from "./cli/skills.mjs";
|
|
32
|
+
import { updateCommand } from "./cli/update.mjs";
|
|
33
|
+
import { contractCli, validateContractFile } from "./cli/contract.mjs";
|
|
34
|
+
import { METRICS_OPTIONS, renderCampaignMetrics } from "./campaign/metrics.mjs";
|
|
35
|
+
import { runContract } from "./engine/scheduler.mjs";
|
|
36
|
+
import { resumeRun } from "./engine/resume.mjs";
|
|
37
|
+
import { cancelRun } from "./engine/cancel.mjs";
|
|
38
|
+
|
|
39
|
+
import { errorMessage } from "./util.mjs";
|
|
40
|
+
import { validateContract } from "./contract/index.mjs";
|
|
41
|
+
import { setLaunchBaseRef } from "./engine/run-identity.mjs";
|
|
42
|
+
import { assertLaunchBaseClean } from "./repo/source-identity.mjs";
|
|
43
|
+
import { detachSelf, waitForBootstrap, writeBootstrapFailure } from "./cli/launch.mjs";
|
|
44
|
+
import { DEFAULT_SUPERVISE_INTERVAL_SEC, superviseRun } from "./engine/supervise.mjs";
|
|
45
|
+
import { preflightContract, reusedDoneWarnings } from "./engine/live-preflight.mjs";
|
|
46
|
+
|
|
47
|
+
/** @typedef {import("./contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
48
|
+
/** @typedef {import("./contract/index.mjs").ValidatedNode} ValidatedNode */
|
|
49
|
+
/** @typedef {import("./contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
50
|
+
/** @typedef {import("./contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
|
|
51
|
+
/** @typedef {import("./contract/index.mjs").RunMetadata} RunMetadata */
|
|
52
|
+
/** @typedef {import("./contract/index.mjs").SourceIdentity} SourceIdentity */
|
|
53
|
+
/** @typedef {import("./contract/index.mjs").EventRecord} EventRecord */
|
|
54
|
+
/** @typedef {import("./contract/index.mjs").Usage} Usage */
|
|
55
|
+
/** @typedef {import("./contract/index.mjs").GateResult} GateResult */
|
|
56
|
+
/** @typedef {import("./contract/index.mjs").SnapshotError} SnapshotError */
|
|
57
|
+
/** @typedef {import("./contract/index.mjs").BoundedScope} BoundedScope */
|
|
58
|
+
/** @typedef {import("./run/lock.mjs").LockRecord} LockRecord */
|
|
59
|
+
/** @typedef {ReturnType<typeof acquireLock>} LockHandle */
|
|
60
|
+
/** @typedef {import("./harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
|
|
61
|
+
/** @typedef {import("./harnesses/index.mjs").ProbeResult} ProbeResult */
|
|
62
|
+
/** @typedef {import("./harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
|
|
63
|
+
/** @typedef {import("./campaign/index.mjs").Campaign} Campaign */
|
|
64
|
+
/** @typedef {{path: string, campaign: Campaign}} CampaignRef */
|
|
65
|
+
/** @typedef {import("./engine/lifecycle.mjs").Job} Job */
|
|
66
|
+
/** @typedef {import("./engine/lifecycle.mjs").Invocation} Invocation */
|
|
67
|
+
/** @typedef {import("./engine/scheduler.mjs").RunOutcome} RunOutcome */
|
|
68
|
+
/** @typedef {import("node:child_process").ChildProcess & {bootstrapNonce?: string, bootstrapProcessStartToken?: string|null}} DetachedChild */
|
|
69
|
+
/** @typedef {{status?: string, nonce?: string, pid?: number, processStartToken?: string|null, holderId?: string, generation?: number, error?: unknown, runDir?: string}} BootstrapRecord */
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether this process is a detached bootstrap child of the CLI: it carries a
|
|
73
|
+
* launcher-issued nonce *and* this file is what was executed. The second half
|
|
74
|
+
* is why this stays here and not in `engine/detach.mjs` -- `evals/run.mjs` and
|
|
75
|
+
* the tests import `runContract` directly, and an inherited nonce must not make
|
|
76
|
+
* them wait for an acknowledgement nobody will write.
|
|
77
|
+
*
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function hasDetachedBootstrapNonce() {
|
|
81
|
+
if (!validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE)) return false;
|
|
82
|
+
try {
|
|
83
|
+
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
90
|
+
const COMMAND_OPTIONS = {
|
|
91
|
+
run: { detach: { type: "boolean" }, "base-ref": { type: "string" } },
|
|
92
|
+
resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" } },
|
|
93
|
+
supervise: { detach: { type: "boolean" }, interval: { type: "string" } },
|
|
94
|
+
cancel: {},
|
|
95
|
+
preflight: { static: { type: "boolean" }, json: { type: "boolean" }, "time-verification": { type: "boolean" } },
|
|
96
|
+
validate: {},
|
|
97
|
+
status: { json: { type: "boolean" } },
|
|
98
|
+
report: { json: { type: "boolean" } },
|
|
99
|
+
findings: {},
|
|
100
|
+
doctor: { cwd: { type: "string" }, json: { type: "boolean" }, discover: { type: "boolean" } },
|
|
101
|
+
models: { probe: { type: "boolean" }, json: { type: "boolean" } },
|
|
102
|
+
"bulk-read": { question: { type: "string" }, paths: { type: "string", multiple: true }, json: { type: "boolean" } },
|
|
103
|
+
next: { cwd: { type: "string" }, json: { type: "boolean" } },
|
|
104
|
+
update: { check: { type: "boolean" }, json: { type: "boolean" } },
|
|
105
|
+
setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, json: { type: "boolean" } },
|
|
106
|
+
init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
|
|
107
|
+
metrics: METRICS_OPTIONS,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Strict per-command parsing: unknown options, missing positionals, and extra
|
|
112
|
+
* positionals are rejected. Flags are scoped to the commands that declare them.
|
|
113
|
+
*
|
|
114
|
+
* @param {string[]} argv
|
|
115
|
+
* @param {boolean} [quiet]
|
|
116
|
+
* @returns {{command: string, target: string|undefined, values: Record<string, unknown>}|null}
|
|
117
|
+
*/
|
|
118
|
+
function parseCli(argv, quiet = false) {
|
|
119
|
+
const [command, ...rest] = argv;
|
|
120
|
+
if (!command || !COMMAND_OPTIONS[command]) return null;
|
|
121
|
+
let parsed;
|
|
122
|
+
try {
|
|
123
|
+
parsed = parseArgs({ args: rest, options: COMMAND_OPTIONS[command], allowPositionals: true, strict: true });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (!quiet) process.stderr.write(`${errorMessage(error)}\n`);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (parsed.positionals.length > 1) return null;
|
|
129
|
+
if (command === "models" && parsed.positionals.length !== 0) return null;
|
|
130
|
+
if (command === "bulk-read" && parsed.positionals.length !== 0) return null;
|
|
131
|
+
if (command === "next" && parsed.positionals.length !== 0) return null;
|
|
132
|
+
if (command === "update" && parsed.positionals.length !== 0) return null;
|
|
133
|
+
if (command === "setup" && parsed.positionals.length !== 0) return null;
|
|
134
|
+
if (command === "init" && parsed.positionals.length !== 0) return null;
|
|
135
|
+
if (command !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && parsed.positionals.length !== 1) return null;
|
|
136
|
+
return {
|
|
137
|
+
command,
|
|
138
|
+
target: parsed.positionals[0],
|
|
139
|
+
values: /** @type {Record<string, unknown>} */ (parsed.values),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The retry-in-place options of a `resume` invocation, validated before any
|
|
145
|
+
* lock is taken.
|
|
146
|
+
*
|
|
147
|
+
* @param {Record<string, unknown>} values
|
|
148
|
+
* @returns {{node?: string, reconcile?: string, answer?: {node: string, path: string}}}
|
|
149
|
+
*/
|
|
150
|
+
function resumeOptionsOf(values) {
|
|
151
|
+
const node = typeof values.node === "string" && values.node ? values.node : undefined;
|
|
152
|
+
const reconcile = typeof values.reconcile === "string" && values.reconcile ? values.reconcile : undefined;
|
|
153
|
+
const answer = answerOf(values.answer);
|
|
154
|
+
// `--node` and `--answer` both select the retry closure; two different
|
|
155
|
+
// targets is a contradiction, not a union, so it is refused before anything
|
|
156
|
+
// is spawned or locked.
|
|
157
|
+
if (node && answer && node !== answer.node) {
|
|
158
|
+
throw new Error(`--answer ${answer.node} conflicts with --node ${node}`);
|
|
159
|
+
}
|
|
160
|
+
return { node, reconcile, answer };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `--answer <node-id>=<path>`, split on the first `=`. A malformed value —
|
|
165
|
+
* no `=`, an empty node id, or an empty path — is refused before anything is
|
|
166
|
+
* spawned.
|
|
167
|
+
*
|
|
168
|
+
* @param {unknown} value
|
|
169
|
+
* @returns {{node: string, path: string}|undefined}
|
|
170
|
+
*/
|
|
171
|
+
function answerOf(value) {
|
|
172
|
+
if (value === undefined) return undefined;
|
|
173
|
+
if (typeof value !== "string" || !value) throw new Error("--answer must be <node-id>=<path>");
|
|
174
|
+
const eq = value.indexOf("=");
|
|
175
|
+
if (eq < 0) throw new Error("--answer must be <node-id>=<path>");
|
|
176
|
+
const node = value.slice(0, eq);
|
|
177
|
+
const path = value.slice(eq + 1);
|
|
178
|
+
if (!node) throw new Error("--answer node id must not be empty");
|
|
179
|
+
if (!path) throw new Error("--answer path must not be empty");
|
|
180
|
+
return { node, path };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `supervise --interval`, in seconds. Rejected rather than defaulted when it
|
|
185
|
+
* is not a positive number: a scheduler passing a typo should hear about it,
|
|
186
|
+
* not silently get a different cadence than the one it asked for.
|
|
187
|
+
*
|
|
188
|
+
* @param {unknown} value
|
|
189
|
+
* @returns {number}
|
|
190
|
+
*/
|
|
191
|
+
function superviseIntervalOf(value) {
|
|
192
|
+
if (value === undefined) return DEFAULT_SUPERVISE_INTERVAL_SEC;
|
|
193
|
+
const seconds = Number(value);
|
|
194
|
+
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error(`--interval must be a positive number of seconds: ${String(value)}`);
|
|
195
|
+
return seconds;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string[]} argv
|
|
200
|
+
* @returns {Promise<void>}
|
|
201
|
+
*/
|
|
202
|
+
async function main(argv) {
|
|
203
|
+
if (argv.length === 0 || HELP_FLAGS.has(argv[0])) { help(); return; }
|
|
204
|
+
if (VERSION_FLAGS.has(argv[0])) { process.stdout.write(`${packageName()} ${packageVersion()}\n`); return; }
|
|
205
|
+
if (argv[0] === "campaign") { await campaignCli(argv.slice(1)); return; }
|
|
206
|
+
// `supervise campaign <id>` is the campaign-level watchdog; `campaign
|
|
207
|
+
// supervise <id>` is the same operation reached through the campaign verb.
|
|
208
|
+
if (argv[0] === "supervise" && argv[1] === "campaign") { await campaignCli(["supervise", ...argv.slice(2)]); return; }
|
|
209
|
+
if (argv[0] === "seat") { seatCli(argv.slice(1)); return; }
|
|
210
|
+
if (argv[0] === "skills") { skillsCli(argv.slice(1)); return; }
|
|
211
|
+
if (argv[0] === "contract") { contractCli(argv.slice(1)); return; }
|
|
212
|
+
const parsed = parseCli(argv);
|
|
213
|
+
if (!parsed) { usage(); return; }
|
|
214
|
+
const { command, values } = parsed;
|
|
215
|
+
const target = parsed.target;
|
|
216
|
+
if (command === "doctor") {
|
|
217
|
+
const ok = await doctorCommand(target, {
|
|
218
|
+
cwd: typeof values.cwd === "string" ? values.cwd : undefined,
|
|
219
|
+
json: values.json === true,
|
|
220
|
+
discover: values.discover === true,
|
|
221
|
+
});
|
|
222
|
+
if (!ok) process.exitCode = 1;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (command === "models") {
|
|
226
|
+
await modelsCommand({ probe: values.probe === true, json: values.json === true });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (command === "bulk-read") {
|
|
230
|
+
await bulkReadCommand({ question: values.question, paths: values.paths, json: values.json === true });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (command === "next") {
|
|
234
|
+
const cwd = resolve(typeof values.cwd === "string" ? values.cwd : ".");
|
|
235
|
+
const runsDir = join(cwd, ".runs");
|
|
236
|
+
process.stdout.write(values.json === true ? renderNextJson(runsDir, cwd) : renderNext(runsDir, cwd));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (command === "update") {
|
|
240
|
+
process.exitCode = await updateCommand({
|
|
241
|
+
check: values.check === true,
|
|
242
|
+
json: values.json === true,
|
|
243
|
+
env: process.env,
|
|
244
|
+
entryPath: process.argv[1],
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (command === "setup") {
|
|
249
|
+
process.exitCode = await setupCommand({
|
|
250
|
+
yes: values.yes === true,
|
|
251
|
+
harnesses: typeof values.harnesses === "string" ? values.harnesses : undefined,
|
|
252
|
+
worker: typeof values.worker === "string" ? values.worker : undefined,
|
|
253
|
+
judge: typeof values.judge === "string" ? values.judge : undefined,
|
|
254
|
+
json: values.json === true,
|
|
255
|
+
env: process.env,
|
|
256
|
+
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (command === "init") {
|
|
261
|
+
// The two compatibility rules are alternatives, not a union; a caller that
|
|
262
|
+
// asks for both has not decided and gets the usage error instead.
|
|
263
|
+
if (values.greenfield === true && values.stable === true) { usage(); return; }
|
|
264
|
+
process.exitCode = await initCommand({
|
|
265
|
+
cwd: typeof values.cwd === "string" ? values.cwd : undefined,
|
|
266
|
+
yes: values.yes === true,
|
|
267
|
+
skill: values["no-skill"] !== true,
|
|
268
|
+
agentkit: values.agentkit === true,
|
|
269
|
+
variant: values.stable === true ? "stable" : values.greenfield === true ? "greenfield" : undefined,
|
|
270
|
+
json: values.json === true,
|
|
271
|
+
env: process.env,
|
|
272
|
+
isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (!target) { usage(); return; }
|
|
277
|
+
if (command === "run") {
|
|
278
|
+
warnIfNoTransport();
|
|
279
|
+
const absolute = resolve(target);
|
|
280
|
+
const contract = validateContract(JSON.parse(readFileSync(absolute, "utf8")), absolute);
|
|
281
|
+
const runDir = join(contract.cwd, ".runs", contract.id);
|
|
282
|
+
const baseRef = typeof values["base-ref"] === "string" && values["base-ref"] ? values["base-ref"] : undefined;
|
|
283
|
+
setLaunchBaseRef(baseRef);
|
|
284
|
+
// The base is what every worktree is cut from; a dirty tree only blocks
|
|
285
|
+
// when the cwd HEAD *is* that base. A `--base-ref` elsewhere leaves the
|
|
286
|
+
// operator's checkout out of the run entirely. The contract file being
|
|
287
|
+
// launched is this launch's own input, not source the worktrees cut, so it
|
|
288
|
+
// never counts as dirt.
|
|
289
|
+
const contractFromCwd = relative(contract.cwd, absolute);
|
|
290
|
+
const contractIgnore = contractFromCwd && !contractFromCwd.startsWith("..") && !isAbsolute(contractFromCwd) ? [contractFromCwd] : [];
|
|
291
|
+
assertLaunchBaseClean(contract.cwd, baseRef, { ignorePaths: contractIgnore });
|
|
292
|
+
if (values.detach === true) {
|
|
293
|
+
if (existsSync(runDir)) throw new Error(`run already exists: ${runDir}`);
|
|
294
|
+
for (const warning of [...contract.warnings, ...reusedDoneWarnings(contract)]) process.stdout.write(`${advisoryToken()} ${warning}\n`);
|
|
295
|
+
const child = detachSelf("run", target, baseRef ? ["--base-ref", baseRef] : []);
|
|
296
|
+
const pid = child.pid;
|
|
297
|
+
if (pid === undefined) throw new Error("detached child has no pid");
|
|
298
|
+
await waitForBootstrap(runDir, pid, child);
|
|
299
|
+
process.stdout.write(`[run] ${contract.id} detached · pid ${pid} · ${runDir}\n`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
for (const warning of [...contract.warnings, ...reusedDoneWarnings(contract)]) process.stdout.write(`${advisoryToken()} ${warning}\n`);
|
|
303
|
+
const result = await runContract(target, { detachedBootstrap: hasDetachedBootstrapNonce() });
|
|
304
|
+
if (!result.ok) process.exitCode = 1;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (command === "resume") {
|
|
308
|
+
const resumeOptions = resumeOptionsOf(values);
|
|
309
|
+
if (values.detach === true) {
|
|
310
|
+
const runDir = resolve(target);
|
|
311
|
+
if (!existsSync(join(runDir, "contract.json"))) throw new Error(`not a run directory: ${runDir}`);
|
|
312
|
+
const extraArgs = [
|
|
313
|
+
...(resumeOptions.node ? ["--node", resumeOptions.node] : []),
|
|
314
|
+
...(resumeOptions.reconcile ? ["--reconcile", resumeOptions.reconcile] : []),
|
|
315
|
+
...(resumeOptions.answer ? ["--answer", `${resumeOptions.answer.node}=${resumeOptions.answer.path}`] : []),
|
|
316
|
+
];
|
|
317
|
+
const child = detachSelf("resume", target, extraArgs);
|
|
318
|
+
const pid = child.pid;
|
|
319
|
+
if (pid === undefined) throw new Error("detached child has no pid");
|
|
320
|
+
await waitForBootstrap(runDir, pid, child);
|
|
321
|
+
process.stdout.write(`[resume] detached · pid ${pid} · ${runDir}\n`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const result = await resumeRun(target, { ...resumeOptions, detachedBootstrap: hasDetachedBootstrapNonce() });
|
|
325
|
+
if (!result.ok) process.exitCode = 1;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (command === "supervise") {
|
|
329
|
+
warnIfNoTransport();
|
|
330
|
+
const runDir = resolve(target);
|
|
331
|
+
if (!existsSync(join(runDir, "contract.json"))) throw new Error(`not a run directory: ${runDir}`);
|
|
332
|
+
const intervalSec = superviseIntervalOf(values.interval);
|
|
333
|
+
if (values.detach === true) {
|
|
334
|
+
// The supervisor is not a controller: it takes no lock, so there is no
|
|
335
|
+
// bootstrap handshake to wait on. Spawning and reporting the pid is the
|
|
336
|
+
// whole contract with the host scheduler that started it.
|
|
337
|
+
const child = detachSelf("supervise", target, ["--interval", String(intervalSec)]);
|
|
338
|
+
if (child.pid === undefined) throw new Error("detached child has no pid");
|
|
339
|
+
process.stdout.write(`[supervise] detached · pid ${child.pid} · every ${intervalSec}s · ${runDir}\n`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const outcome = await superviseRun(runDir, {
|
|
343
|
+
intervalSec,
|
|
344
|
+
launch: async (target) => {
|
|
345
|
+
const child = detachSelf("resume", target);
|
|
346
|
+
if (child.pid === undefined) throw new Error("detached child has no pid");
|
|
347
|
+
await waitForBootstrap(target, child.pid, child);
|
|
348
|
+
process.stdout.write(`[supervise] resumed · pid ${child.pid} · ${target}\n`);
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
process.stdout.write(`[supervise] ${outcome.state} · ${outcome.launches} resume${outcome.launches === 1 ? "" : "s"} over ${outcome.ticks} checks${outcome.reason ? ` · ${outcome.reason}` : ""}\n`);
|
|
352
|
+
if (outcome.state !== "done") process.exitCode = 1;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (command === "cancel") { await cancelRun(target); return; }
|
|
356
|
+
if (command === "preflight") {
|
|
357
|
+
const absolute = resolve(target);
|
|
358
|
+
const contract = validateContract(JSON.parse(readFileSync(absolute, "utf8")), absolute);
|
|
359
|
+
const checks = await preflightContract(absolute, { static: values.static === true });
|
|
360
|
+
const environment = environmentPreflight({
|
|
361
|
+
cwd: contract.cwd,
|
|
362
|
+
runtimes: reachableRuntimes(contract),
|
|
363
|
+
harnessVersions: Object.fromEntries(checks.map((check) => [check.id, check.version])),
|
|
364
|
+
});
|
|
365
|
+
// Opt-in: this actually runs the contract's verification commands, so it
|
|
366
|
+
// costs whatever they cost. It is the only check that can prove a command
|
|
367
|
+
// fits the timeout the contract gives it.
|
|
368
|
+
const timing = values["time-verification"] === true ? timeVerificationCommands(contract) : [];
|
|
369
|
+
const environmentChecks = [...environment.checks, notifyTransportCheck(process.env)];
|
|
370
|
+
const ok = environment.ok && checks.every((check) => check.ok) && timing.every((check) => check.ok || check.advisory);
|
|
371
|
+
if (values.json === true) {
|
|
372
|
+
process.stdout.write(`${JSON.stringify({
|
|
373
|
+
schemaVersion: 1,
|
|
374
|
+
contractId: contract.id,
|
|
375
|
+
ok,
|
|
376
|
+
environment: [...environmentChecks, ...timing],
|
|
377
|
+
checks: checks.map((check) => ({
|
|
378
|
+
id: check.id,
|
|
379
|
+
harness: check.harness,
|
|
380
|
+
executable: check.executable,
|
|
381
|
+
model: check.model,
|
|
382
|
+
version: check.version,
|
|
383
|
+
ok: check.ok,
|
|
384
|
+
live: check.live === true,
|
|
385
|
+
liveStatus: check.liveStatus ?? null,
|
|
386
|
+
usage: check.usage ?? null,
|
|
387
|
+
costUsd: check.costUsd ?? null,
|
|
388
|
+
detail: check.detail,
|
|
389
|
+
})),
|
|
390
|
+
})}\n`);
|
|
391
|
+
} else {
|
|
392
|
+
const level = colorLevel(process.env, process.stdout.isTTY);
|
|
393
|
+
for (const check of [...environmentChecks, ...timing]) process.stdout.write(`${statusToken(check.ok ? "ok" : check.advisory ? "warn" : "fail", level)} ${check.name} · ${check.detail}\n`);
|
|
394
|
+
for (const check of checks) process.stdout.write(`${statusToken(check.ok ? "ok" : "fail", level)} ${check.id} · ${check.detail}\n`);
|
|
395
|
+
}
|
|
396
|
+
if (!ok) process.exitCode = 1;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (command === "status") {
|
|
400
|
+
const runDir = resolve(target);
|
|
401
|
+
if (values.json === true) {
|
|
402
|
+
process.stdout.write(renderStatusJson(runDir));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const status = renderStatus(runDir);
|
|
406
|
+
writeTextAtomic(join(runDir, "STATUS.md"), status);
|
|
407
|
+
try {
|
|
408
|
+
renderRunHandoff(runDir);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
process.stderr.write(`${statusToken("warn", colorLevel(process.env, process.stderr.isTTY))} campaign handoff render failed: ${errorMessage(error)}\n`);
|
|
411
|
+
}
|
|
412
|
+
process.stdout.write(status);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (command === "report") {
|
|
416
|
+
process.stdout.write(values.json === true ? renderReportJson(resolve(target)) : renderReport(resolve(target)));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (command === "metrics") { process.stdout.write(renderCampaignMetrics(target, values)); return; }
|
|
420
|
+
if (command === "findings") { process.stdout.write(renderFindings(resolve(target))); return; }
|
|
421
|
+
if (command === "validate") { validateContractFile(resolve(target)); return; }
|
|
422
|
+
usage();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The warn token for an advisory written to stdout. DESIGN.md gives every
|
|
427
|
+
* `[warn]` advisory the warn role without distinguishing the stream; only the
|
|
428
|
+
* capability of stdout chooses the escape codes.
|
|
429
|
+
*
|
|
430
|
+
* @returns {string}
|
|
431
|
+
*/
|
|
432
|
+
function advisoryToken() {
|
|
433
|
+
return statusToken("warn", colorLevel(process.env, process.stdout.isTTY));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The foreground launch command is the only moment an operator is present, so
|
|
438
|
+
* it is where the no-transport warning belongs. A detached controller's stdio
|
|
439
|
+
* is discarded, so this prints into nothing there by construction — the
|
|
440
|
+
* warning is not suppressed, it is simply not observable.
|
|
441
|
+
*/
|
|
442
|
+
function warnIfNoTransport() {
|
|
443
|
+
const warning = noTransportWarning();
|
|
444
|
+
if (warning) process.stdout.write(`${advisoryToken()} ${warning}\n`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The harness binaries whose presence the banner counts on PATH. */
|
|
448
|
+
const HARNESS_BINARIES = ["claude", "codex", "agy", "dsh", "zcode"];
|
|
449
|
+
|
|
450
|
+
/** Handled before verb dispatch, so they are not options of any command. */
|
|
451
|
+
const HELP_FLAGS = new Set(["--help", "-h"]);
|
|
452
|
+
const VERSION_FLAGS = new Set(["--version", "-v"]);
|
|
453
|
+
|
|
454
|
+
/** @returns {number} how many harness binaries are on PATH */
|
|
455
|
+
function countHarnesses() {
|
|
456
|
+
return HARNESS_BINARIES.filter((binary) => findExecutable(binary) !== null).length;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* `faberun` with no arguments and `faberun --help`: the identity on stdout
|
|
461
|
+
* with a success exit. The banner belongs to an interactive terminal only; a
|
|
462
|
+
* pipe, a log or `NO_COLOR` receives the usage alone.
|
|
463
|
+
*/
|
|
464
|
+
function help() {
|
|
465
|
+
if (process.stdout.isTTY && process.env.NO_COLOR === undefined) {
|
|
466
|
+
process.stdout.write(renderBanner({
|
|
467
|
+
version: packageVersion(),
|
|
468
|
+
// DESIGN.md draws `node 26.8.1`; `process.version` is `v26.8.1`.
|
|
469
|
+
nodeVersion: process.version.replace(/^v/u, ""),
|
|
470
|
+
harnessCount: countHarnesses(),
|
|
471
|
+
level: colorLevel(process.env, process.stdout.isTTY),
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
process.stdout.write(renderUsage());
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The usage error: the same text as help, on stderr, no banner, exit code 2.
|
|
479
|
+
* The text lives in `cli/brand.mjs` as `renderUsage()` so help and error
|
|
480
|
+
* cannot drift.
|
|
481
|
+
*/
|
|
482
|
+
function usage() {
|
|
483
|
+
process.stderr.write(renderUsage());
|
|
484
|
+
process.exitCode = 2;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// A closed stdout pipe (orphaned monitor, ended pipeline) must never kill a
|
|
488
|
+
// controller through an unhandled EPIPE. Run state lives in the run directory;
|
|
489
|
+
// console output is advisory.
|
|
490
|
+
process.stdout.on("error", () => {});
|
|
491
|
+
process.stderr.on("error", () => {});
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Dispatch one CLI invocation, recording a bootstrap failure before reporting
|
|
495
|
+
* it so a `--detach` launcher watching the run directory sees why its child
|
|
496
|
+
* died. Exported because `bin/faberun.mjs` is the installed entry point
|
|
497
|
+
* and `import.meta.url` cannot see it.
|
|
498
|
+
*
|
|
499
|
+
* @param {string[]} [argv]
|
|
500
|
+
* @returns {Promise<void>}
|
|
501
|
+
*/
|
|
502
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
503
|
+
try {
|
|
504
|
+
await main(argv);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
const parsed = parseCli(argv, true);
|
|
507
|
+
writeBootstrapFailure(parsed?.command ?? "", parsed?.target, error instanceof Error ? error : new Error(errorMessage(error)));
|
|
508
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
509
|
+
process.exitCode = 1;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// `node src/cli.mjs …` still works, and the tests and evals invoke it that way.
|
|
514
|
+
// A detached child is always spawned as this file (see spawnDetached), so the
|
|
515
|
+
// nonce check below keeps working whichever entry the launcher itself used.
|
|
516
|
+
if (process.argv[1] && sameFile(process.argv[1], import.meta.url)) runCli();
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* @param {string|undefined} left
|
|
520
|
+
* @param {string} right
|
|
521
|
+
* @returns {boolean}
|
|
522
|
+
*/
|
|
523
|
+
function sameFile(left, right) {
|
|
524
|
+
try { return realpathSync(resolve(left ?? "")) === realpathSync(new URL(right)); } catch { return false; }
|
|
525
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reserved constitution articles: the common law this repository writes
|
|
3
|
+
* and no contract may overwrite. Separate from index.mjs because the list is
|
|
4
|
+
* data other layers (tests, future article tooling) read without pulling in
|
|
5
|
+
* the whole validator.
|
|
6
|
+
*/
|
|
7
|
+
export const RESERVED_ARTICLES = [
|
|
8
|
+
"references/rules.md",
|
|
9
|
+
"references/engineering.md",
|
|
10
|
+
"references/workflow.md",
|
|
11
|
+
"references/handoffs.md",
|
|
12
|
+
];
|