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,67 @@
1
+ /**
2
+ * `contract validate`: validate an authored contract from the command line and
3
+ * print the report the authoring turn reads (TECH-SPEC lean, rule 4: a
4
+ * partly finished run is continued by `resume`, never re-authored, so this is
5
+ * the only contract operation the CLI carries).
6
+ */
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { loadPersistedContract, validateContract } from "../contract/index.mjs";
10
+
11
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
12
+
13
+ /** Flags are scoped to the operation that declares them; all others are rejected. */
14
+ /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
15
+ const OPERATION_OPTIONS = {
16
+ validate: {},
17
+ };
18
+
19
+ /**
20
+ * @param {string[]} args
21
+ * @returns {void}
22
+ */
23
+ export function contractCli(args) {
24
+ const operation = args[0];
25
+ if (!operation || !Object.hasOwn(OPERATION_OPTIONS, operation)) return usage();
26
+ const target = args[1];
27
+ if (!target || args.length > 2) return usage();
28
+ if (operation === "validate") validateContractFile(resolve(target));
29
+ }
30
+
31
+ /**
32
+ * Validate an authored contract and print the same report the `validate`
33
+ * command has always printed. A single-node contract is simply valid.
34
+ *
35
+ * A contract.json that sits beside a run.json is a persisted run's frozen copy,
36
+ * not an authored contract: it takes the tree-free persisted path and must
37
+ * match the digest recorded at launch. Every other target keeps today's
38
+ * authoring validation, tree reads included.
39
+ *
40
+ * @param {string} path
41
+ * @returns {ValidatedContract}
42
+ */
43
+ export function validateContractFile(path) {
44
+ const runJsonPath = join(dirname(path), "run.json");
45
+ const contract = existsSync(runJsonPath)
46
+ ? loadPersistedContract(path, readRunDigest(runJsonPath))
47
+ : validateContract(JSON.parse(readFileSync(path, "utf8")), path);
48
+ const count = contract.warnings.length;
49
+ process.stdout.write(`valid${count ? ` (${count} warning${count === 1 ? "" : "s"})` : ""}\n`);
50
+ for (const warning of contract.warnings) process.stdout.write(`[warn] ${warning}\n`);
51
+ return contract;
52
+ }
53
+
54
+ /**
55
+ * @param {string} runJsonPath
56
+ * @returns {string|undefined}
57
+ */
58
+ function readRunDigest(runJsonPath) {
59
+ const metadata = JSON.parse(readFileSync(runJsonPath, "utf8"));
60
+ return typeof metadata.contractDigest === "string" ? metadata.contractDigest : undefined;
61
+ }
62
+
63
+ /** @returns {void} */
64
+ function usage() {
65
+ process.stderr.write("usage: faberun contract validate <contract.json>\n");
66
+ process.exitCode = 2;
67
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `faberun init [--cwd <dir>] [--yes] [--no-skill] [--agentkit]
3
+ * [--greenfield|--stable] [--json]`: prepare a target repository for campaigns.
4
+ *
5
+ * Three facts are established in order: the target is a git work tree, `.runs/`
6
+ * is ignored, and the `faberun` skill is installed under `.claude/skills/`. The
7
+ * agent kit is optional, and its compatibility rule is always asked or given by
8
+ * flag -- never defaulted silently. The shipped shell script does the writing;
9
+ * this module owns only the questions and the check lines.
10
+ *
11
+ * Questions, streams and process facts are injected so tests never touch a
12
+ * terminal, the same split `cli/setup.mjs` uses.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { join, resolve } from "node:path";
17
+ import { createInterface } from "node:readline/promises";
18
+ import { fileURLToPath } from "node:url";
19
+ import { colorLevel, statusToken } from "./brand.mjs";
20
+ import { installSkills } from "./skills.mjs";
21
+ import { boundedGitSync } from "../repo/worktree.mjs";
22
+
23
+ /** @typedef {(text: string) => void} Writer */
24
+ /** @typedef {(question: string) => Promise<string>} Asker */
25
+ /** @typedef {{ask: Asker, close: () => void}} AskerHandle */
26
+ /** @typedef {"greenfield"|"stable"} Variant */
27
+ /** @typedef {{cwd: string, runsIgnored: boolean, skillInstalled: boolean, agentkit: boolean}} InitReport */
28
+ /**
29
+ * @typedef {object} InitOptions
30
+ * @property {string} [cwd]
31
+ * @property {boolean} [yes]
32
+ * @property {boolean} [skill] whether to install the faberun skill (default true)
33
+ * @property {boolean} [agentkit] install the agent kit without asking
34
+ * @property {Variant} [variant] the already-chosen compatibility rule
35
+ * @property {boolean} [json]
36
+ * @property {NodeJS.ProcessEnv} [env]
37
+ * @property {Asker} [ask]
38
+ * @property {Writer} [stdout]
39
+ * @property {Writer} [stderr]
40
+ * @property {boolean} [isTTY]
41
+ */
42
+
43
+ /** The shipped installer that lays the agent kit down, resolved from this module rather than the caller's cwd. */
44
+ const AGENTKIT_INSTALLER = fileURLToPath(new URL("../../skills/init-agentkit/scripts/install-agentkit.sh", import.meta.url));
45
+
46
+ /**
47
+ * Prepare `cwd` for campaigns and return the process exit code.
48
+ *
49
+ * @param {InitOptions} [options]
50
+ * @returns {Promise<number>}
51
+ */
52
+ export async function initCommand(options = {}) {
53
+ const env = options.env ?? process.env;
54
+ const stdout = options.stdout ?? ((text) => process.stdout.write(text));
55
+ const stderr = options.stderr ?? ((text) => process.stderr.write(text));
56
+ const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
57
+ const json = options.json === true;
58
+ const cwd = resolve(options.cwd ?? ".");
59
+ const level = colorLevel(env, isTTY);
60
+ // `--yes` and `--json` are the two non-interactive paths; both take the
61
+ // flags as given and never wait on a terminal.
62
+ const interactive = isTTY && options.yes !== true && !json;
63
+ const report = /** @type {InitReport} */ ({ cwd, runsIgnored: false, skillInstalled: false, agentkit: false });
64
+
65
+ // The readline interface is created only if a question is actually asked, so
66
+ // a flag-driven or `--json` invocation never opens stdin. The holder object
67
+ // keeps the assignment inside the closure visible to the `finally` close.
68
+ const state = { asker: /** @type {AskerHandle|null} */ (null) };
69
+ /** @param {string} question @returns {Promise<string>} */
70
+ const askQuestion = async (question) => {
71
+ if (state.asker === null) {
72
+ if (options.ask) {
73
+ state.asker = { ask: options.ask, close: () => {} };
74
+ } else {
75
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
76
+ state.asker = { ask: (text) => rl.question(text), close: () => rl.close() };
77
+ }
78
+ }
79
+ return (await state.asker.ask(question)).trim();
80
+ };
81
+
82
+ try {
83
+ if (!isGitWorkTree(cwd)) {
84
+ if (json) stdout(`${JSON.stringify(report, null, 2)}\n`);
85
+ else stdout(`${statusToken("fail", level)} git · ${cwd} is not a git work tree\n`);
86
+ return 1;
87
+ }
88
+
89
+ const gitignore = ensureRunsIgnored(cwd);
90
+ report.runsIgnored = true;
91
+ if (!json) stdout(`${statusToken("ok", level)} .runs ignored · ${gitignore}\n`);
92
+
93
+ let skill = options.skill !== false;
94
+ if (skill && interactive) {
95
+ skill = !(await askQuestion("Install the faberun skill into .claude/skills? [Y/n] ")).toLowerCase().startsWith("n");
96
+ }
97
+ if (skill) {
98
+ // `--json` still installs; the skill's own report lines are human output
99
+ // and would otherwise corrupt the one machine-readable object.
100
+ const skills = installSkills({
101
+ names: ["faberun"],
102
+ skillsDir: join(cwd, ".claude", "skills"),
103
+ force: false,
104
+ stdout: json ? { write: () => {} } : { write: stdout },
105
+ });
106
+ report.skillInstalled = skills.installed + skills.skipped > 0;
107
+ }
108
+
109
+ let agentkit = options.agentkit === true;
110
+ if (!agentkit && interactive) {
111
+ agentkit = (await askQuestion("Install the agent kit (AGENTS.md, docs/, ADRs, Sentrux gate)? [y/N] ")).toLowerCase().startsWith("y");
112
+ }
113
+ if (agentkit) {
114
+ let variant = /** @type {Variant} */ (options.variant === "stable" ? "stable" : "greenfield");
115
+ if (interactive && options.variant === undefined) {
116
+ const answer = (await askQuestion("Compatibility rule: greenfield (break freely) or stable (preserve published contracts)? [greenfield] ")).toLowerCase();
117
+ variant = answer.startsWith("s") ? "stable" : "greenfield";
118
+ }
119
+ const result = spawnSync("bash", [AGENTKIT_INSTALLER, cwd, `--${variant}`], { stdio: "inherit" });
120
+ if (result.error) {
121
+ stderr(`${result.error.message}\n`);
122
+ return 1;
123
+ }
124
+ if (result.status !== 0) return result.status ?? 1;
125
+ report.agentkit = true;
126
+ }
127
+
128
+ if (json) stdout(`${JSON.stringify(report, null, 2)}\n`);
129
+ else stdout(`next · faberun doctor --cwd ${cwd} · faberun campaign init <id> --cwd ${cwd} --goal "..."\n`);
130
+ return 0;
131
+ } finally {
132
+ state.asker?.close();
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Whether `git -C cwd rev-parse --is-inside-work-tree` answers `true`.
138
+ *
139
+ * @param {string} cwd
140
+ * @returns {boolean}
141
+ */
142
+ function isGitWorkTree(cwd) {
143
+ const result = boundedGitSync(["-C", cwd, "rev-parse", "--is-inside-work-tree"], {
144
+ encoding: "utf8",
145
+ stdio: ["ignore", "pipe", "ignore"],
146
+ });
147
+ return !result.error && result.status === 0 && String(result.stdout).trim() === "true";
148
+ }
149
+
150
+ /**
151
+ * Add a `.runs/` line to `cwd/.gitignore` unless an equivalent line already
152
+ * exists, creating the file when missing. A file that does not end in a newline
153
+ * gets one before the appended line, so the result is always a whole line.
154
+ *
155
+ * @param {string} cwd
156
+ * @returns {string} the `.gitignore` path written or confirmed
157
+ */
158
+ function ensureRunsIgnored(cwd) {
159
+ const path = join(cwd, ".gitignore");
160
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
161
+ const ignored = existing.split(/\r?\n/u).some((line) => {
162
+ const trimmed = line.trim();
163
+ return trimmed === ".runs/" || trimmed === ".runs";
164
+ });
165
+ if (!ignored) {
166
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
167
+ writeFileSync(path, `${existing}${separator}.runs/\n`);
168
+ }
169
+ return path;
170
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The launcher half of `--detach`: spawn the controller, wait for it to prove it
3
+ * owns the run, acknowledge, and exit.
4
+ *
5
+ * The controller half is `engine/detach.mjs`. They are separate because only
6
+ * this side knows about `process.argv` and about exiting -- and because the
7
+ * engine importing the CLI was the one import cycle a layered tree could not
8
+ * express.
9
+ *
10
+ * `writeBootstrapFailure` is what makes a detached failure visible at all: a
11
+ * child that dies before taking the lock leaves nothing behind unless it writes
12
+ * why, and the launcher is already gone by then.
13
+ */
14
+ import { SETTLED } from "../engine/prompts.mjs";
15
+ import { bootstrapAckPath, bootstrapAttemptPath, bootstrapPath, cleanupBootstrapAttempts, readJson, writeJsonAtomic } from "../run/store.mjs";
16
+ import { bootstrapFailureMatchesChild, bootstrapMatchesChild, processStartToken, readLock, sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
17
+ import { cleanupBootstrapNonce } from "../engine/detach.mjs";
18
+ import { delay, errorCode } from "../util.mjs";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+ import { join, resolve } from "node:path";
22
+ import { randomUUID } from "node:crypto";
23
+ import { readRunNodes } from "../engine/scheduler.mjs";
24
+ import { spawn } from "node:child_process";
25
+ import { validateContract } from "../contract/index.mjs";
26
+
27
+ /**
28
+ * The file a detached child is spawned as. It must be the CLI and not this
29
+ * module: `cli.mjs` guards its dispatch on `process.argv[1]` being itself, so a
30
+ * child started as `launch.mjs` would run no command at all -- which is exactly
31
+ * what happened when this code moved out of `cli.mjs` and kept using
32
+ * `import.meta.url`.
33
+ */
34
+ const CLI_ENTRY = fileURLToPath(new URL("../cli.mjs", import.meta.url));
35
+
36
+ /** @typedef {import("../cli.mjs").BootstrapRecord} BootstrapRecord */
37
+ /** @typedef {import("node:child_process").ChildProcess & {bootstrapNonce?: string, bootstrapProcessStartToken?: string|null}} DetachedChild */
38
+ /** @typedef {import("../run/lock.mjs").ReadLockResult} ReadLockResult */
39
+
40
+ /**
41
+ * @param {string} command
42
+ * @param {string} target
43
+ * @param {string[]} [extraArgs]
44
+ * @returns {DetachedChild}
45
+ */
46
+ export function detachSelf(command, target, extraArgs = []) {
47
+ return detachArgv([command, target, ...extraArgs]);
48
+ }
49
+
50
+ /**
51
+ * Spawn this CLI as a detached child with a fresh bootstrap nonce, for argv
52
+ * shapes `detachSelf`'s (command, target) pair cannot express — a campaign
53
+ * watch is `campaign watch <id>`, not `<command> <target>`. stdio is discarded
54
+ * exactly as for every detached controller: a foreground launcher is the only
55
+ * moment an operator is present.
56
+ *
57
+ * @param {string[]} argv
58
+ * @param {{nonce?: string, env?: NodeJS.ProcessEnv}} [options]
59
+ * @returns {DetachedChild}
60
+ */
61
+ export function detachArgv(argv, options = {}) {
62
+ const nonce = options.nonce ?? randomUUID();
63
+ const child = /** @type {DetachedChild} */ (spawn(process.execPath, [CLI_ENTRY, ...argv], {
64
+ cwd: process.cwd(),
65
+ env: { ...process.env, ...options.env, FABERUN_BOOTSTRAP_NONCE: nonce },
66
+ detached: process.platform !== "win32",
67
+ stdio: "ignore",
68
+ }));
69
+ child.unref();
70
+ child.bootstrapNonce = nonce;
71
+ child.bootstrapProcessStartToken = processStartToken(child.pid ?? null);
72
+ return child;
73
+ }
74
+ /**
75
+ * @param {string} runDir
76
+ * @param {number} pid
77
+ * @param {DetachedChild|null} [child]
78
+ * @param {number} [timeoutMs]
79
+ * @returns {Promise<BootstrapRecord>}
80
+ */
81
+ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = 30_000) {
82
+ const deadline = Date.now() + timeoutMs;
83
+ const nonce = child?.bootstrapNonce;
84
+ if (!nonce) throw new Error(`detached bootstrap has no start nonce for pid ${pid}`);
85
+ if (!validBootstrapNonce(nonce) || child.pid !== pid) throw new Error(`detached bootstrap has invalid child identity for pid ${pid}`);
86
+ let expectedProcessStartToken = child.bootstrapProcessStartToken ?? null;
87
+ let exited = false;
88
+ child?.once("exit", () => { exited = true; });
89
+ while (Date.now() < deadline) {
90
+ const childExited = exited || child?.exitCode !== null || child?.signalCode !== null;
91
+ if (expectedProcessStartToken === null && !childExited) expectedProcessStartToken = processStartToken(pid);
92
+ for (const path of [bootstrapAttemptPath(runDir, nonce), bootstrapPath(runDir)]) {
93
+ try {
94
+ const bootstrap = /** @type {BootstrapRecord} */ (readJson(path));
95
+ const childIdentity = bootstrapMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken);
96
+ if (bootstrap.status === "failed" && bootstrapFailureMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken)) {
97
+ cleanupBootstrapAttempts(runDir);
98
+ throw new Error(`detached bootstrap failed: ${bootstrap.error}`);
99
+ }
100
+ const lock = readLock(runDir);
101
+ const currentOwner = lockOwnedBy(lock, pid, expectedProcessStartToken);
102
+ if (!childExited && bootstrap.status === "ready" && childIdentity && currentOwner && runIsNonterminal(runDir)) {
103
+ cleanupBootstrapAttempts(runDir);
104
+ try {
105
+ writeBootstrapAcknowledgement(runDir, bootstrap, expectedProcessStartToken);
106
+ } catch (error) {
107
+ cleanupBootstrapNonce(runDir, nonce);
108
+ throw error;
109
+ }
110
+ return bootstrap;
111
+ }
112
+ } catch (error) {
113
+ if (errorCode(error) !== "ENOENT") {
114
+ cleanupBootstrapNonce(runDir, nonce);
115
+ cleanupBootstrapAttempts(runDir);
116
+ throw error;
117
+ }
118
+ }
119
+ }
120
+ if (childExited) {
121
+ try {
122
+ const failure = /** @type {BootstrapRecord} */ (readJson(bootstrapAttemptPath(runDir, nonce)));
123
+ if (failure.status === "failed" && bootstrapFailureMatchesChild(failure, pid, nonce, expectedProcessStartToken)) {
124
+ cleanupBootstrapAttempts(runDir);
125
+ throw new Error(`detached bootstrap failed: ${failure.error}`);
126
+ }
127
+ } catch (error) {
128
+ if (errorCode(error) !== "ENOENT") {
129
+ cleanupBootstrapNonce(runDir, nonce);
130
+ cleanupBootstrapAttempts(runDir);
131
+ throw error;
132
+ }
133
+ }
134
+ cleanupBootstrapNonce(runDir, nonce);
135
+ cleanupBootstrapAttempts(runDir);
136
+ throw new Error(`detached bootstrap failed before readiness for pid ${pid}`);
137
+ }
138
+ await delay(50);
139
+ }
140
+ cleanupBootstrapNonce(runDir, nonce);
141
+ cleanupBootstrapAttempts(runDir);
142
+ throw new Error(`detached bootstrap did not become ready for pid ${pid}`);
143
+ }
144
+ /**
145
+ * @param {string} runDir
146
+ * @param {BootstrapRecord} bootstrap
147
+ * @param {string|null} expectedProcessStartToken
148
+ */
149
+ export function writeBootstrapAcknowledgement(runDir, bootstrap, expectedProcessStartToken) {
150
+ if (!bootstrap.nonce) throw new Error("bootstrap record has no nonce");
151
+ writeJsonAtomic(bootstrapAckPath(runDir, bootstrap.nonce), {
152
+ status: "acknowledged",
153
+ nonce: bootstrap.nonce,
154
+ pid: bootstrap.pid ?? null,
155
+ processStartToken: expectedProcessStartToken,
156
+ at: new Date().toISOString(),
157
+ });
158
+ }
159
+ /**
160
+ * @param {string} runDir
161
+ * @returns {boolean}
162
+ */
163
+ export function runIsNonterminal(runDir) {
164
+ try {
165
+ const contractPath = join(runDir, "contract.json");
166
+ const contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
167
+ const nodes = readRunNodes(runDir, contract);
168
+ return nodes.length > 0 && nodes.some((node) => !SETTLED.has(node.status));
169
+ } catch (error) {
170
+ if (errorCode(error) === "ENOENT") return false;
171
+ throw error;
172
+ }
173
+ }
174
+ /**
175
+ * @param {string} command
176
+ * @param {string|undefined} target
177
+ * @returns {string|null}
178
+ */
179
+ export function bootstrapRunDir(command, target) {
180
+ try {
181
+ if (command === "run") {
182
+ if (!target) return null;
183
+ const path = resolve(target);
184
+ const contract = validateContract(JSON.parse(readFileSync(path, "utf8")), path);
185
+ return join(contract.cwd, ".runs", contract.id);
186
+ }
187
+ if (["resume", "cancel"].includes(command)) {
188
+ if (!target) return null;
189
+ return resolve(target);
190
+ }
191
+ } catch {
192
+ return null;
193
+ }
194
+ return null;
195
+ }
196
+ /**
197
+ * @param {string} command
198
+ * @param {string|undefined} target
199
+ * @param {Error} error
200
+ */
201
+ export function writeBootstrapFailure(command, target, error) {
202
+ const runDir = bootstrapRunDir(command, target);
203
+ if (!runDir || !existsSync(runDir)) return;
204
+ const nonce = validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE) ? process.env.FABERUN_BOOTSTRAP_NONCE : null;
205
+ const failure = { status: "failed", pid: process.pid, processStartToken: processStartToken(process.pid), runDir, nonce, at: new Date().toISOString(), error: error.message };
206
+ if (command === "run" && existsSync(join(runDir, "contract.json"))) return;
207
+ /** @type {BootstrapRecord|null} */
208
+ let current = null;
209
+ try {
210
+ current = /** @type {BootstrapRecord} */ (readJson(bootstrapPath(runDir)));
211
+ const controllerLock = readLock(runDir);
212
+ const currentOwnerActive = current.status === "ready" && current.pid !== process.pid
213
+ && lockOwnedBy(controllerLock, current.pid, current.processStartToken);
214
+ if (currentOwnerActive) return;
215
+ } catch (readError) {
216
+ if (errorCode(readError) !== "ENOENT") return;
217
+ }
218
+ // Only a detached bootstrap child records an attempt file for its own nonce;
219
+ // a --detach parent that observed the failure must not recreate attempt
220
+ // artifacts with an ambient nonce it does not own.
221
+ if (nonce && !process.argv.includes("--detach") && !(current?.status === "ready" && current.pid === process.pid)) {
222
+ try { writeJsonAtomic(bootstrapAttemptPath(runDir, nonce), failure); } catch {
223
+ // Best-effort record: a failed attempt write must not mask the bootstrap error being reported.
224
+ }
225
+ }
226
+ try { writeJsonAtomic(bootstrapPath(runDir), failure); } catch {
227
+ // Best-effort record: there is nothing left to do if this write fails too.
228
+ }
229
+ }
230
+ /**
231
+ * @param {import("../run/lock.mjs").ReadLockResult} lock
232
+ * @param {number|undefined} pid
233
+ * @param {string|null|undefined} processStartToken
234
+ * @returns {boolean}
235
+ */
236
+ function lockOwnedBy(lock, pid, processStartToken) {
237
+ return lock !== null && !lock.invalid && lock.pid === pid
238
+ && sameProcessStartToken(lock.processStartToken, processStartToken);
239
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `seat` argv: start, attach, status, stop, switch.
3
+ *
4
+ * Per-operation options only, so a flag declared for one operation is rejected
5
+ * for the others. The facade in `src/seat/index.mjs` owns the behavior; this
6
+ * file owns the wire, the same split `campaign.mjs` uses.
7
+ */
8
+ import { parseArgs as parseFlags } from "node:util";
9
+ import { attachSeat, seatStatus, startSeat, stopSeat, switchSeat } from "../seat/index.mjs";
10
+
11
+ /** @typedef {{cwd?: string, harness?: string, ssh?: string, json?: boolean}} SeatValues */
12
+
13
+ /** Flags are scoped to the operations that declare them; all other flags are rejected. */
14
+ /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
15
+ const OPERATION_OPTIONS = {
16
+ start: { cwd: { type: "string" }, harness: { type: "string" } },
17
+ attach: { cwd: { type: "string" }, ssh: { type: "string" } },
18
+ status: { cwd: { type: "string" }, json: { type: "boolean" } },
19
+ stop: { cwd: { type: "string" } },
20
+ switch: { cwd: { type: "string" }, harness: { type: "string" } },
21
+ };
22
+
23
+ /**
24
+ * @param {string[]} args
25
+ * @returns {void}
26
+ */
27
+ export function seatCli(args) {
28
+ const operation = args[0];
29
+ if (!operation || !(operation in OPERATION_OPTIONS)) return usage();
30
+ let parsed;
31
+ try {
32
+ parsed = parseFlags({ args: args.slice(1), options: OPERATION_OPTIONS[operation], allowPositionals: true, strict: true });
33
+ } catch (error) {
34
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
35
+ return usage();
36
+ }
37
+ const [campaign, ...extra] = parsed.positionals;
38
+ const values = /** @type {SeatValues} */ (parsed.values);
39
+ if (extra.length) return usage();
40
+ if (operation === "status") {
41
+ if (campaign !== undefined) return usage();
42
+ return reportStatus(values);
43
+ }
44
+ if (operation === "start") {
45
+ if (!campaign) return usage();
46
+ return reportStart(campaign, values);
47
+ }
48
+ if (operation === "attach") return reportAttach(campaign, values);
49
+ if (operation === "stop") return reportStop(campaign);
50
+ if (operation === "switch") {
51
+ if (!values.harness) return usage();
52
+ return reportSwitch(campaign, values);
53
+ }
54
+ return usage();
55
+ }
56
+
57
+ /**
58
+ * @param {string} campaign
59
+ * @param {SeatValues} values
60
+ */
61
+ function reportStart(campaign, values) {
62
+ const result = startSeat({ campaign, harness: values.harness, cwd: values.cwd });
63
+ if (!result.ok) {
64
+ process.stderr.write(`[seat] ${result.message ?? result.reason}\n`);
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+ process.stdout.write(`[seat] ${campaign} started with ${result.harness} · ${result.session}:${campaign}\n`);
69
+ process.stdout.write(`${result.attachCommand}\n`);
70
+ }
71
+
72
+ /**
73
+ * @param {string|undefined} campaign
74
+ * @param {SeatValues} values
75
+ */
76
+ function reportAttach(campaign, values) {
77
+ const result = attachSeat({ campaign, ssh: values.ssh });
78
+ if (!result.ok) {
79
+ process.stderr.write(`[seat] ${result.message ?? "cannot reattach to the seat"}\n`);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ process.stdout.write(`${result.command}\n`);
84
+ }
85
+
86
+ /**
87
+ * @param {SeatValues} values
88
+ */
89
+ function reportStatus(values) {
90
+ const status = seatStatus();
91
+ if (values.json === true) {
92
+ process.stdout.write(`${JSON.stringify(status)}\n`);
93
+ return;
94
+ }
95
+ if (!status.tmux) {
96
+ process.stdout.write("[seat] tmux is not available; no seat can be listed\n");
97
+ return;
98
+ }
99
+ if (!status.seats.length) {
100
+ process.stdout.write(`[seat] ${status.session} has no windows\n`);
101
+ return;
102
+ }
103
+ for (const seat of status.seats) {
104
+ process.stdout.write(`[seat] ${seat.campaign} · harness ${seat.harness ?? "unknown"} · ambient ${seat.canRenderAmbient ? "yes" : "no"}\n`);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * @param {string|undefined} campaign
110
+ */
111
+ function reportStop(campaign) {
112
+ const result = stopSeat({ campaign });
113
+ if (!result.ok) {
114
+ process.stderr.write(`[seat] ${result.reason ?? "stop failed"}\n`);
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ process.stdout.write(campaign ? `[seat] ${campaign} stopped\n` : "[seat] seat session stopped\n");
119
+ }
120
+
121
+ /**
122
+ * @param {string|undefined} campaign
123
+ * @param {SeatValues} values
124
+ */
125
+ function reportSwitch(campaign, values) {
126
+ const result = switchSeat({ campaign, harness: values.harness, cwd: values.cwd });
127
+ if (!result.ok) {
128
+ process.stderr.write(`[seat] ${result.message ?? result.reason}\n`);
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ process.stdout.write(`[seat] ${result.campaign} switched to ${result.harness} · ${result.session}:${result.window}\n`);
133
+ process.stdout.write(`${result.attachCommand}\n`);
134
+ }
135
+
136
+ function usage() {
137
+ process.stderr.write("usage: faberun seat <start|attach|status|stop|switch> [<campaign-id>] [--cwd <dir>] ...\n");
138
+ process.exitCode = 2;
139
+ }