faberun 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -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
|
+
}
|