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,359 @@
1
+ /**
2
+ * Direct notification dispatcher (TECH-SPEC lean, rule 6). On `node.terminal`,
3
+ * `run.terminal` and `attention` the controller renders a one-line message
4
+ * from a fixed per-type template, calls `FABERUN_NOTIFY_BIN` with the
5
+ * event as JSON on stdin, and appends a receipt (`delivered`, `failed` or
6
+ * `no_transport`, with the timestamp) to `<run-dir>/notify.jsonl`. Delivery is
7
+ * lossy: an event is attempted once, a failure schedules no further attempt and
8
+ * is never requeued, and the controller never waits on a retry it will not
9
+ * make. The next read of the run's own artefacts carries the full state. With
10
+ * no transport bound (`FABERUN_NOTIFY_BIN` unset) nothing is spawned and
11
+ * a `no_transport` receipt is recorded instead — there is no implicit desktop
12
+ * fallback. The macOS notifier is reachable only by setting
13
+ * `FABERUN_NOTIFY_BIN=os-macos`, an explicit opt-in, never a default.
14
+ */
15
+
16
+ import { spawn as defaultSpawn } from "node:child_process";
17
+ import { createHash } from "node:crypto";
18
+ import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { createMacosNotifier } from "./os-macos.mjs";
21
+ import { errorMessage } from "../util.mjs";
22
+
23
+ export const NOTIFY_BIN_ENV = "FABERUN_NOTIFY_BIN";
24
+ const MACOS_TRANSPORT = "os-macos";
25
+ export const NOTIFY_LOG_FILE = "notify.jsonl";
26
+ /**
27
+ * The bounded retry budget the dispatcher used to spend before giving up.
28
+ * `campaign/metrics.mjs` still reads it through `notifyReceiptRate`: a `failed`
29
+ * receipt at the final attempt counts as settled. Delivery is lossy now, so no
30
+ * receipt carries an attempt past the first and a failed delivery no longer
31
+ * satisfies that indicator — the metric is unchanged and reports the drop.
32
+ */
33
+ export const MAX_ATTEMPTS = 3;
34
+
35
+ const SUMMARY_CHARS = 200;
36
+
37
+ /**
38
+ * The append-only, campaign-level record the managed `AGENTS.md` signal block
39
+ * summarises. It lives at `<runs-dir>/inbox.jsonl` so a notification that
40
+ * belongs to no single run — a campaign watcher line — has a durable home that
41
+ * is not a run directory.
42
+ *
43
+ * Schema, one JSON object per line:
44
+ * `{schemaVersion, eventId, at, type, campaignId, runId, nodeId, status,
45
+ * errorCode, dedupeKey, summary}`. `eventId` is the sha256 hex of `dedupeKey`,
46
+ * and an entry is appended only when no existing entry carries the same
47
+ * `dedupeKey` (first write wins). Concurrent writers append whole lines with a
48
+ * single `O_APPEND` write each, so lines never interleave; the check-then-append
49
+ * is not atomic, so two writers racing on the same key may both append, and
50
+ * readers collapse repeated keys. A torn trailing line from a crash is skipped
51
+ * by `readInbox`.
52
+ */
53
+ export const INBOX_FILE = "inbox.jsonl";
54
+ export const INBOX_SCHEMA_VERSION = 1;
55
+
56
+ /**
57
+ * The named warning `doctor`, `preflight` and the foreground launch command
58
+ * emit when no human transport is bound. It is a warning, not an error: the
59
+ * opt-in is intentional and no default exists on any platform.
60
+ */
61
+ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is configured (FABERUN_NOTIFY_BIN unset): terminal events reach only .runs/inbox.jsonl and the AGENTS.md managed block";
62
+
63
+ /** @typedef {Record<string, unknown>} JsonObject */
64
+ /** @typedef {{schemaVersion: number, eventId: string, at: string, type: string, campaignId: string|null, runId: string|null, nodeId: string|null, status: string|null, errorCode: string|null, dedupeKey: string, summary: string}} InboxEntry */
65
+ /** @typedef {{type: string, dedupeKey: string, summary: string, at?: string, campaignId?: string|null, runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null}} InboxEvent */
66
+ /** @typedef {{type: "node.terminal"|"run.terminal"|"attention", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string}} NotifyEvent */
67
+ /** @typedef {{ok: boolean, error?: string, noTransport?: boolean}} DeliveryResult */
68
+
69
+ /**
70
+ * Render the fixed one-line message for an event, from counters and
71
+ * identifiers only (node id, run id or directory, state, attempt, error
72
+ * code, done/total, cost), never from model text:
73
+ * `node <id> failed · run <id> · attempt 2 · verification_failed · resume <run-dir>`
74
+ * `run <id> done · 3/3 nodes · $4.21`
75
+ * `node <id> needs you · run <id> · <error code>`
76
+ * `runDir` and `costUsd`, when present on the event, come from the run's own
77
+ * `status.json` (`NotifyQueue.enqueue` reads it) — never from the model.
78
+ *
79
+ * @param {NotifyEvent} event
80
+ * @returns {string}
81
+ */
82
+ export function renderNotification(event) {
83
+ const runId = event.runId ?? "-";
84
+ switch (event.type) {
85
+ case "node.terminal": {
86
+ const ok = event.status === "done" || event.status === "no-op";
87
+ const errorPart = event.errorCode ? ` · ${event.errorCode}` : "";
88
+ const resumePart = !ok && event.runDir ? ` · resume ${event.runDir}` : "";
89
+ return truncate(`node ${event.nodeId ?? "-"} ${event.status ?? "-"} · run ${runId} · attempt ${event.attempt ?? 0}${errorPart}${resumePart}`);
90
+ }
91
+ case "run.terminal": {
92
+ const done = event.done ?? 0;
93
+ const total = event.total ?? 0;
94
+ const state = total > done ? "attention" : "done";
95
+ const costPart = typeof event.costUsd === "number" ? ` · $${event.costUsd.toFixed(2)}` : "";
96
+ return truncate(`run ${runId} ${state} · ${done}/${total} nodes${costPart}`);
97
+ }
98
+ case "attention": {
99
+ const subject = event.nodeId ? `node ${event.nodeId} needs you · run ${runId}` : `run ${runId} needs you`;
100
+ const errorPart = event.errorCode ? ` · ${event.errorCode}` : "";
101
+ return truncate(`${subject}${errorPart}`);
102
+ }
103
+ default:
104
+ throw new TypeError(`renderNotification: unknown event type ${String(event.type)}`);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The run's total cost so far, read from its own `status.json` (the single
110
+ * source `writeStatusArtifacts` refreshes every tick). Missing or unreadable
111
+ * is `null`: a notification never blocks or fails on this being unavailable.
112
+ *
113
+ * @param {string} runDir
114
+ * @returns {number|null}
115
+ */
116
+ function readRunCostUsd(runDir) {
117
+ try {
118
+ const payload = JSON.parse(readFileSync(join(runDir, "status.json"), "utf8"));
119
+ const costUsd = payload?.usage?.costUsd;
120
+ return typeof costUsd === "number" ? costUsd : null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /** @param {string} value @returns {string} */
127
+ function truncate(value) {
128
+ return value.length <= SUMMARY_CHARS ? value : `${value.slice(0, SUMMARY_CHARS - 1)}…`;
129
+ }
130
+
131
+ /**
132
+ * The named no-transport warning, or null when a transport is bound. The empty
133
+ * string counts as unset, exactly as `deliverNotification` reads it.
134
+ *
135
+ * @param {NodeJS.ProcessEnv} [env]
136
+ * @returns {string|null}
137
+ */
138
+ export function noTransportWarning(env = process.env) {
139
+ return env[NOTIFY_BIN_ENV] ? null : NOTIFY_NO_TRANSPORT_WARNING;
140
+ }
141
+
142
+ /**
143
+ * What `campaign watch --wake` must say about waking. No adapter declares
144
+ * `canWake: true` (`os-macos` is `canWake: false`), so the wake verb records
145
+ * to the inbox and the managed block and never implies a session was woken.
146
+ *
147
+ * @param {string|undefined} [bin]
148
+ * @returns {string}
149
+ */
150
+ export function wakeCapabilityNotice(bin = process.env[NOTIFY_BIN_ENV]) {
151
+ if (!bin) {
152
+ return "no notify transport is configured; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; no session is woken";
153
+ }
154
+ if (bin === MACOS_TRANSPORT) {
155
+ return "os-macos cannot wake a session (canWake: false); --wake records to .runs/inbox.jsonl and the AGENTS.md managed block";
156
+ }
157
+ return `notify transport ${bin} declares canWake: false; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; no session is woken`;
158
+ }
159
+
160
+ /** @param {string} runsDir @returns {string} */
161
+ export function inboxPath(runsDir) {
162
+ return join(runsDir, INBOX_FILE);
163
+ }
164
+
165
+ /**
166
+ * Read every committed inbox entry. A missing file is `[]`; a torn or
167
+ * unparsable line is skipped, exactly as `alreadyNotified` treats one.
168
+ *
169
+ * @param {string} runsDir
170
+ * @returns {InboxEntry[]}
171
+ */
172
+ export function readInbox(runsDir) {
173
+ let text;
174
+ try {
175
+ text = readFileSync(inboxPath(runsDir), "utf8");
176
+ } catch {
177
+ return [];
178
+ }
179
+ /** @type {InboxEntry[]} */
180
+ const entries = [];
181
+ for (const line of text.split("\n")) {
182
+ if (!line.trim()) continue;
183
+ try {
184
+ const parsed = JSON.parse(line);
185
+ if (parsed && typeof parsed === "object") entries.push(/** @type {InboxEntry} */ (parsed));
186
+ } catch {
187
+ // A torn trailing line was never a committed entry.
188
+ }
189
+ }
190
+ return entries;
191
+ }
192
+
193
+ /**
194
+ * Append one entry unless its `dedupeKey` is already present. `eventId` is the
195
+ * hash of the key, so the identity is stable across processes and restarts.
196
+ *
197
+ * @param {string} runsDir
198
+ * @param {InboxEvent} event
199
+ * @returns {{appended: boolean, eventId: string, entry?: InboxEntry}}
200
+ */
201
+ export function appendInbox(runsDir, event) {
202
+ if (typeof event.dedupeKey !== "string" || !event.dedupeKey) {
203
+ throw new TypeError("appendInbox: an entry requires a non-empty dedupeKey");
204
+ }
205
+ const eventId = createHash("sha256").update(event.dedupeKey).digest("hex");
206
+ if (readInbox(runsDir).some((entry) => entry.dedupeKey === event.dedupeKey)) {
207
+ return { appended: false, eventId };
208
+ }
209
+ /** @type {InboxEntry} */
210
+ const entry = {
211
+ schemaVersion: INBOX_SCHEMA_VERSION,
212
+ eventId,
213
+ at: event.at ?? new Date().toISOString(),
214
+ type: event.type,
215
+ campaignId: event.campaignId ?? null,
216
+ runId: event.runId ?? null,
217
+ nodeId: event.nodeId ?? null,
218
+ status: event.status ?? null,
219
+ errorCode: event.errorCode ?? null,
220
+ dedupeKey: event.dedupeKey,
221
+ summary: event.summary,
222
+ };
223
+ mkdirSync(runsDir, { recursive: true });
224
+ const fd = openSync(inboxPath(runsDir), "a", 0o600);
225
+ try {
226
+ writeSync(fd, Buffer.from(`${JSON.stringify(entry)}\n`, "utf8"));
227
+ } finally {
228
+ closeSync(fd);
229
+ }
230
+ return { appended: true, eventId, entry };
231
+ }
232
+
233
+ /**
234
+ * Deliver one event through the bound transport. No transport bound resolves
235
+ * `{ok: false, noTransport: true}` without spawning anything.
236
+ *
237
+ * @param {{type: string, summary: string, campaignId?: string|null, [key: string]: unknown}} event
238
+ * @param {{bin?: string, spawn?: typeof defaultSpawn, timeoutMs?: number}} [options]
239
+ * @returns {Promise<DeliveryResult>}
240
+ */
241
+ function deliverNotification(event, options = {}) {
242
+ const bin = options.bin ?? process.env[NOTIFY_BIN_ENV];
243
+ if (!bin) return Promise.resolve({ ok: false, noTransport: true });
244
+ if (bin === MACOS_TRANSPORT) {
245
+ return createMacosNotifier({ spawn: options.spawn }).deliver(/** @type {any} */ (event));
246
+ }
247
+ return spawnDeliver(bin, event, options);
248
+ }
249
+
250
+ /**
251
+ * @param {string} bin
252
+ * @param {JsonObject} event
253
+ * @param {{spawn?: typeof defaultSpawn, timeoutMs?: number}} options
254
+ * @returns {Promise<DeliveryResult>}
255
+ */
256
+ function spawnDeliver(bin, event, { spawn = defaultSpawn, timeoutMs = 5_000 } = {}) {
257
+ return new Promise((resolveDelivery) => {
258
+ let child;
259
+ try {
260
+ child = spawn(bin, [], { stdio: ["pipe", "ignore", "pipe"], env: process.env });
261
+ } catch (error) {
262
+ resolveDelivery({ ok: false, error: errorMessage(error) });
263
+ return;
264
+ }
265
+ let settled = false;
266
+ /** @param {DeliveryResult} result */
267
+ const finish = (result) => {
268
+ if (settled) return;
269
+ settled = true;
270
+ clearTimeout(timer);
271
+ resolveDelivery(result);
272
+ };
273
+ let stderr = "";
274
+ child.stderr?.on("data", (chunk) => {
275
+ stderr = `${stderr}${chunk}`.slice(-1024);
276
+ });
277
+ child.once("error", (error) => finish({ ok: false, error: errorMessage(error) }));
278
+ child.once("close", (code) => finish(code === 0 ? { ok: true } : { ok: false, error: stderr || `notification exited ${code}` }));
279
+ const timer = setTimeout(() => {
280
+ try {
281
+ child.kill("SIGTERM");
282
+ } catch {
283
+ // ESRCH: the child already exited before the timeout kill; finish still resolves.
284
+ }
285
+ finish({ ok: false, error: `notification timed out after ${timeoutMs}ms` });
286
+ }, timeoutMs);
287
+ child.stdin.end(`${JSON.stringify(event)}\n`);
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Per-run notification dispatcher. `enqueue` renders the message, attempts
293
+ * delivery once, and appends one receipt to `<runDir>/notify.jsonl` whatever
294
+ * the outcome. There is no pending queue to hold a failure and no scheduled
295
+ * retry: a failed delivery is recorded and dropped. A resume or an audit reads
296
+ * the receipt log for what happened and `status.json` for the state, which the
297
+ * next controller re-derives from disk instead of replaying a notify backup.
298
+ */
299
+ export class NotifyQueue {
300
+ /**
301
+ * @param {{runDir: string, deliver?: typeof deliverNotification, now?: () => number}} options
302
+ */
303
+ constructor({ runDir, deliver = deliverNotification, now = () => Date.now() }) {
304
+ this.runDir = runDir;
305
+ this.deliver = deliver;
306
+ this.now = now;
307
+ }
308
+
309
+ /**
310
+ * Enrich the event with `runDir` and the run's current `costUsd` (from its
311
+ * own `status.json`, never the model) before rendering its summary, so
312
+ * `resume <run-dir>` and the run-terminal cost are counters and
313
+ * identifiers the templates can use without the caller supplying them.
314
+ * Delivery is awaited exactly once; a failure is recorded, not rescheduled.
315
+ *
316
+ * @param {NotifyEvent} event
317
+ * @returns {Promise<void>}
318
+ */
319
+ async enqueue(event) {
320
+ const enriched = { ...event, runDir: this.runDir, costUsd: readRunCostUsd(this.runDir) };
321
+ // A campaign-level line arrives already rendered; a run-level event is
322
+ // rendered from its counters here. Either way the stored summary is the
323
+ // one that reaches the transport and the receipt.
324
+ const summary = typeof enriched.summary === "string" && enriched.summary
325
+ ? enriched.summary
326
+ : renderNotification(enriched);
327
+ // Consumers deduplicate by eventId (the Ford adapter rejects an event without
328
+ // one), so every delivery carries a stable id derived from the dedupe key.
329
+ const eventId = enriched.eventId
330
+ ?? createHash("sha256").update(enriched.dedupeKey ?? JSON.stringify(enriched)).digest("hex");
331
+ /** @type {DeliveryResult} */
332
+ let result;
333
+ try {
334
+ result = await this.deliver({ ...enriched, summary, eventId });
335
+ } catch (error) {
336
+ // A transport that rejects is a failed delivery, not a controller fault:
337
+ // the receipt is still appended and the failure is dropped like any other.
338
+ result = { ok: false, error: errorMessage(error) };
339
+ }
340
+ /** @type {JsonObject} */
341
+ const receipt = {
342
+ eventId,
343
+ type: enriched.type,
344
+ runId: enriched.runId ?? null,
345
+ nodeId: enriched.nodeId ?? null,
346
+ nodeStatus: enriched.status ?? null,
347
+ errorCode: enriched.errorCode ?? null,
348
+ done: enriched.done ?? null,
349
+ total: enriched.total ?? null,
350
+ dedupeKey: enriched.dedupeKey ?? null,
351
+ summary,
352
+ attempt: 1,
353
+ status: result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
354
+ at: new Date(this.now()).toISOString(),
355
+ };
356
+ if (!result.ok && !result.noTransport) receipt.error = result.error ?? null;
357
+ appendFileSync(join(this.runDir, NOTIFY_LOG_FILE), `${JSON.stringify(receipt)}\n`);
358
+ }
359
+ }
@@ -0,0 +1,81 @@
1
+ import { spawn as defaultSpawn } from "node:child_process";
2
+ import { errorMessage, truncateChars } from "../util.mjs";
3
+
4
+ const TITLE = "faberun";
5
+ const BODY_CHARS = 200;
6
+ const SUBTITLE_CHARS = 80;
7
+
8
+ /** @typedef {Record<string, unknown>} JsonObject */
9
+ /** @typedef {{type: string, campaignId?: string, summary?: string, eventId?: string, at?: string, data?: JsonObject}} NotificationEvent */
10
+ /** @typedef {{id: string, capabilities: {canPush: boolean, canWake: boolean, canRenderAmbient: boolean}, deliver(event: NotificationEvent): Promise<{ok: boolean, error?: string}>}} NotifyAdapter */
11
+ /** @typedef {{once(event: string, listener: (...args: any[]) => void): unknown, kill(signal?: any): unknown, stderr?: {on(event: string, listener: (chunk: string|Buffer) => void): void}|null}} SpawnedChild */
12
+ /** @typedef {(command: string, args?: any, options?: any) => SpawnedChild} SpawnFunction */
13
+
14
+ /**
15
+ * Create the macOS notification adapter. On a non-darwin platform deliver
16
+ * resolves { ok: false, error: "unsupported platform" } without spawning.
17
+ *
18
+ * @param {{spawn?: SpawnFunction, platform?: string, timeoutMs?: number}} [options]
19
+ * @returns {NotifyAdapter}
20
+ */
21
+ export function createMacosNotifier({ spawn = defaultSpawn, platform = process.platform, timeoutMs = 5000 } = {}) {
22
+ return {
23
+ id: "os-macos",
24
+ capabilities: { canPush: true, canWake: false, canRenderAmbient: false },
25
+ deliver(event) {
26
+ return new Promise((resolve) => {
27
+ if (platform !== "darwin") {
28
+ resolve({ ok: false, error: "unsupported platform" });
29
+ return;
30
+ }
31
+ const summary = typeof event.summary === "string" ? event.summary : "";
32
+ const body = escapeAppleScript(truncateChars(summary, BODY_CHARS));
33
+ const campaignLabel = typeof event.campaignId === "string" ? event.campaignId : "campaign";
34
+ const subtitle = escapeAppleScript(truncateChars(`${campaignLabel} · ${event.type}`, SUBTITLE_CHARS));
35
+ const script = `display notification "${body}" with title "${TITLE}" subtitle "${subtitle}"`;
36
+ let child;
37
+ try {
38
+ child = spawn("osascript", ["-e", script]);
39
+ } catch (error) {
40
+ resolve({ ok: false, error: errorMessage(error) });
41
+ return;
42
+ }
43
+ let settled = false;
44
+ let stderr = "";
45
+ /** @type {ReturnType<typeof setTimeout>|undefined} */
46
+ let timer;
47
+ /** @param {{ok: boolean, error?: string}} result */
48
+ const finish = (result) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ clearTimeout(timer);
52
+ resolve(result);
53
+ };
54
+ child.once("error", (error) => finish({ ok: false, error: errorMessage(error) }));
55
+ child.once("close", (code) => {
56
+ finish(code === 0 ? { ok: true } : { ok: false, error: stderr.trim() || `osascript exited ${code}` });
57
+ });
58
+ child.stderr?.on("data", (chunk) => {
59
+ stderr = `${stderr}${String(chunk)}`.slice(-2048);
60
+ });
61
+ timer = setTimeout(() => {
62
+ try {
63
+ child.kill("SIGTERM");
64
+ } catch {
65
+ // ESRCH: the child already exited before the timeout kill; finish still resolves.
66
+ }
67
+ finish({ ok: false, error: `osascript timed out after ${timeoutMs}ms` });
68
+ }, timeoutMs);
69
+ });
70
+ },
71
+ };
72
+ }
73
+
74
+ /**
75
+ * @param {string} value
76
+ * @returns {string}
77
+ */
78
+ function escapeAppleScript(value) {
79
+ return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
80
+ }
81
+
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Whether the paths a packet declared are paths the controller can actually
3
+ * observe, and whether its verification commands cover what it writes.
4
+ *
5
+ * Both answers come from git: a declared write inside an ignored directory
6
+ * leaves no diff to inspect, so the node would pass on evidence that cannot
7
+ * exist. These are warnings, not refusals -- the author may mean it -- but they
8
+ * are the warnings worth reading.
9
+ *
10
+ * It lived in `contract/index.mjs` and it runs `git check-ignore`: repository
11
+ * knowledge validating a contract, which is why it now sits in `repo/`.
12
+ */
13
+ import { errorCode, exitStatus } from "../util.mjs";
14
+ import { execFileSync } from "node:child_process";
15
+ import { join, resolve } from "node:path";
16
+ import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+
19
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
20
+
21
+ const ALWAYS_UNOBSERVABLE_ROOTS = new Set([".runs", ".git"]);
22
+ /**
23
+ * @param {ValidatedNode} node
24
+ * @param {number} index
25
+ * @param {string} cwd
26
+ * @returns {string[]}
27
+ */
28
+ export function unsnapshottedWriteWarnings(node, index, cwd) {
29
+ const declarations = /** @type {{kind: "writeFiles"|"writeRoots", path: string}[]} */ ([
30
+ ...(node.taskPacket.writeFiles ?? []).map((path) => ({ kind: "writeFiles", path })),
31
+ ...(node.taskPacket.writeRoots ?? []).map((path) => ({ kind: "writeRoots", path })),
32
+ ]);
33
+ const hidden = new Map();
34
+ for (const declaration of declarations) {
35
+ if (!isUnobservableDeclaredPath(cwd, declaration.path, declaration.kind)) continue;
36
+ const path = process.platform === "win32" ? String(declaration.path).replaceAll("\\", "/") : String(declaration.path);
37
+ const root = path.split("/")[0];
38
+ const key = `${declaration.kind}:${root}`;
39
+ hidden.set(key, { kind: declaration.kind, root, path });
40
+ }
41
+ return [...hidden.values()].map(({ kind, root, path }) =>
42
+ `nodes[${index}] (${node.id}): ${path.includes("/") ? `${kind} under ${root}/` : `${kind} ${path}`} are outside the workspace snapshot, so the closed-scope gate cannot observe them`,
43
+ );
44
+ }
45
+ /**
46
+ * @param {string|undefined} cwd
47
+ * @param {string} declaredPath
48
+ * @param {"writeFiles"|"writeRoots"} kind
49
+ * @returns {boolean}
50
+ */
51
+ function isUnobservableDeclaredPath(cwd, declaredPath, kind) {
52
+ if (!cwd) return false;
53
+ const path = process.platform === "win32" ? String(declaredPath).replaceAll("\\", "/") : String(declaredPath);
54
+ const root = path.split("/")[0];
55
+ if (ALWAYS_UNOBSERVABLE_ROOTS.has(root)) return true;
56
+ return gitDeclaredPathState(cwd, path, kind) === "ignored";
57
+ }
58
+ /**
59
+ * @param {string} cwd
60
+ * @param {string} path
61
+ * @param {"writeFiles"|"writeRoots"} kind
62
+ * @returns {"tracked"|"ignored"|"visible"|"unknown"}
63
+ */
64
+ function gitDeclaredPathState(cwd, path, kind) {
65
+ const literals = kind === "writeRoots" ? [path, `${path}/`] : [path];
66
+ try {
67
+ execFileSync("git", ["-C", cwd, "ls-files", "--cached", "--error-unmatch", "--", path], {
68
+ stdio: ["ignore", "ignore", "ignore"],
69
+ });
70
+ return "tracked";
71
+ } catch (error) {
72
+ if (exitStatus(error) !== 1) return "unknown";
73
+ }
74
+
75
+ let hasFaberunIgnore = false;
76
+ try {
77
+ hasFaberunIgnore = lstatSync(resolve(cwd, ".faberunignore")).isFile();
78
+ } catch (error) {
79
+ if (errorCode(error) !== "ENOENT") return "unknown";
80
+ }
81
+ const faberunIgnore = hasFaberunIgnore ? resolve(cwd, ".faberunignore") : undefined;
82
+ for (const literal of literals) {
83
+ const combined = checkCombinedGitIgnore(cwd, literal, faberunIgnore);
84
+ if (combined === "unknown") return "unknown";
85
+ if (combined === false) return "visible";
86
+ }
87
+ return "ignored";
88
+ }
89
+ /**
90
+ * Use the same combined Git enumeration as workspace snapshots for paths
91
+ * that already exist. Missing declarations fall through to check-ignore so
92
+ * validation can still warn about future paths hidden by a rule.
93
+ *
94
+ * @param {string} cwd
95
+ * @param {string} path
96
+ * @param {string|undefined} extraExclude
97
+ * @returns {boolean|"unknown"|undefined}
98
+ */
99
+ function checkCombinedGitIgnore(cwd, path, extraExclude) {
100
+ let exists = true;
101
+ try {
102
+ lstatSync(resolve(cwd, path));
103
+ } catch (error) {
104
+ if (errorCode(error) === "ENOENT") exists = false;
105
+ else return "unknown";
106
+ }
107
+ if (!exists) {
108
+ return checkMissingCombinedGitIgnore(cwd, path, extraExclude);
109
+ }
110
+
111
+ try {
112
+ const args = ["-C", cwd, "ls-files", "--others", "--exclude-standard"];
113
+ if (extraExclude) args.push(`--exclude-from=${extraExclude}`);
114
+ args.push("-z", "--", path);
115
+ const output = execFileSync("git", args, { encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
116
+ return output.length === 0;
117
+ } catch {
118
+ return "unknown";
119
+ }
120
+ }
121
+ /**
122
+ * A missing path cannot be checked with the snapshot enumeration. Ask Git for
123
+ * the standard result in the real repository, then ask Git whether the extra
124
+ * source matched in an isolated context so repository `.gitignore` files
125
+ * cannot outrank it.
126
+ *
127
+ * @param {string} cwd
128
+ * @param {string} path
129
+ * @param {string|undefined} extraExclude
130
+ * @returns {boolean|"unknown"}
131
+ */
132
+ function checkMissingCombinedGitIgnore(cwd, path, extraExclude) {
133
+ const standard = checkGitIgnore(cwd, path);
134
+ if (!extraExclude || standard === "unknown") return standard;
135
+ const temporaryWorktree = mkdtempSync(join(tmpdir(), "faberun-ignore-check-"));
136
+ try {
137
+ execFileSync("git", ["init", "-q", temporaryWorktree], { stdio: ["ignore", "ignore", "ignore"] });
138
+ const temporaryGit = resolve(temporaryWorktree, ".git");
139
+ writeFileSync(resolve(temporaryGit, "info", "exclude"), readFileSync(extraExclude), { mode: 0o600 });
140
+ const args = ["--git-dir", temporaryGit, "--work-tree", temporaryWorktree, "-c", `core.excludesFile=${process.platform === "win32" ? "NUL" : "/dev/null"}`, "check-ignore", "--no-index", "--verbose", "--", path];
141
+ let customMatched;
142
+ try {
143
+ execFileSync("git", args, { stdio: ["ignore", "ignore", "ignore"] });
144
+ customMatched = true;
145
+ } catch (error) {
146
+ if (exitStatus(error) !== 1) return "unknown";
147
+ customMatched = false;
148
+ }
149
+
150
+ if (!customMatched) return standard;
151
+ try {
152
+ execFileSync("git", args.toSpliced(-3, 1, "--quiet"), { stdio: ["ignore", "ignore", "ignore"] });
153
+ return true;
154
+ } catch (error) {
155
+ return exitStatus(error) === 1 ? false : "unknown";
156
+ }
157
+ } catch {
158
+ return "unknown";
159
+ } finally {
160
+ rmSync(temporaryWorktree, { recursive: true, force: true });
161
+ }
162
+ }
163
+ /**
164
+ * Ask Git to classify a path even when it does not exist yet. For the
165
+ * optional runner ignore file, temporarily use Git's configured global
166
+ * exclude slot so Git remains the pattern parser.
167
+ *
168
+ * @param {string} cwd
169
+ * @param {string} path
170
+ * @param {string|undefined} [extraExclude]
171
+ * @returns {boolean|"unknown"}
172
+ */
173
+ function checkGitIgnore(cwd, path, extraExclude) {
174
+ const args = ["-C", cwd];
175
+ if (extraExclude) args.push("-c", `core.excludesFile=${extraExclude}`);
176
+ args.push("check-ignore", "--no-index", "--quiet", "--", path);
177
+ try {
178
+ execFileSync("git", args, { stdio: ["ignore", "ignore", "ignore"] });
179
+ return true;
180
+ } catch (error) {
181
+ return exitStatus(error) === 1 ? false : "unknown";
182
+ }
183
+ }
184
+ /**
185
+ * @param {ValidatedNode} node
186
+ * @param {number} index
187
+ * @returns {string[]}
188
+ */
189
+ export function commandCoverageWarnings(node, index) {
190
+ if (!Array.isArray(node.definitionOfDone) || node.definitionOfDone.length === 0) return [];
191
+ const dodText = node.definitionOfDone.map((item) => item.text).join("\n");
192
+ const warnings = [];
193
+ const lines = node.taskPacket.verification.map((command) => command.argv.join(" "));
194
+ for (const line of lines) {
195
+ const target = extractCommandTarget(line);
196
+ if (target && !dodText.includes(target)) {
197
+ warnings.push(`nodes[${index}] (${node.id}): command target "${target}" is not mentioned in any Definition of Done item`);
198
+ }
199
+ }
200
+ return warnings;
201
+ }
202
+ /**
203
+ * @param {string} line
204
+ * @returns {string|null}
205
+ */
206
+ function extractCommandTarget(line) {
207
+ const trimmed = line.trim();
208
+ const patterns = [
209
+ [/^cargo test\b/u, /cargo test(?:\s+\S+)*\s+([a-z_]+::[a-z_:]+)/u],
210
+ [/^pnpm exec vitest run\b/u, /vitest run\s+(\S+)/u],
211
+ [/^(?:pnpm exec )?playwright test\b/u, /playwright test\s+(\S+)/u],
212
+ [/^node\s+/u, /^node\s+(\S+\.(?:mjs|js))/u],
213
+ [/^(?:pnpm|npm) run\s+/u, /^(?:pnpm|npm) run\s+(\S+)/u],
214
+ ];
215
+ for (const [trigger, extract] of patterns) {
216
+ if (!trigger.test(trimmed)) continue;
217
+ return extract.exec(trimmed)?.[1] ?? null;
218
+ }
219
+ return null;
220
+ }