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,730 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { parseArgs as parseFlags } from "node:util";
5
+ import {
6
+ authoredContractDigest,
7
+ closeCampaign,
8
+ discoverCampaigns,
9
+ initializeCampaign,
10
+ renderHandoff,
11
+ resolveCampaign,
12
+ } from "../campaign/index.mjs";
13
+ import { lockStale, pidAlive, processStartToken, readLock } from "../run/lock.mjs";
14
+ import { syncAgentSignal } from "../repo/signal.mjs";
15
+ import { acknowledgeJournalEvent, appendJournal, readJournal, watchJournal } from "../campaign/journal.mjs";
16
+ import { driveCampaignChain } from "../campaign/chain.mjs";
17
+ import { unparkCampaign } from "../campaign/unpark.mjs";
18
+ import { readCampaign } from "../campaign/record.mjs";
19
+ import { notifyQueueFor } from "../engine/notify-queue.mjs";
20
+ import { appendInbox, readInbox, wakeCapabilityNotice } from "../notify/index.mjs";
21
+ import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
22
+ import { errorCode, readJsonTolerant } from "../util.mjs";
23
+
24
+ const SYNC_OUTPUT_MAX_BYTES = 8000;
25
+ const DEFAULT_WAKE_POLL_MS = 30_000;
26
+ const WAKE_IDLE_AFTER_MS = 20 * 60_000;
27
+ const TERMINAL_NODE_STATUSES = new Set(["done", "no-op", "blocked", "failed", "exhausted", "stalled", "canceled", "cancelled"]);
28
+ const ATTENTION_NODE_STATUSES = new Set(["failed", "exhausted", "stalled", "canceled", "cancelled"]);
29
+
30
+ const NOTE_KINDS = new Set([
31
+ "intent",
32
+ "decision",
33
+ "supersede",
34
+ "constraint",
35
+ "outcome",
36
+ "next",
37
+ "open-question",
38
+ "retrospective",
39
+ ]);
40
+
41
+ /** Flags that only apply to a single note kind; rejected for every other kind. */
42
+ const NOTE_KIND_FLAGS = {
43
+ decision: ["decision-id"],
44
+ supersede: ["supersedes"],
45
+ "open-question": ["question-id"],
46
+ outcome: ["run-id"],
47
+ };
48
+
49
+ /** Flags are scoped to the operations that declare them; all other flags are rejected. */
50
+ /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
51
+ const OPERATION_OPTIONS = {
52
+ list: { cwd: { type: "string" } },
53
+ init: { cwd: { type: "string" }, goal: { type: "string" }, contract: { type: "string", multiple: true }, "land-branch": { type: "string" } },
54
+ watch: { cwd: { type: "string" }, wake: { type: "boolean" }, detach: { type: "boolean" }, interval: { type: "string" }, once: { type: "boolean" } },
55
+ attach: {
56
+ cwd: { type: "string" },
57
+ tool: { type: "string" },
58
+ "session-id": { type: "string" },
59
+ transcript: { type: "string" },
60
+ "no-transcript": { type: "boolean" },
61
+ format: { type: "string" },
62
+ cursor: { type: "string" },
63
+ "event-id": { type: "string" },
64
+ },
65
+ note: {
66
+ cwd: { type: "string" },
67
+ "session-id": { type: "string" },
68
+ kind: { type: "string" },
69
+ text: { type: "string" },
70
+ "event-id": { type: "string" },
71
+ "decision-id": { type: "string" },
72
+ supersedes: { type: "string" },
73
+ "question-id": { type: "string" },
74
+ "run-id": { type: "string" },
75
+ },
76
+ resolve: {
77
+ cwd: { type: "string" },
78
+ "session-id": { type: "string" },
79
+ "question-id": { type: "string" },
80
+ text: { type: "string" },
81
+ "event-id": { type: "string" },
82
+ },
83
+ close: { cwd: { type: "string" }, "event-id": { type: "string" } },
84
+ supervise: { cwd: { type: "string" }, "allow-main": { type: "boolean" } },
85
+ unpark: { cwd: { type: "string" }, force: { type: "boolean" }, "event-id": { type: "string" } },
86
+ show: { cwd: { type: "string" } },
87
+ sync: { cwd: { type: "string" }, "session-id": { type: "string" } },
88
+ ack: { cwd: { type: "string" }, "session-id": { type: "string" }, "event-id": { type: "string" } },
89
+ };
90
+
91
+ /** @typedef {{cwd?: string, goal?: string, contract?: string[], landBranch?: string, tool?: string, sessionId?: string, transcript?: string, format?: string, cursor?: string, since?: string, kind?: string, text?: string, runId?: string, supersedes?: string, decisionId?: string, questionId?: string, eventId?: string, noTranscript?: boolean, wake?: boolean, detach?: boolean, interval?: string, once?: boolean, allowMain?: boolean, force?: boolean}} CliValues */
92
+ /** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
93
+
94
+ /**
95
+ * @param {string[]} args
96
+ * @returns {Promise<number|void>}
97
+ */
98
+ export async function campaignCli(args) {
99
+ const operation = args[0];
100
+ if (!operation || !(operation in OPERATION_OPTIONS)) return usage();
101
+ const { positional, values } = parseArgs(args.slice(1), operation);
102
+ const [campaignId, ...extra] = positional;
103
+ if (operation === "list") {
104
+ if (campaignId !== undefined || extra.length) return usage();
105
+ return listCampaigns(values);
106
+ }
107
+ if (!campaignId || extra.length) return usage();
108
+ if (operation === "init") return init(campaignId, values);
109
+ if (operation === "watch") return watch(campaignId, values);
110
+ if (operation === "attach") return attach(campaignId, values);
111
+ if (operation === "note") return note(campaignId, values);
112
+ if (operation === "resolve") return resolveQuestion(campaignId, values);
113
+ if (operation === "close") return close(campaignId, values);
114
+ if (operation === "supervise") return supervise(campaignId, values);
115
+ if (operation === "unpark") return unpark(campaignId, values);
116
+ if (operation === "show") return show(campaignId, values);
117
+ if (operation === "sync") return sync(campaignId, values);
118
+ if (operation === "ack") return ack(campaignId, values);
119
+ return usage();
120
+ }
121
+
122
+ /**
123
+ * `campaign watch <id> --wake`: poll the campaign's linked runs' status.json
124
+ * files and print exactly one line per actionable change (TECH-SPEC lean,
125
+ * rule 6 and section 5 row 2b). Replaces the harness-side
126
+ * `watch-campaign.mjs` monitor and the old pull-based outbox watch.
127
+ *
128
+ * `--detach` spawns the same loop as a detached child whose stdio is
129
+ * discarded, so a host scheduler can arm it without a terminal. The loop is
130
+ * protected by a durable `watch.lock` in the campaign directory and the
131
+ * inbox's dedupe key, so two watchers never double-send and a restarted one
132
+ * does not replay what it already delivered.
133
+ *
134
+ * @param {string} campaignId
135
+ * @param {CliValues} values
136
+ */
137
+ async function watch(campaignId, values) {
138
+ if (values.wake !== true) throw new TypeError("watch requires --wake");
139
+ const cwd = resolve(values.cwd ?? ".");
140
+ const { path, runsDir } = selectCampaign(campaignId, values);
141
+ const pollMs = values.interval === undefined ? DEFAULT_WAKE_POLL_MS : positiveIntervalMs(values.interval);
142
+ if (values.detach === true) {
143
+ const argv = ["campaign", "watch", campaignId, "--wake", "--cwd", cwd];
144
+ if (values.interval !== undefined) argv.push("--interval", values.interval);
145
+ if (values.once === true) argv.push("--once");
146
+ const child = detachArgv(argv);
147
+ if (child.pid === undefined) throw new Error("detached campaign watch has no pid");
148
+ process.stdout.write(`[campaign] watch detached · pid ${child.pid} · ${campaignId}\n`);
149
+ return;
150
+ }
151
+ await watchCampaignWake(path, runsDir, { pollMs, once: values.once === true });
152
+ }
153
+
154
+ /**
155
+ * The watcher loop. Each line is announced through `notify`, which by default
156
+ * records it in `<runs-dir>/inbox.jsonl` and delivers it to the campaign's
157
+ * `notify.jsonl`; the inbox is both the durable record and the dedupe, so a
158
+ * line already recorded is never re-sent. The injectable seams exist so a
159
+ * test can drive the loop deterministically.
160
+ *
161
+ * @param {string} campaignPath
162
+ * @param {string} runsDir
163
+ * @param {{pollMs?: number, once?: boolean, now?: () => number, sleep?: (ms: number) => Promise<void>, emit?: (line: string) => void, notify?: (event: {type: string, campaignId: string, dedupeKey: string, summary: string, runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null}) => Promise<void>|void, lock?: {release: () => void}}} [options]
164
+ * @returns {Promise<void>}
165
+ */
166
+ export async function watchCampaignWake(campaignPath, runsDir, options = {}) {
167
+ const pollMs = options.pollMs ?? DEFAULT_WAKE_POLL_MS;
168
+ const now = options.now ?? (() => Date.now());
169
+ const sleep = options.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)));
170
+ const emit = options.emit ?? ((line) => process.stdout.write(`${line}\n`));
171
+ const notify = options.notify ?? ((event) => notifyQueueFor(campaignPath).enqueue({
172
+ type: "attention",
173
+ campaignId: event.campaignId,
174
+ dedupeKey: event.dedupeKey,
175
+ summary: event.summary,
176
+ runId: event.runId ?? null,
177
+ nodeId: event.nodeId ?? null,
178
+ status: event.status ?? null,
179
+ errorCode: event.errorCode ?? null,
180
+ }));
181
+ const seen = new Set(readInbox(runsDir).map((entry) => entry.dedupeKey));
182
+ const lock = options.lock ?? acquireWatchLock(campaignPath);
183
+ emit(wakeCapabilityNotice());
184
+ /** @type {Map<string, string>} */
185
+ const runSignatures = new Map();
186
+ let lastActiveAt = now();
187
+ let first = true;
188
+ try {
189
+ for (;;) {
190
+ const campaign = readCampaign(campaignPath);
191
+ if (campaign.status !== "active") {
192
+ emit(`campaign-watch: ${campaign.id} is ${campaign.status}; stopping`);
193
+ return;
194
+ }
195
+ /**
196
+ * Persist first, deliver second: the inbox entry is the durable dedupe,
197
+ * so a restart or a second watcher skips a line already recorded even
198
+ * when delivery is injected.
199
+ *
200
+ * @param {string} dedupeKey @param {string} summary @param {{runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null}} [extra]
201
+ */
202
+ const announce = async (dedupeKey, summary, extra = {}) => {
203
+ if (seen.has(dedupeKey)) return;
204
+ seen.add(dedupeKey);
205
+ const appended = appendInbox(runsDir, { type: "attention", campaignId: campaign.id, dedupeKey, summary, ...extra });
206
+ if (!appended.appended) return;
207
+ emit(summary);
208
+ await notify({ type: "attention", campaignId: campaign.id, dedupeKey, summary, ...extra });
209
+ };
210
+ let anyActive = false;
211
+ for (const runId of campaign.linkedRunIds) {
212
+ const status = /** @type {Record<string, any>|null} */ (readJsonTolerant(join(runsDir, runId, "status.json")));
213
+ if (!status || !Array.isArray(status.nodes)) continue;
214
+ const terminal = status.nodes.every((/** @type {any} */ node) => TERMINAL_NODE_STATUSES.has(String(node.status)));
215
+ const signature = status.nodes.map((/** @type {any} */ node) => `${node.id}:${node.status}:${node.errorCode ?? ""}`).join("|");
216
+ const previous = runSignatures.get(runId);
217
+ runSignatures.set(runId, signature);
218
+ if (!terminal) {
219
+ anyActive = true;
220
+ const runLock = readLock(join(runsDir, runId));
221
+ const stale = !runLock || /** @type {{invalid?: true}} */ (runLock).invalid || lockStale(runLock);
222
+ if (stale && !first) {
223
+ await announce(`stale:${runId}`, `campaign-watch: ${runId} has non-terminal nodes but no live controller; resume it`, { runId });
224
+ }
225
+ }
226
+ if (!first && previous !== signature) {
227
+ for (const node of status.nodes) {
228
+ const attention = ATTENTION_NODE_STATUSES.has(String(node.status))
229
+ || (node.status === "blocked" && !(Array.isArray(node.blockedBy) && node.blockedBy.length > 0));
230
+ if (attention) {
231
+ const key = `node:${runId}:${node.id}:${node.status}:${node.errorCode ?? ""}`;
232
+ await announce(
233
+ key,
234
+ `campaign-watch: ${runId} node ${node.id} ${node.status}${node.errorCode ? ` [${node.errorCode}]` : ""}${node.note ? ` ${node.note}` : ""}`,
235
+ { runId, nodeId: String(node.id), status: String(node.status), errorCode: node.errorCode ?? null },
236
+ );
237
+ }
238
+ }
239
+ }
240
+ if (terminal) {
241
+ await announce(`terminal:${runId}`, `campaign-watch: ${runId} terminal · ${status.summary ?? ""}`, { runId });
242
+ }
243
+ }
244
+ const nowMs = now();
245
+ if (anyActive) lastActiveAt = nowMs;
246
+ else if (!first && nowMs - lastActiveAt >= WAKE_IDLE_AFTER_MS) {
247
+ const key = `idle:${Math.floor((nowMs - lastActiveAt) / WAKE_IDLE_AFTER_MS)}`;
248
+ await announce(key, `campaign-watch: ${campaign.id} active but no run has been active for ${Math.round((nowMs - lastActiveAt) / 60_000)} min; dispatch the next step`);
249
+ }
250
+ first = false;
251
+ if (options.once === true) return;
252
+ await sleep(pollMs);
253
+ }
254
+ } finally {
255
+ lock.release();
256
+ }
257
+ }
258
+
259
+ const WATCH_LOCK_FILE = "watch.lock";
260
+
261
+ /**
262
+ * A durable campaign-watch lock, one watcher per campaign across processes.
263
+ * A live holder is never taken over; a dead or recycled pid's lock is stale
264
+ * and is replaced, so a restart after a crash is not blocked. The same
265
+ * liveness rule as the controller lock: a pid is dead only when the probe
266
+ * proves it.
267
+ *
268
+ * @param {string} campaignPath
269
+ * @returns {{pid: number, processStartToken: string|null, startedAt: string, release: () => void}}
270
+ */
271
+ export function acquireWatchLock(campaignPath) {
272
+ const path = join(campaignPath, WATCH_LOCK_FILE);
273
+ /** @type {{pid?: number, processStartToken?: string|null, startedAt?: string}} */
274
+ let occupant = {};
275
+ for (let attempt = 0; attempt < 20; attempt += 1) {
276
+ const record = { pid: process.pid, processStartToken: processStartToken(process.pid), startedAt: new Date().toISOString() };
277
+ try {
278
+ const fd = openSync(path, "wx", 0o600);
279
+ try {
280
+ writeSync(fd, JSON.stringify(record));
281
+ } finally {
282
+ closeSync(fd);
283
+ }
284
+ return {
285
+ ...record,
286
+ release() {
287
+ try {
288
+ unlinkSync(path);
289
+ } catch (error) {
290
+ if (errorCode(error) !== "ENOENT") throw error;
291
+ }
292
+ },
293
+ };
294
+ } catch (error) {
295
+ if (errorCode(error) !== "EEXIST") throw error;
296
+ }
297
+ try {
298
+ occupant = /** @type {{pid?: number, processStartToken?: string|null}} */ (JSON.parse(readFileSync(path, "utf8")));
299
+ } catch {
300
+ occupant = {};
301
+ }
302
+ if (!watchLockStale(occupant)) {
303
+ throw new Error(`campaign watch is already running (pid ${occupant.pid})`);
304
+ }
305
+ try {
306
+ unlinkSync(path);
307
+ } catch (error) {
308
+ if (errorCode(error) !== "ENOENT") throw error;
309
+ }
310
+ }
311
+ throw new Error("campaign watch lock contention did not settle");
312
+ }
313
+
314
+ /**
315
+ * @param {{pid?: number, processStartToken?: string|null}} occupant
316
+ * @returns {boolean}
317
+ */
318
+ function watchLockStale(occupant) {
319
+ if (typeof occupant.pid !== "number") return true;
320
+ if (!pidAlive(occupant.pid)) return true;
321
+ return Boolean(occupant.processStartToken) && processStartToken(occupant.pid) !== occupant.processStartToken;
322
+ }
323
+
324
+
325
+ /**
326
+ * @param {string} campaignId
327
+ * @param {CliValues} values
328
+ */
329
+ function init(campaignId, values) {
330
+ const cwd = resolve(values.cwd ?? ".");
331
+ const runsDir = join(cwd, ".runs");
332
+ const goal = textValue(values.goal, "--goal");
333
+ const contracts = contractManifest(values.contract);
334
+ const created = initializeCampaign(runsDir, { campaignId, goal, contracts, landBranch: values.landBranch });
335
+ renderHandoff(created.path, runsDir);
336
+ process.stdout.write(`[campaign] ${campaignId} initialized · ${created.path} · landBranch ${created.campaign.landBranch} · ${created.campaign.contracts.length} contract(s)\n`);
337
+ if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
338
+ }
339
+
340
+ /**
341
+ * Read each `--contract` path and record the digest of its authored bytes. The
342
+ * contract is not validated here: a manifest entry may name a file a
343
+ * predecessor will create, so validation happens at launch.
344
+ *
345
+ * @param {string[]|undefined} paths
346
+ * @returns {{path: string, digest: string}[]}
347
+ */
348
+ function contractManifest(paths) {
349
+ return (paths ?? []).map((path) => {
350
+ const absolute = resolve(path);
351
+ return { path: absolute, digest: authoredContractDigest(absolute) };
352
+ });
353
+ }
354
+
355
+ /**
356
+ * @param {string} campaignId
357
+ * @param {CliValues} values
358
+ */
359
+ function attach(campaignId, values) {
360
+ const { path, runsDir, campaign } = selectCampaign(campaignId, values);
361
+ requireActive(campaign);
362
+ const tool = required(values.tool, "--tool");
363
+ const sessionId = required(values.sessionId, "--session-id");
364
+ const unavailable = Boolean(values.noTranscript);
365
+ const transcript = unavailable ? null : values.transcript;
366
+ if (!unavailable && typeof transcript !== "string") {
367
+ throw new TypeError("attach requires --transcript <absolute-path> or --no-transcript");
368
+ }
369
+ appendJournal(path, {
370
+ type: "session.attached",
371
+ eventId: values.eventId ?? randomUUID(),
372
+ at: new Date().toISOString(),
373
+ sessionId,
374
+ tool,
375
+ transcript,
376
+ transcriptUnavailable: unavailable,
377
+ format: unavailable ? null : (values.format ?? null),
378
+ cursor: values.cursor ?? null,
379
+ });
380
+ renderHandoff(path, runsDir);
381
+ process.stdout.write(`[campaign] session ${sessionId} attached to ${campaignId}\n`);
382
+ }
383
+
384
+ /**
385
+ * @param {string} campaignId
386
+ * @param {CliValues} values
387
+ */
388
+ function note(campaignId, values) {
389
+ const { path, runsDir, campaign } = selectCampaign(campaignId, values);
390
+ requireActive(campaign);
391
+ const kind = required(values.kind, "--kind");
392
+ if (!NOTE_KINDS.has(kind)) {
393
+ throw new TypeError(`--kind must be one of ${[...NOTE_KINDS].join(", ")}`);
394
+ }
395
+ for (const [kindName, flags] of Object.entries(NOTE_KIND_FLAGS)) {
396
+ if (kindName === kind) continue;
397
+ for (const flag of flags) {
398
+ const present = /** @type {Record<string, unknown>} */ (values)[camelFlag(`--${flag}`)];
399
+ if (present !== undefined) {
400
+ throw new TypeError(`--${flag} is only valid for --kind ${kindName}`);
401
+ }
402
+ }
403
+ }
404
+ /** @type {Record<string, unknown>} */
405
+ const entry = {
406
+ type: kind,
407
+ eventId: values.eventId ?? randomUUID(),
408
+ at: new Date().toISOString(),
409
+ sessionId: required(values.sessionId, "--session-id"),
410
+ text: textValue(values.text, "--text"),
411
+ };
412
+ if (kind === "decision") entry.decisionId = required(values.decisionId, "--decision-id");
413
+ if (kind === "supersede") entry.supersedes = required(values.supersedes, "--supersedes");
414
+ if (kind === "open-question") entry.questionId = required(values.questionId, "--question-id");
415
+ if (kind === "outcome" && values.runId !== undefined) entry.runId = required(values.runId, "--run-id");
416
+ appendJournal(path, entry);
417
+ renderHandoff(path, runsDir);
418
+ process.stdout.write(`[campaign] ${kind} noted\n`);
419
+ }
420
+
421
+ /**
422
+ * @param {string} campaignId
423
+ * @param {CliValues} values
424
+ */
425
+ function resolveQuestion(campaignId, values) {
426
+ const { path, runsDir, campaign } = selectCampaign(campaignId, values);
427
+ requireActive(campaign);
428
+ const questionId = required(values.questionId, "--question-id");
429
+ appendJournal(path, {
430
+ type: "question.resolved",
431
+ eventId: values.eventId ?? randomUUID(),
432
+ at: new Date().toISOString(),
433
+ sessionId: required(values.sessionId, "--session-id"),
434
+ questionId,
435
+ text: textValue(values.text, "--text"),
436
+ });
437
+ renderHandoff(path, runsDir);
438
+ process.stdout.write(`[campaign] question ${questionId} resolved\n`);
439
+ }
440
+
441
+ /**
442
+ * @param {string} campaignId
443
+ * @param {CliValues} values
444
+ */
445
+ function close(campaignId, values) {
446
+ const { path, runsDir } = selectCampaign(campaignId, values);
447
+ const closed = closeCampaign(path, { eventId: values.eventId ?? randomUUID() });
448
+ renderHandoff(path, runsDir);
449
+ process.stdout.write(`[campaign] ${closed.campaign.id} closed\n`);
450
+ if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
451
+ }
452
+
453
+ /**
454
+ * `campaign supervise <id>` (also spelled `supervise campaign <id>`): the
455
+ * idempotent re-invocation that drives the manifest. It takes the campaign's
456
+ * `coordinator.lock`, writes the campaign heartbeat, and launches each
457
+ * contract's run from the same controller snapshot the previous run recorded.
458
+ * A second invocation against a fresh heartbeat writes nothing and exits 0.
459
+ *
460
+ * @param {string} campaignId
461
+ * @param {CliValues} values
462
+ */
463
+ async function supervise(campaignId, values) {
464
+ const cwd = resolve(values.cwd ?? ".");
465
+ const runsDir = join(cwd, ".runs");
466
+ const { path } = resolveCampaign(runsDir, campaignId);
467
+ const outcome = await driveCampaignChain(path, {
468
+ repo: cwd,
469
+ allowMain: values.allowMain === true,
470
+ emit: (line) => process.stdout.write(`${line}\n`),
471
+ launch: async (contractPath, { baseRef, runDir }) => {
472
+ const child = detachSelf("run", contractPath, baseRef ? ["--base-ref", baseRef] : []);
473
+ if (child.pid === undefined) throw new Error("detached run has no pid");
474
+ await waitForBootstrap(runDir, child.pid, child);
475
+ process.stdout.write(`[campaign] launched ${contractPath} · pid ${child.pid}\n`);
476
+ },
477
+ });
478
+ process.stdout.write(`[campaign] ${campaignId} ${outcome.state} · ${outcome.launches} launch${outcome.launches === 1 ? "" : "es"}${outcome.reason ? ` · ${outcome.reason}` : ""}\n`);
479
+ if (outcome.state === "parked" || outcome.state === "stopped") process.exitCode = 1;
480
+ }
481
+
482
+ /**
483
+ * `campaign unpark <id>`: clear the campaign's attention once the run it
484
+ * points at is no longer parked, so `supervise campaign` can drive the chain
485
+ * again. `--force` skips the still-parked check.
486
+ *
487
+ * @param {string} campaignId
488
+ * @param {CliValues} values
489
+ */
490
+ function unpark(campaignId, values) {
491
+ const { path, runsDir } = selectCampaign(campaignId, values);
492
+ const result = unparkCampaign(path, {
493
+ runsDir,
494
+ force: values.force === true,
495
+ eventId: values.eventId ?? randomUUID(),
496
+ });
497
+ process.stdout.write(`[campaign] ${result.campaign.id} unparked · ${result.cleared.code} cleared\n`);
498
+ }
499
+
500
+ /**
501
+ * @param {string} campaignId
502
+ * @param {CliValues} values
503
+ */
504
+ function show(campaignId, values) {
505
+ const { path, runsDir } = selectCampaign(campaignId, values);
506
+ process.stdout.write(renderHandoff(path, runsDir));
507
+ }
508
+
509
+ /**
510
+ * User-pull campaign sync: attach the session once per day when it is not
511
+ * attached yet, then print the campaign status header, the newest linked
512
+ * run's status.json summary, and the unseen journal events after the session
513
+ * cursor. sync never writes the cursor: only `ack` does.
514
+ *
515
+ * @param {string} campaignId
516
+ * @param {CliValues} values
517
+ */
518
+ function sync(campaignId, values) {
519
+ const { path, runsDir } = selectCampaign(campaignId, values);
520
+ const sessionId = required(values.sessionId, "--session-id");
521
+ const cursorId = sessionCursorId(sessionId);
522
+ attachSessionOnceDaily(path, runsDir, sessionId);
523
+ const campaign = readCampaign(path);
524
+ const seen = watchJournal(path, { cursor: cursorId, readOnly: true });
525
+ const header = `campaign ${campaign.id} · status ${campaign.status}`;
526
+ const runLine = latestRunStatusLine(runsDir, campaign);
527
+ let output = `${header}\n${runLine}\n`;
528
+ let index = 0;
529
+ for (; index < seen.events.length; index += 1) {
530
+ const event = seen.events[index];
531
+ const line = `${event.at} ${event.type} ${journalEntryText(event)}\n`;
532
+ if (Buffer.byteLength(output + line, "utf8") <= SYNC_OUTPUT_MAX_BYTES - 64) output += line;
533
+ else break;
534
+ }
535
+ if (index < seen.events.length) output += `sync truncated: ${seen.events.length - index} more events\n`;
536
+ process.stdout.write(output);
537
+ }
538
+
539
+ /**
540
+ * @param {string} campaignId
541
+ * @param {CliValues} values
542
+ */
543
+ function ack(campaignId, values) {
544
+ const { path } = selectCampaign(campaignId, values);
545
+ const sessionId = required(values.sessionId, "--session-id");
546
+ const eventId = required(values.eventId, "--event-id");
547
+ const cursorId = sessionCursorId(sessionId);
548
+ const position = acknowledgeJournalEvent(path, cursorId, eventId);
549
+ process.stdout.write(`[campaign] session ${sessionId} acknowledged up to ${position.eventId}\n`);
550
+ }
551
+
552
+ /**
553
+ * @param {string} runsDir
554
+ * @param {Campaign} campaign
555
+ * @returns {string}
556
+ */
557
+ function latestRunStatusLine(runsDir, campaign) {
558
+ const runId = campaign.linkedRunIds.at(-1);
559
+ if (!runId) return "run: none linked yet";
560
+ const status = /** @type {Record<string, any>|null} */ (readJsonTolerant(join(runsDir, runId, "status.json")));
561
+ if (!status) return `run ${runId}: no status.json yet`;
562
+ const controllerState = status.controller?.state ?? "none";
563
+ return `run ${runId} · ${status.summary ?? ""} · controller ${controllerState}`;
564
+ }
565
+
566
+ /**
567
+ * @param {Record<string, unknown>} entry
568
+ * @returns {string}
569
+ */
570
+ function journalEntryText(entry) {
571
+ if (typeof entry.text === "string" && entry.text) return entry.text;
572
+ if (entry.type === "session.attached") return `session ${entry.sessionId} attached (${entry.tool})`;
573
+ if (entry.type === "run.registered") return `run ${entry.runId} registered`;
574
+ return String(entry.type);
575
+ }
576
+
577
+ /**
578
+ * Append one session.attached journal entry per day when the session has no
579
+ * attach for today yet. The entry reuses the attach journal shape with
580
+ * --no-transcript semantics: no transcript path is known here, so the record
581
+ * is transcriptUnavailable with null transcript and format. The tool is
582
+ * inherited from the session's newest recorded attach (fallback "sync") so
583
+ * the session lineage stays truthful.
584
+ *
585
+ * @param {string} campaignPath
586
+ * @param {string} runsDir
587
+ * @param {string} sessionId
588
+ */
589
+ function attachSessionOnceDaily(campaignPath, runsDir, sessionId) {
590
+ const attaches = readJournal(campaignPath).filter(
591
+ (entry) => entry.type === "session.attached" && entry.sessionId === sessionId,
592
+ );
593
+ const newest = attaches.at(-1);
594
+ if (newest !== undefined && localDay(String(newest.at)) === localDay(new Date().toISOString())) return;
595
+ const tool = typeof newest?.tool === "string" && newest.tool.trim() ? newest.tool : "sync";
596
+ appendJournal(campaignPath, {
597
+ type: "session.attached",
598
+ eventId: randomUUID(),
599
+ at: new Date().toISOString(),
600
+ sessionId,
601
+ tool,
602
+ transcript: null,
603
+ transcriptUnavailable: true,
604
+ format: null,
605
+ cursor: null,
606
+ });
607
+ renderHandoff(campaignPath, runsDir);
608
+ }
609
+
610
+ /** @param {string} sessionId @returns {string} */
611
+ function sessionCursorId(sessionId) {
612
+ if (!/^[A-Za-z0-9._-]{1,120}$/u.test(sessionId)) {
613
+ throw new TypeError("--session-id must be letters, digits, dots, underscores or dashes");
614
+ }
615
+ return `session-${sessionId}`;
616
+ }
617
+
618
+ /**
619
+ * @param {CliValues} values
620
+ */
621
+ function listCampaigns(values) {
622
+ const cwd = resolve(values.cwd ?? ".");
623
+ const runsDir = join(cwd, ".runs");
624
+ const { campaigns, corrupt } = discoverCampaigns(runsDir);
625
+ if (!campaigns.length && !corrupt.length) {
626
+ process.stdout.write("[campaign] none\n");
627
+ return;
628
+ }
629
+ for (const { campaign, path } of campaigns) {
630
+ const updated = campaign.updatedAt;
631
+ process.stdout.write(
632
+ `[campaign] ${campaign.id} · ${campaign.status} · ${campaign.linkedRunIds.length} linked runs · updated ${updated} · ${path}\n`,
633
+ );
634
+ }
635
+ for (const entry of corrupt) {
636
+ process.stdout.write(`[campaign] ${entry.id} · corrupt · ${entry.error.message} · ${entry.path}\n`);
637
+ }
638
+ }
639
+
640
+ /**
641
+ * @param {string} campaignId
642
+ * @param {CliValues} values
643
+ * @returns {{path: string, runsDir: string, campaign: Campaign}}
644
+ */
645
+ function selectCampaign(campaignId, values) {
646
+ const cwd = resolve(values.cwd ?? ".");
647
+ const runsDir = join(cwd, ".runs");
648
+ return { ...resolveCampaign(runsDir, campaignId), runsDir };
649
+ }
650
+
651
+ /**
652
+ * @param {Campaign} campaign
653
+ */
654
+ function requireActive(campaign) {
655
+ if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaign.id}`);
656
+ }
657
+
658
+ /**
659
+ * @param {string} iso
660
+ * @returns {string}
661
+ */
662
+ function localDay(iso) {
663
+ const date = new Date(iso);
664
+ const month = String(date.getMonth() + 1).padStart(2, "0");
665
+ const day = String(date.getDate()).padStart(2, "0");
666
+ return `${date.getFullYear()}-${month}-${day}`;
667
+ }
668
+
669
+ /**
670
+ * Strict per-operation parsing with node:util.parseArgs: unknown options,
671
+ * missing values, and extra positionals are rejected; flags are scoped to the
672
+ * operation that declares them.
673
+ *
674
+ * @param {string[]} args
675
+ * @param {keyof typeof OPERATION_OPTIONS} operation
676
+ * @returns {{positional: string[], values: CliValues}}
677
+ */
678
+ function parseArgs(args, operation) {
679
+ const parsed = parseFlags({
680
+ args,
681
+ options: OPERATION_OPTIONS[operation],
682
+ allowPositionals: true,
683
+ strict: true,
684
+ });
685
+ /** @type {Record<string, unknown>} */
686
+ const values = {};
687
+ for (const [key, value] of Object.entries(parsed.values)) values[camelFlag(`--${key}`)] = value;
688
+ return { positional: parsed.positionals, values: /** @type {CliValues} */ (values) };
689
+ }
690
+
691
+ /**
692
+ * @param {string} flag
693
+ * @returns {string}
694
+ */
695
+ function camelFlag(flag) {
696
+ return flag.replace(/^--/u, "").replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase());
697
+ }
698
+
699
+ /**
700
+ * @param {unknown} value
701
+ * @param {string} label
702
+ * @returns {string}
703
+ */
704
+ function textValue(value, label) {
705
+ return required(value === "-" ? readFileSync(0, "utf8").trim() : value, label);
706
+ }
707
+
708
+ /**
709
+ * @param {unknown} value
710
+ * @param {string} label
711
+ * @returns {string}
712
+ */
713
+ function required(value, label) {
714
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${label} requires a value`);
715
+ return value;
716
+ }
717
+
718
+ /** @param {string} value @returns {number} */
719
+ function positiveIntervalMs(value) {
720
+ const seconds = Number(value);
721
+ if (!Number.isFinite(seconds) || seconds <= 0) throw new TypeError("--interval must be a positive number of seconds");
722
+ return Math.floor(seconds * 1_000);
723
+ }
724
+
725
+ function usage() {
726
+ process.stderr.write(
727
+ "usage: faberun campaign <init|watch|attach|note|resolve|close|supervise|unpark|show|list|sync|ack> <campaign-id> [--cwd <dir>] ...\n",
728
+ );
729
+ process.exitCode = 2;
730
+ }