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,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
|
+
}
|