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,300 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ } from "node:fs";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { join, resolve } from "node:path";
9
+ import { writeJsonAtomic } from "../run/store.mjs";
10
+ import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
11
+ import { promoteRun } from "../repo/integrate.mjs";
12
+ import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
13
+ import { readCampaign } from "./record.mjs";
14
+ import { appendJournal, normalizeText, readJournalForDedupe } from "./journal.mjs";
15
+ import { readProjectionState } from "./projection.mjs";
16
+ import { handoffFromState, materializeHandoff } from "./handoff.mjs";
17
+
18
+ /** @typedef {Record<string, unknown>} JsonObject */
19
+ /** @typedef {{path: string, digest: string}} CampaignContract */
20
+ /** @typedef {{runId: string, contractPath?: string, branch: string, sha: string, previousSha: string|null, at: string}} PromotionRecord */
21
+ /** @typedef {{code: string, message: string, at: string, contractPath?: string, contractId?: string, runId?: string, node?: string|null, status?: string|null, resume?: string}} CampaignAttention */
22
+ /** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
23
+ /** @typedef {{type: string, eventId: string, at: string, sessionId?: string, text?: string, tool?: string, transcript?: string|null, transcriptUnavailable?: boolean, format?: string|null, cursor?: string|null, decisionId?: string, supersedes?: string, runId?: string, questionId?: string, campaignId?: string, nodeId?: string|null, phase?: string, checkpointsDone?: number, checkpointsTotal?: number, runtime?: string|null, state?: string, lastProgressAt?: string, attention?: string|null}} JournalEntry */
24
+ /** @typedef {{updatedAt: string|null, decisions: Record<string, JournalEntry>, questions: Record<string, JournalEntry>, constraints: JournalEntry[], intents: JournalEntry[], outcomes: JournalEntry[], sessions: JournalEntry[], next: JournalEntry|null, evicted: Record<string, number>}} Projection */
25
+ /** @typedef {{cursor: number, byte: number, size: number, projection: Projection}} ProjectionRecord */
26
+ /** @typedef {{id: string, exists: boolean, total: number, summary: string, attention: {id: string, status: string, note: string}[], unreadable: string|null}} RunSummary */
27
+ /** @typedef {{campaign: Campaign, updatedAt: string, linkedRuns: RunSummary[], activeDecisions: JournalEntry[], constraints: JournalEntry[], intents: JournalEntry[], outcomes: JournalEntry[], nextEntry: JournalEntry|null, questions: JournalEntry[], sessions: JournalEntry[], totals: {decisions: number, constraints: number, intents: number, outcomes: number, questions: number, sessions: number}, evicted: Record<string, number>}} Handoff */
28
+
29
+ /**
30
+ * @param {string} runsDir
31
+ * @param {{campaignId: string, goal: unknown, at?: string, contracts?: CampaignContract[], landBranch?: string}} options
32
+ * @returns {{path: string, campaign: Campaign}}
33
+ */
34
+ export function initializeCampaign(runsDir, { campaignId, goal, at = new Date().toISOString(), contracts = [], landBranch = undefined }) {
35
+ requireId(campaignId, "campaignId");
36
+ requireTimestamp(at, "at");
37
+ const path = campaignDir(runsDir, campaignId);
38
+ if (existsSync(path)) throw new Error(`campaign already exists: ${path}`);
39
+ mkdirSync(path, { recursive: true });
40
+ const validatedContracts = contracts.map((entry, index) => {
41
+ requireString(entry.path, `contracts[${index}].path`);
42
+ requirePacketHash(entry.digest, `contracts[${index}].digest`);
43
+ return { path: entry.path, digest: entry.digest };
44
+ });
45
+ const branch = landBranch ?? `campaign/${campaignId}`;
46
+ requireString(branch, "landBranch");
47
+ /** @type {Campaign} */
48
+ const campaign = {
49
+ id: campaignId,
50
+ goal: normalizeText(goal, "goal", GOAL_TEXT_BYTES),
51
+ status: "active",
52
+ linkedRunIds: [],
53
+ contracts: validatedContracts,
54
+ landBranch: branch,
55
+ promotions: [],
56
+ createdAt: at,
57
+ updatedAt: at,
58
+ };
59
+ writeJsonAtomic(join(path, CAMPAIGN_FILE), campaign);
60
+ appendJournal(path, { type: "campaign.initialized", at, eventId: randomUUID() });
61
+ return { path, campaign };
62
+ }
63
+
64
+ /**
65
+ * @param {string} runsDir
66
+ * @returns {{campaigns: {path: string, campaign: Campaign}[], corrupt: {id: string, path: string, error: Error}[]}}
67
+ */
68
+ export function discoverCampaigns(runsDir) {
69
+ const root = campaignsDir(runsDir);
70
+ if (!existsSync(root)) return { campaigns: [], corrupt: [] };
71
+ /** @type {{path: string, campaign: Campaign}[]} */
72
+ const campaigns = [];
73
+ /** @type {{id: string, path: string, error: Error}[]} */
74
+ const corrupt = [];
75
+ for (const name of readdirSync(root, { withFileTypes: true })
76
+ .filter((entry) => entry.isDirectory())
77
+ .map((entry) => entry.name)
78
+ .sort()) {
79
+ const path = join(root, name);
80
+ if (!existsSync(join(path, CAMPAIGN_FILE))) {
81
+ corrupt.push({ id: name, path, error: new Error(`campaign.json missing in ${path}`) });
82
+ continue;
83
+ }
84
+ try {
85
+ campaigns.push({ path, campaign: readCampaign(path) });
86
+ } catch (error) {
87
+ corrupt.push({ id: name, path, error: error instanceof Error ? error : new Error(String(error)) });
88
+ }
89
+ }
90
+ return { campaigns, corrupt };
91
+ }
92
+
93
+ /**
94
+ * @param {string} runsDir
95
+ * @param {string|null|undefined} [campaignId]
96
+ * @returns {{path: string, campaign: Campaign}}
97
+ */
98
+ export function resolveCampaign(runsDir, campaignId) {
99
+ if (campaignId !== undefined && campaignId !== null) {
100
+ requireId(campaignId, "campaignId");
101
+ const path = campaignDir(runsDir, campaignId);
102
+ return { path, campaign: readCampaign(path) };
103
+ }
104
+ const { campaigns, corrupt } = discoverCampaigns(runsDir);
105
+ if (corrupt.length) {
106
+ throw new Error(`corrupt campaign entries: ${corrupt.map((entry) => entry.id).join(", ")}`);
107
+ }
108
+ const active = campaigns.filter((entry) => entry.campaign.status === "active");
109
+ if (!active.length) {
110
+ if (campaigns.length) {
111
+ throw new Error(`no active campaign under ${campaignsDir(runsDir)}; all campaigns are closed`);
112
+ }
113
+ throw new Error(`no campaign found under ${campaignsDir(runsDir)}; initialize one with: runner.mjs campaign init`);
114
+ }
115
+ if (active.length > 1) {
116
+ const ids = active.map((entry) => entry.campaign.id).join(", ");
117
+ throw new Error(`multiple campaigns found (${ids}); choose one by id`);
118
+ }
119
+ return active[0];
120
+ }
121
+
122
+ /**
123
+ * @param {string} campaignPath
124
+ * @param {{at?: string, eventId?: string}} options
125
+ * @returns {{path: string, campaign: Campaign}}
126
+ */
127
+ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
128
+ requireTimestamp(at, "at");
129
+ const campaign = readCampaign(campaignPath);
130
+ if (campaign.status === "closed") throw new Error(`campaign already closed: ${campaign.id}`);
131
+ if (!readJournalForDedupe(campaignPath).some((entry) => entry.type === "retrospective")) {
132
+ throw new Error(`campaign ${campaign.id} has no recorded retrospective; record one with note --kind retrospective before close`);
133
+ }
134
+ const closed = /** @type {Campaign} */ ({ ...campaign, status: "closed", closedAt: at, updatedAt: at });
135
+ writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
136
+ appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
137
+ return { path: campaignPath, campaign: closed };
138
+ }
139
+
140
+ /**
141
+ * @param {string} campaignPath
142
+ * @param {string} runId
143
+ * @param {string} at
144
+ * @returns {Campaign}
145
+ */
146
+ export function registerRun(campaignPath, runId, at = new Date().toISOString()) {
147
+ requireId(runId, "runId");
148
+ requireTimestamp(at, "at");
149
+ const campaign = readCampaign(campaignPath);
150
+ if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
151
+ if (campaign.linkedRunIds.includes(runId)) {
152
+ campaign.updatedAt = at;
153
+ writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), campaign);
154
+ return campaign;
155
+ }
156
+ campaign.linkedRunIds.push(runId);
157
+ campaign.updatedAt = at;
158
+ writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), campaign);
159
+ appendJournal(campaignPath, { type: "run.registered", at, eventId: randomUUID(), runId });
160
+ return campaign;
161
+ }
162
+
163
+ /**
164
+ * The digest of a contract's authored bytes: the raw file, before validation
165
+ * canonicalizes or resolves anything. This is what a manifest entry records,
166
+ * so tampering between authoring and launch is detectable without validating
167
+ * the contract early (validation is deferred because a readFile may name a
168
+ * file a predecessor has not created yet).
169
+ *
170
+ * @param {string} contractPath
171
+ * @returns {string}
172
+ */
173
+ export function authoredContractDigest(contractPath) {
174
+ return createHash("sha256").update(readFileSync(contractPath)).digest("hex");
175
+ }
176
+
177
+ /**
178
+ * Refuse a manifest entry whose file no longer matches the bytes it recorded.
179
+ * The chain calls this at launch, before it validates N+1 against the landing
180
+ * branch.
181
+ *
182
+ * @param {CampaignContract} entry
183
+ * @returns {void}
184
+ */
185
+ export function assertContractManifestIntact(entry) {
186
+ requireString(entry.path, "contract path");
187
+ requirePacketHash(entry.digest, "contract digest");
188
+ const actual = authoredContractDigest(entry.path);
189
+ if (actual !== entry.digest) {
190
+ throw Object.assign(
191
+ new Error(`contract ${entry.path} changed after it was authored; refusing to launch bytes the manifest did not record`),
192
+ { code: "contract_authored_bytes_changed" },
193
+ );
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Persist one promotion in the campaign record. Idempotent by run id and the
199
+ * sha it landed: a re-invocation after a crash between the branch move and
200
+ * this write repairs the record without adding a second promotion.
201
+ *
202
+ * @param {string} campaignPath
203
+ * @param {PromotionRecord} entry
204
+ * @returns {PromotionRecord}
205
+ */
206
+ export function recordPromotion(campaignPath, entry) {
207
+ requireId(entry.runId, "promotion.runId");
208
+ requireString(entry.branch, "promotion.branch");
209
+ requireString(entry.sha, "promotion.sha");
210
+ requireTimestamp(entry.at, "promotion.at");
211
+ const campaign = readCampaign(campaignPath);
212
+ const existing = campaign.promotions.find((record) => record.runId === entry.runId && record.sha === entry.sha);
213
+ if (existing) return existing;
214
+ campaign.promotions.push(entry);
215
+ campaign.updatedAt = entry.at;
216
+ writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), campaign);
217
+ return entry;
218
+ }
219
+
220
+ /**
221
+ * Record a durable attention entry on the campaign and keep it active. The
222
+ * chain parks here when a contract's run did not succeed, when a contract no
223
+ * longer validates, or when two digests disagree; the message names the
224
+ * contract, the node and the status whenever they exist.
225
+ *
226
+ * @param {string} campaignPath
227
+ * @param {CampaignAttention} attention
228
+ * @returns {Campaign}
229
+ */
230
+ export function parkCampaign(campaignPath, attention) {
231
+ requireString(attention.code, "campaign.attention.code");
232
+ requireString(attention.message, "campaign.attention.message");
233
+ requireTimestamp(attention.at, "campaign.attention.at");
234
+ const campaign = readCampaign(campaignPath);
235
+ const parked = /** @type {Campaign} */ ({ ...campaign, attention, updatedAt: attention.at });
236
+ writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), parked);
237
+ return parked;
238
+ }
239
+
240
+ /**
241
+ * Promote a run onto the campaign's landing branch and record it. The branch
242
+ * name comes from the campaign record, never from the caller, so a campaign
243
+ * cannot be promoted somewhere its manifest does not name.
244
+ *
245
+ * @param {{campaignPath: string, repo: string, runId: string, runHead?: string|null, baseSha?: string|null, finalVerificationPassed: boolean, allowMain?: boolean, contractPath?: string}} args
246
+ * @returns {import("../repo/integrate.mjs").PromoteRecord}
247
+ */
248
+ export function promoteRunInCampaign({ campaignPath, repo, runId, runHead, baseSha, finalVerificationPassed, allowMain = false, contractPath }) {
249
+ const campaign = readCampaign(campaignPath);
250
+ if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
251
+ return promoteRun({
252
+ repo,
253
+ runId,
254
+ landBranch: campaign.landBranch,
255
+ runHead,
256
+ baseSha,
257
+ finalVerificationPassed,
258
+ allowMain,
259
+ onPromoted: (record) => {
260
+ recordPromotion(campaignPath, {
261
+ runId: record.runId,
262
+ ...(contractPath === undefined ? {} : { contractPath }),
263
+ branch: record.branch,
264
+ sha: record.sha,
265
+ previousSha: record.previousSha,
266
+ at: record.at,
267
+ });
268
+ },
269
+ });
270
+ }
271
+
272
+ /**
273
+ * @param {string} campaignPath
274
+ * @param {string} runsDir
275
+ * @returns {string}
276
+ */
277
+ export function renderHandoff(campaignPath, runsDir) {
278
+ const campaign = readCampaign(campaignPath);
279
+ const { state, cursor, byte, size, changed } = readProjectionState(campaignPath, campaign);
280
+ const handoff = handoffFromState(campaign, state, runsDir);
281
+ const text = materializeHandoff(campaignPath, handoff);
282
+ if (changed) writeJsonAtomic(join(campaignPath, PROJECTION_FILE), { cursor, byte, size, projection: state });
283
+ return text;
284
+ }
285
+
286
+ /**
287
+ * @param {string} runDir
288
+ * @returns {string|null}
289
+ */
290
+ export function renderRunHandoff(runDir) {
291
+ const contractPath = join(runDir, "contract.json");
292
+ if (!existsSync(contractPath)) return null;
293
+ const contract = /** @type {JsonObject} */ (JSON.parse(readFileSync(contractPath, "utf8")));
294
+ if (!contract.campaignId) return null;
295
+ const runsDir = resolve(runDir, "..");
296
+ const path = campaignDir(runsDir, /** @type {string} */ (contract.campaignId));
297
+ if (!existsSync(join(path, CAMPAIGN_FILE))) return null;
298
+ return renderHandoff(path, runsDir);
299
+ }
300
+
@@ -0,0 +1,347 @@
1
+ /**
2
+ * The campaign journal: an append-only log of material events, and the cursor
3
+ * machinery that lets a session read only what it has not seen.
4
+ *
5
+ * Append-only is the point. A campaign is the durable memory across sessions and
6
+ * across agents, so the journal is never rewritten -- `watchJournal` and
7
+ * `acknowledgeJournalEvent` move a per-session cursor over it instead, keyed by
8
+ * the journal's own `eventId` so two sessions cannot consume each other's place.
9
+ */
10
+ import { JOURNAL_FILE, JOURNAL_TEXT_BYTES, JOURNAL_WATCH_CURSOR_DIR, JOURNAL_WATCH_CURSOR_SCHEMA_VERSION } from "./layout.mjs";
11
+ import { boundedText, collapseLines } from "../util.mjs";
12
+ import { campaignIdOf } from "./record.mjs";
13
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, isAbsolute, join } from "node:path";
15
+ import { requireText, requireTimestamp } from "../contract/assert.mjs";
16
+ import { writeJsonAtomic } from "../run/store.mjs";
17
+
18
+ /** @typedef {import("./index.mjs").JournalEntry} JournalEntry */
19
+ /** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
20
+
21
+ const JOURNAL_TYPES = new Set([
22
+ "campaign.initialized",
23
+ "campaign.closed",
24
+ "campaign.unparked",
25
+ "run.registered",
26
+ "session.attached",
27
+ "intent",
28
+ "decision",
29
+ "supersede",
30
+ "constraint",
31
+ "outcome",
32
+ "next",
33
+ "open-question",
34
+ "question.resolved",
35
+ "retrospective",
36
+ "liveness",
37
+ ]);
38
+ const SESSION_REQUIRED_TYPES = new Set([
39
+ "session.attached",
40
+ "intent",
41
+ "decision",
42
+ "supersede",
43
+ "constraint",
44
+ "outcome",
45
+ "next",
46
+ "open-question",
47
+ "question.resolved",
48
+ "retrospective",
49
+ ]);
50
+ const ENTRY_SHAPES = {
51
+ "campaign.initialized": ["at", "type", "eventId"],
52
+ "campaign.closed": ["at", "type", "eventId"],
53
+ "campaign.unparked": ["at", "type", "eventId", "code", "runId"],
54
+ "run.registered": ["at", "type", "eventId", "runId"],
55
+ "session.attached": ["at", "type", "eventId", "sessionId", "tool", "transcript", "transcriptUnavailable", "format", "cursor"],
56
+ intent: ["at", "type", "eventId", "sessionId", "text"],
57
+ decision: ["at", "type", "eventId", "sessionId", "decisionId", "text"],
58
+ supersede: ["at", "type", "eventId", "sessionId", "supersedes", "text"],
59
+ constraint: ["at", "type", "eventId", "sessionId", "text"],
60
+ outcome: ["at", "type", "eventId", "sessionId", "text", "runId"],
61
+ next: ["at", "type", "eventId", "sessionId", "text"],
62
+ "open-question": ["at", "type", "eventId", "sessionId", "questionId", "text"],
63
+ "question.resolved": ["at", "type", "eventId", "sessionId", "questionId", "text"],
64
+ retrospective: ["at", "type", "eventId", "sessionId", "text"],
65
+ liveness: ["at", "type", "eventId", "campaignId", "runId", "nodeId", "phase", "checkpointsDone", "checkpointsTotal", "runtime", "state", "lastProgressAt", "attention"],
66
+ };
67
+ /**
68
+ * Fields a liveness fact carried before the budget ceiling was removed. A
69
+ * historical journal (like the live campaign's own) still has lines shaped
70
+ * like the pre-diet fact, so a read path drops them instead of rejecting the
71
+ * whole file; nothing writes them any more.
72
+ */
73
+ const LEGACY_LIVENESS_FIELDS = ["weightedUsed", "weightedCap"];
74
+ /**
75
+ * @param {JournalEntry} entry
76
+ * @returns {JournalEntry}
77
+ */
78
+ export function withoutLegacyLivenessFields(entry) {
79
+ if (entry.type !== "liveness") return entry;
80
+ const cleaned = /** @type {JournalEntry} */ ({ ...entry });
81
+ for (const field of LEGACY_LIVENESS_FIELDS) delete /** @type {JsonObject} */ (cleaned)[field];
82
+ return cleaned;
83
+ }
84
+ /**
85
+ * @param {string} campaignPath
86
+ * @param {unknown} entry
87
+ * @returns {{entry: JournalEntry, deduplicated: boolean}}
88
+ */
89
+ export function appendJournal(campaignPath, entry) {
90
+ validateJournalEntry(entry);
91
+ const normalized = normalizeEntry(/** @type {JournalEntry} */ (entry));
92
+ const journalPath = join(campaignPath, JOURNAL_FILE);
93
+ if (existsSync(journalPath)) {
94
+ for (const existing of readJournalForDedupe(campaignPath)) {
95
+ if (existing.eventId === normalized.eventId) return { entry: existing, deduplicated: true };
96
+ }
97
+ }
98
+ const descriptor = openSync(journalPath, "a");
99
+ try {
100
+ writeFileSync(descriptor, `${JSON.stringify(normalized)}\n`);
101
+ fsyncSync(descriptor);
102
+ } finally {
103
+ closeSync(descriptor);
104
+ }
105
+ return { entry: normalized, deduplicated: false };
106
+ }
107
+ /**
108
+ * @param {string} campaignPath
109
+ * @returns {JournalEntry[]}
110
+ */
111
+ export function readJournalForDedupe(campaignPath) {
112
+ const path = join(campaignPath, JOURNAL_FILE);
113
+ if (!existsSync(path)) return [];
114
+ /** @type {JournalEntry[]} */
115
+ const entries = [];
116
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/u)) {
117
+ if (!line.trim()) continue;
118
+ try {
119
+ entries.push(/** @type {JournalEntry} */ (JSON.parse(line)));
120
+ } catch {
121
+ // A corrupt prior line cannot match an event id; appends must not be
122
+ // blocked by it because the journal is the authoritative audit source.
123
+ }
124
+ }
125
+ return entries;
126
+ }
127
+ /**
128
+ * Incremental read of the campaign journal for a session sync/watch, keyed by
129
+ * the journal's own `eventId`. Legacy `liveness` entries (the old heartbeat
130
+ * mechanism used to append them; nothing does any more) are filtered out: they carry no
131
+ * narrative a session needs to catch up on. Exactly one of `since` (a
132
+ * stateless event id) or `cursor` (a durable per-watcher position) is
133
+ * accepted; `cursor` advances atomically after the unseen list is built
134
+ * unless `readOnly` is set, so a repeated read-only call returns no events.
135
+ *
136
+ * @param {string} campaignPath
137
+ * @param {{since?: string, cursor?: string, readOnly?: boolean}} [options]
138
+ * @returns {{campaignId: string, cursor: {cursorId: string, at: string, eventId: string}|null, events: JournalEntry[]}}
139
+ */
140
+ export function watchJournal(campaignPath, options = {}) {
141
+ if ((options.since !== undefined) === (options.cursor !== undefined)) {
142
+ throw new TypeError("watch requires exactly one of --since or --cursor");
143
+ }
144
+ const entries = readJournal(campaignPath)
145
+ .filter((entry) => entry.type !== "liveness")
146
+ .sort(compareJournalPositions);
147
+ let cursorId = null;
148
+ /** @type {{at: string, eventId: string}} */
149
+ let position;
150
+ if (options.since !== undefined) {
151
+ const since = String(options.since);
152
+ const entry = entries.find((candidate) => candidate.eventId === since);
153
+ if (!entry) throw new TypeError("--since event ID is not retained in the journal");
154
+ position = { at: entry.at, eventId: entry.eventId };
155
+ } else {
156
+ cursorId = String(options.cursor);
157
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(cursorId)) throw new TypeError("--cursor must be a safe identifier");
158
+ position = readJournalCursor(campaignPath, cursorId);
159
+ }
160
+ const events = entries.filter((entry) => compareJournalPositions(entry, position) > 0);
161
+ if (cursorId !== null && !options.readOnly && events.length > 0) {
162
+ const last = events[events.length - 1];
163
+ position = { at: last.at, eventId: last.eventId };
164
+ writeJournalCursor(campaignPath, { cursorId, ...position });
165
+ }
166
+ return { campaignId: campaignIdOf(campaignPath), cursor: cursorId === null ? null : { cursorId, ...position }, events };
167
+ }
168
+ /**
169
+ * Acknowledge one retained journal event by atomically advancing a durable
170
+ * per-session cursor to it. `ack` is the only cursor writer: `watchJournal`
171
+ * only advances when explicitly told to (never in read-only mode). Cursor
172
+ * movement never regresses.
173
+ *
174
+ * @param {string} campaignPath
175
+ * @param {string} cursorId
176
+ * @param {string} eventId
177
+ * @returns {{cursorId: string, at: string, eventId: string}}
178
+ */
179
+ export function acknowledgeJournalEvent(campaignPath, cursorId, eventId) {
180
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(cursorId)) throw new TypeError("--cursor must be a safe identifier");
181
+ if (typeof eventId !== "string" || !eventId.trim()) throw new TypeError("--event-id requires a value");
182
+ const current = readJournalCursor(campaignPath, cursorId);
183
+ const found = readJournal(campaignPath).find((candidate) => candidate.eventId === eventId);
184
+ let position;
185
+ if (found) {
186
+ position = { at: found.at, eventId: found.eventId };
187
+ } else if (current.eventId !== "" && current.eventId === eventId) {
188
+ position = current;
189
+ } else {
190
+ throw new TypeError("--event-id is not retained in the journal");
191
+ }
192
+ if (current.eventId !== "" && compareJournalPositions(position, current) <= 0) position = current;
193
+ if (current.eventId === "" || compareJournalPositions(position, current) > 0) {
194
+ writeJournalCursor(campaignPath, { cursorId, ...position });
195
+ }
196
+ return { cursorId, ...position };
197
+ }
198
+ /** @param {{at: string, eventId: string}} left @param {{at: string, eventId: string}} right @returns {number} */
199
+ function compareJournalPositions(left, right) {
200
+ if (left.at !== right.at) return left.at < right.at ? -1 : 1;
201
+ return left.eventId < right.eventId ? -1 : left.eventId > right.eventId ? 1 : 0;
202
+ }
203
+ /** @param {string} campaignPath @param {string} cursorId @returns {{at: string, eventId: string}} */
204
+ function readJournalCursor(campaignPath, cursorId) {
205
+ const path = join(campaignPath, JOURNAL_WATCH_CURSOR_DIR, `${cursorId}.json`);
206
+ if (!existsSync(path)) return { at: "", eventId: "" };
207
+ const record = /** @type {JsonObject} */ (JSON.parse(readFileSync(path, "utf8")));
208
+ requireText(record.at, "journal watch cursor.at");
209
+ if (typeof record.eventId !== "string") throw new TypeError("journal watch cursor.eventId must be a string");
210
+ return { at: String(record.at), eventId: record.eventId };
211
+ }
212
+ /** @param {string} campaignPath @param {{cursorId: string, at: string, eventId: string}} position */
213
+ function writeJournalCursor(campaignPath, position) {
214
+ const path = join(campaignPath, JOURNAL_WATCH_CURSOR_DIR, `${position.cursorId}.json`);
215
+ mkdirSync(dirname(path), { recursive: true });
216
+ writeJsonAtomic(path, { schemaVersion: JOURNAL_WATCH_CURSOR_SCHEMA_VERSION, ...position, updatedAt: new Date().toISOString() });
217
+ }
218
+ /**
219
+ * @param {string} campaignPath
220
+ * @returns {JournalEntry[]}
221
+ */
222
+ export function readJournal(campaignPath) {
223
+ const path = join(campaignPath, JOURNAL_FILE);
224
+ if (!existsSync(path)) return [];
225
+ const text = readFileSync(path, "utf8");
226
+ const lines = text.split(/\r?\n/u);
227
+ /** @type {JournalEntry[]} */
228
+ const entries = [];
229
+ for (let index = 0; index < lines.length; index += 1) {
230
+ const line = lines[index];
231
+ if (!line.trim()) continue;
232
+ if (index === lines.length - 1 && !text.endsWith("\n")) {
233
+ // The newline is written atomically with the entry, so an unterminated
234
+ // final line is never a committed entry; skip it regardless of whether
235
+ // its bytes happen to parse.
236
+ continue;
237
+ }
238
+ try {
239
+ const entry = withoutLegacyLivenessFields(/** @type {JournalEntry} */ (JSON.parse(line)));
240
+ validateJournalEntry(entry);
241
+ entries.push(entry);
242
+ } catch (error) {
243
+ throw new Error(`journal line ${index + 1} is invalid: ${error instanceof Error ? error.message : String(error)}`);
244
+ }
245
+ }
246
+ return entries;
247
+ }
248
+ /**
249
+ * @param {unknown} entry
250
+ */
251
+ export function validateJournalEntry(entry) {
252
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
253
+ throw new TypeError("journal entry must be an object");
254
+ }
255
+ const record = /** @type {JsonObject} */ (entry);
256
+ const type = /** @type {keyof typeof ENTRY_SHAPES} */ (record.type);
257
+ if (!JOURNAL_TYPES.has(type)) {
258
+ throw new TypeError(`journal entry type must be one of ${[...JOURNAL_TYPES].join(", ")}`);
259
+ }
260
+ const allowed = ENTRY_SHAPES[type];
261
+ for (const key of Object.keys(record)) {
262
+ if (!allowed.includes(key)) throw new TypeError(`journal entry ${type} has unexpected field ${key}`);
263
+ }
264
+ requireTimestamp(record.at, "entry.at");
265
+ requireText(record.eventId, "entry.eventId");
266
+ if (SESSION_REQUIRED_TYPES.has(type)) {
267
+ requireText(record.sessionId, "entry.sessionId");
268
+ }
269
+ // "liveness" is a legacy type: the old heartbeat mechanism used to append it and nothing
270
+ // writes it any more, but a durable journal may still carry old entries and
271
+ // reading them must not throw. The shared allowed-field check above already
272
+ // rejects an unexpected key; no deeper shape validation is needed for a type
273
+ // nothing produces.
274
+ if (type === "liveness") return;
275
+ if (type === "session.attached") return validateSessionEntry(record);
276
+ if (type === "run.registered") {
277
+ requireText(record.runId, "entry.runId");
278
+ return;
279
+ }
280
+ if (type === "campaign.initialized" || type === "campaign.closed") return;
281
+ if (type === "campaign.unparked") {
282
+ requireText(record.code, "entry.code");
283
+ // `runId` is optional, exactly as `outcome` treats it, but the unpark
284
+ // writer records an explicit null when the parked attention named no run.
285
+ if (record.runId !== null && record.runId !== undefined) requireText(record.runId, "entry.runId");
286
+ return;
287
+ }
288
+ requireText(record.text, "entry.text");
289
+ if (type === "decision") requireText(record.decisionId, "entry.decisionId");
290
+ if (type === "supersede") requireText(record.supersedes, "entry.supersedes");
291
+ if (type === "open-question" || type === "question.resolved") {
292
+ requireText(record.questionId, "entry.questionId");
293
+ }
294
+ if (type === "outcome" && record.runId !== undefined) requireText(record.runId, "entry.runId");
295
+ }
296
+ /**
297
+ * @param {JsonObject} entry
298
+ */
299
+ function validateSessionEntry(entry) {
300
+ requireText(entry.tool, "session.tool");
301
+ requireText(entry.sessionId, "session.sessionId");
302
+ if (typeof entry.transcriptUnavailable !== "boolean") {
303
+ throw new TypeError("session.transcriptUnavailable must be a boolean");
304
+ }
305
+ if (entry.transcriptUnavailable) {
306
+ if (entry.transcript !== null) throw new TypeError("unavailable session must not include a transcript path");
307
+ if (entry.format !== null) throw new TypeError("unavailable session must not include a transcript format");
308
+ } else {
309
+ if (typeof entry.transcript !== "string" || !isAbsolute(entry.transcript)) {
310
+ throw new TypeError("session.transcript must be an absolute path when available");
311
+ }
312
+ if (typeof entry.format !== "string" || !entry.format.trim()) {
313
+ throw new TypeError("session.format must be a non-empty string when a transcript is available");
314
+ }
315
+ }
316
+ if (entry.cursor !== null && entry.cursor !== undefined && (typeof entry.cursor !== "string" || !entry.cursor.trim())) {
317
+ throw new TypeError("session.cursor must be null, omitted, or a non-empty string");
318
+ }
319
+ }
320
+ /**
321
+ * @param {JournalEntry} entry
322
+ * @returns {JournalEntry}
323
+ */
324
+ function normalizeEntry(entry) {
325
+ /** @type {Record<string, unknown>} */
326
+ const normalized = {};
327
+ for (const [key, value] of Object.entries(entry)) {
328
+ if (typeof value === "string") {
329
+ normalized[key] = key === "text" ? normalizeText(value, "entry.text") : collapseLines(value);
330
+ } else {
331
+ normalized[key] = value;
332
+ }
333
+ }
334
+ return /** @type {JournalEntry} */ (normalized);
335
+ }
336
+ /**
337
+ * @param {unknown} value
338
+ * @param {string} label
339
+ * @param {number} maxBytes
340
+ * @returns {string}
341
+ */
342
+ export function normalizeText(value, label, maxBytes = JOURNAL_TEXT_BYTES) {
343
+ requireText(value, label);
344
+ const collapsed = collapseLines(value);
345
+ if (!collapsed) throw new TypeError(`${label} must not be blank`);
346
+ return boundedText(collapsed, maxBytes);
347
+ }