faberun 0.22.0 → 0.23.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@ import { execFileSync } from "node:child_process";
32
32
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
33
33
  import { hostname, tmpdir } from "node:os";
34
34
  import { dirname, join, relative, resolve } from "node:path";
35
- import { assertContractManifestIntact, parkCampaign, promoteRunInCampaign } from "./index.mjs";
35
+ import { assertContractManifestIntact, parkCampaign, promoteRunInCampaign, recordOperatorCommand } from "./index.mjs";
36
36
  import { readCampaign } from "./record.mjs";
37
37
  import { validateContract } from "../contract/index.mjs";
38
38
  import { defaultControllerIdentity, storedContractDigest, verifyControllerIdentity } from "../engine/run-identity.mjs";
@@ -386,6 +386,7 @@ function storedContractId(stored) {
386
386
  * maxTicks?: number,
387
387
  * coordination?: boolean,
388
388
  * controllerIdentity?: ControllerIdentity,
389
+ * refreshController?: boolean,
389
390
  * launch?: (contractPath: string, context: {baseRef: string|undefined, controllerIdentity: ControllerIdentity, runDir: string, contract: ValidatedContract}) => Promise<void>|void,
390
391
  * validate?: (entry: CampaignContract, context: {repo: string|undefined, baseRef: string|undefined, contractPath: string}) => ValidatedContract,
391
392
  * progress?: (runDir: string) => RunProgress,
@@ -551,13 +552,25 @@ export async function driveCampaignChain(campaignPath, options = {}) {
551
552
  try {
552
553
  verifyControllerIdentity(controllerIdentity);
553
554
  } catch (error) {
554
- return park({
555
- code: errorCode(error) ?? "controller_snapshot_changed",
556
- message: errorMessage(error),
557
- contractPath: entry.path,
558
- contractId: id,
559
- runId: id,
560
- });
555
+ // A campaign whose contracts change faberun's own src/ changes the
556
+ // controller between launches (measured on evidence-you-can-recompute).
557
+ // Adopting the new snapshot is the operator's call, so it happens only
558
+ // under --refresh-controller and is recorded as an operator command.
559
+ if (options.refreshController === true && errorCode(error) === "controller_snapshot_changed") {
560
+ controllerIdentity = defaultControllerIdentity();
561
+ recordOperatorCommand(campaignPath, "campaign supervise --refresh-controller");
562
+ emit(`[campaign] controller snapshot refreshed · ${controllerIdentity.sha.slice(0, 12)}`);
563
+ } else {
564
+ return park({
565
+ code: errorCode(error) ?? "controller_snapshot_changed",
566
+ message: errorCode(error) === "controller_snapshot_changed"
567
+ ? `${errorMessage(error)}: \`faberun campaign supervise ${readCampaign(campaignPath).id} --refresh-controller\``
568
+ : errorMessage(error),
569
+ contractPath: entry.path,
570
+ contractId: id,
571
+ runId: id,
572
+ });
573
+ }
561
574
  }
562
575
  try {
563
576
  await launch(entry.path, { baseRef, controllerIdentity, runDir, contract });
@@ -1,5 +1,4 @@
1
1
  import {
2
- copyFileSync,
3
2
  existsSync,
4
3
  mkdirSync,
5
4
  readFileSync,
@@ -11,11 +10,14 @@ import { writeJsonAtomic } from "../run/store.mjs";
11
10
  import { projectIdForRunsDir, repositoryForRunsDir } from "../run/paths.mjs";
12
11
  import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
13
12
  import { promoteRun } from "../repo/integrate.mjs";
14
- import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, JOURNAL_FILE, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
13
+ import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
15
14
  import { readCampaign } from "./record.mjs";
16
15
  import { appendJournal, normalizeText, readJournalForDedupe } from "./journal.mjs";
17
16
  import { readProjectionState } from "./projection.mjs";
18
17
  import { handoffFromState, materializeHandoff } from "./handoff.mjs";
18
+ import { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
19
+
20
+ export { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
19
21
 
20
22
  /** @typedef {Record<string, unknown>} JsonObject */
21
23
  /** @typedef {{path: string, digest: string}} CampaignContract */
@@ -23,7 +25,8 @@ import { handoffFromState, materializeHandoff } from "./handoff.mjs";
23
25
  /** @typedef {{code: string, message: string, at: string, contractPath?: string, contractId?: string, runId?: string, node?: string|null, status?: string|null, resume?: string}} CampaignAttention */
24
26
  /** @typedef {{runId: string, node: string, passed: boolean|null, verdict: string|null}} RequirementNodeEvidence */
25
27
  /** @typedef {{requirementId: string, status: "covered"|"open", nodes: RequirementNodeEvidence[]}} RequirementClosure */
26
- /** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, requirements?: RequirementClosure[], createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
28
+ /** @typedef {{oldPath: string, newPath: string, runIds: string[], at: string}} ContractReplacement */
29
+ /** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], replacements?: ContractReplacement[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, requirements?: RequirementClosure[], createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
27
30
  /** @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 */
28
31
  /** @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 */
29
32
  /** @typedef {{cursor: number, byte: number, size: number, projection: Projection}} ProjectionRecord */
@@ -55,6 +58,7 @@ export function initializeCampaign(runsDir, { campaignId, goal, at = new Date().
55
58
  status: "active",
56
59
  linkedRunIds: [],
57
60
  contracts: validatedContracts,
61
+ replacements: [],
58
62
  landBranch: branch,
59
63
  promotions: [],
60
64
  createdAt: at,
@@ -126,7 +130,7 @@ export function resolveCampaign(runsDir, campaignId) {
126
130
  /**
127
131
  * @param {string} campaignPath
128
132
  * @param {{at?: string, eventId?: string}} options
129
- * @returns {{path: string, campaign: Campaign, ledgerFiles: string[]}}
133
+ * @returns {{path: string, campaign: Campaign, ledgerFiles: string[], ledgerSkipped: {runId: string, source: string}[]}}
130
134
  */
131
135
  export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
132
136
  requireTimestamp(at, "at");
@@ -140,8 +144,6 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
140
144
  if (unacknowledged.length) {
141
145
  throw new Error(`campaign ${campaign.id} has judge findings no note has answered: ${unacknowledged.join("; ")}. Read them with \`faberun findings <run-dir>\`, then name the node in a note (\`campaign note ${campaign.id} --kind outcome --run-id <run-id> --text "...<node>..."\`) before close`);
142
146
  }
143
- const repoRoot = campaignRepoRoot(campaignPath);
144
- const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
145
147
  // The closure travels on the record itself, computed in one deterministic
146
148
  // pass over the linked runs' own files before the close is written.
147
149
  const requirements = buildRequirementClosure(campaignPath, campaign);
@@ -149,7 +151,24 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
149
151
  const closed = { ...campaign, status: "closed", closedAt: at, updatedAt: at, requirements };
150
152
  writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
151
153
  appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
152
- return { path: campaignPath, campaign: closed, ledgerFiles };
154
+ // Preserve after the closed record exists: the ledger is the recomputable
155
+ // closed record, including requirements used by the north-star projector.
156
+ const ledger = preserveCampaignLedger(campaignPath, campaignRepoRoot(campaignPath));
157
+ return { path: campaignPath, campaign: closed, ledgerFiles: ledger.written, ledgerSkipped: ledger.skipped };
158
+ }
159
+
160
+ /**
161
+ * Complete the versioned evidence ledger of a closed campaign. Unlike close,
162
+ * this operation never updates the campaign record or journal: it only adds
163
+ * surviving run sources beneath the repository's ledger directory.
164
+ *
165
+ * @param {string} campaignPath
166
+ * @returns {import("./ledger.mjs").LedgerReledger & {ledgerDir: string}}
167
+ */
168
+ export function reledgerCampaign(campaignPath) {
169
+ const campaign = readCampaign(campaignPath);
170
+ if (campaign.status !== "closed") throw new Error(`campaign is not closed: ${campaign.id}`);
171
+ return reledgerCampaignLedger(campaignPath, campaignRepoRoot(campaignPath));
153
172
  }
154
173
 
155
174
  /**
@@ -290,46 +309,6 @@ function campaignRepoRoot(campaignPath) {
290
309
  return resolve(campaignPath, "..", "..", "..");
291
310
  }
292
311
 
293
- /**
294
- * Copy a campaign's journal, record and each linked run's usage into
295
- * `<repoRoot>/docs/campaigns/<id>/ledger/` so the comparative arm of the
296
- * planner has a session-side baseline even after `.runs/` (gitignored) is
297
- * pruned. Nothing in this tree redacts token counts, costs or operator notes
298
- * before this point, so the copy is verbatim; the pre-commit secret scan is
299
- * the guard against anything that should not land in git.
300
- *
301
- * Idempotent: re-running it (a second `close` on an already-closed campaign
302
- * cannot reach this, but a direct call can) overwrites the same destination
303
- * files rather than duplicating them. A linked run without a `usage.jsonl`
304
- * (never launched, or pruned) is skipped rather than thrown.
305
- *
306
- * @param {string} campaignPath
307
- * @param {string} repoRoot
308
- * @returns {string[]}
309
- */
310
- export function preserveCampaignLedger(campaignPath, repoRoot) {
311
- const campaign = readCampaign(campaignPath);
312
- const runsDir = resolve(campaignPath, "..", "..");
313
- const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
314
- mkdirSync(ledgerDir, { recursive: true });
315
- const written = [];
316
- for (const name of [JOURNAL_FILE, CAMPAIGN_FILE]) {
317
- const source = join(campaignPath, name);
318
- if (!existsSync(source)) continue;
319
- const destination = join(ledgerDir, name);
320
- copyFileSync(source, destination);
321
- written.push(destination);
322
- }
323
- for (const runId of campaign.linkedRunIds) {
324
- const source = join(runsDir, runId, "usage.jsonl");
325
- if (!existsSync(source)) continue;
326
- const destination = join(ledgerDir, `${runId}.usage.jsonl`);
327
- copyFileSync(source, destination);
328
- written.push(destination);
329
- }
330
- return written;
331
- }
332
-
333
312
  /**
334
313
  * @param {string} campaignPath
335
314
  * @param {string} runId
@@ -422,9 +401,22 @@ export function addContractToCampaign(campaignPath, contractPath, { at = new Dat
422
401
  : campaign.contracts.map((existing, index) => (index === existingIndex ? entry : existing));
423
402
  const updated = /** @type {Campaign} */ ({ ...campaign, contracts, updatedAt: at });
424
403
  writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
404
+ recordOperatorCommand(campaignPath, "campaign add-contract", at);
425
405
  return { campaign: updated, added: true };
426
406
  }
427
407
 
408
+ /**
409
+ * The one writer of `operator.command` (docs/FIELD-OWNERSHIP.md): an operator
410
+ * command that changed campaign state, which `humanTouches` counts.
411
+ *
412
+ * @param {string} campaignPath
413
+ * @param {string} command
414
+ * @param {string} [at]
415
+ */
416
+ export function recordOperatorCommand(campaignPath, command, at = new Date().toISOString()) {
417
+ appendJournal(campaignPath, { type: "operator.command", eventId: randomUUID(), at, command });
418
+ }
419
+
428
420
  /**
429
421
  * Replace a manifest entry -- matched by its current, recorded path -- with a
430
422
  * freshly authored contract. Re-authoring a phase after a blocked or failed
@@ -458,13 +450,43 @@ export function replaceContractInCampaign(campaignPath, oldPath, newPath, { at =
458
450
  const contracts = campaign.contracts.map((entry, position) => (position === index ? replaced : entry));
459
451
  const attention = campaign.attention;
460
452
  const clearAttention = attention !== undefined && attention.contractPath === oldPath;
453
+ const replacementRunIds = replacedRunIds(campaignPath, campaign, oldPath, newPath, attention?.runId);
454
+ const replacement = { oldPath, newPath, runIds: replacementRunIds, at };
461
455
  /** @type {Campaign} */
462
- const updated = { ...campaign, contracts, updatedAt: at };
456
+ const updated = { ...campaign, contracts, replacements: [...(campaign.replacements ?? []), replacement], updatedAt: at };
463
457
  if (clearAttention) delete updated.attention;
464
458
  writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
465
459
  return { campaign: updated, replaced, clearedAttention: clearAttention ? /** @type {CampaignAttention} */ (attention) : null };
466
460
  }
467
461
 
462
+ /**
463
+ * Identify the run whose authored contract is being re-issued. The parked
464
+ * attention is the authoritative link when present; matching the old
465
+ * contract id also covers a replacement requested before the run was parked.
466
+ *
467
+ * @param {string} campaignPath
468
+ * @param {Campaign} campaign
469
+ * @param {string} oldPath
470
+ * @param {string} newPath
471
+ * @param {string|undefined} attentionRunId
472
+ * @returns {string[]}
473
+ */
474
+ function replacedRunIds(campaignPath, campaign, oldPath, newPath, attentionRunId) {
475
+ const ids = new Set();
476
+ if (attentionRunId !== undefined && campaign.linkedRunIds.includes(attentionRunId)) ids.add(attentionRunId);
477
+ const oldContract = readRunJson(oldPath);
478
+ const oldId = oldContract !== null && typeof oldContract.id === "string" ? oldContract.id : null;
479
+ if (oldId !== null && campaign.linkedRunIds.includes(oldId)) ids.add(oldId);
480
+ const runsDir = resolve(campaignPath, "..", "..");
481
+ for (const runId of campaign.linkedRunIds) {
482
+ const runContract = readRunJson(join(runsDir, runId, "contract.json"));
483
+ if (runContract !== null && oldId !== null && runContract.id === oldId) ids.add(runId);
484
+ }
485
+ const replacementContract = readRunJson(newPath);
486
+ const replacementId = replacementContract !== null && typeof replacementContract.id === "string" ? replacementContract.id : null;
487
+ return [...ids].filter((runId) => replacementId === null || runId !== replacementId).sort();
488
+ }
489
+
468
490
  /**
469
491
  * Persist one promotion in the campaign record. Idempotent by run id and the
470
492
  * sha it landed: a re-invocation after a crash between the branch move and
@@ -584,4 +606,3 @@ export function renderRunHandoff(runDir) {
584
606
  if (!existsSync(join(path, CAMPAIGN_FILE))) return null;
585
607
  return renderHandoff(path, runsDir);
586
608
  }
587
-
@@ -33,6 +33,7 @@ const JOURNAL_TYPES = new Set([
33
33
  "next",
34
34
  "open-question",
35
35
  "question.resolved",
36
+ "operator.command",
36
37
  "retrospective",
37
38
  "liveness",
38
39
  "seat.allowance",
@@ -63,6 +64,7 @@ const ENTRY_SHAPES = {
63
64
  next: ["at", "type", "eventId", "sessionId", "text"],
64
65
  "open-question": ["at", "type", "eventId", "sessionId", "questionId", "text"],
65
66
  "question.resolved": ["at", "type", "eventId", "sessionId", "questionId", "text"],
67
+ "operator.command": ["at", "type", "eventId", "command", "sessionId", "runId"],
66
68
  retrospective: ["at", "type", "eventId", "sessionId", "text"],
67
69
  liveness: ["at", "type", "eventId", "campaignId", "runId", "nodeId", "phase", "checkpointsDone", "checkpointsTotal", "runtime", "state", "lastProgressAt", "attention"],
68
70
  "seat.allowance": ["at", "type", "eventId", "sample", "harness", "remaining", "limit", "resetsAt", "delta", "window"],
@@ -276,6 +278,12 @@ export function validateJournalEntry(entry) {
276
278
  // rejects an unexpected key; no deeper shape validation is needed for a type
277
279
  // nothing produces.
278
280
  if (type === "liveness") return;
281
+ if (type === "operator.command") {
282
+ requireText(record.command, "entry.command");
283
+ if (record.sessionId !== undefined) requireText(record.sessionId, "entry.sessionId");
284
+ if (record.runId !== undefined) requireText(record.runId, "entry.runId");
285
+ return;
286
+ }
279
287
  if (type === "seat.allowance") return validateSeatAllowanceEntry(record);
280
288
  if (type === "session.attached") return validateSessionEntry(record);
281
289
  if (type === "run.registered") {
@@ -13,6 +13,7 @@ import { requireId } from "../contract/assert.mjs";
13
13
  export const CAMPAIGN_DIR_NAME = "campaigns";
14
14
  export const CAMPAIGN_FILE = "campaign.json";
15
15
  export const JOURNAL_FILE = "journal.jsonl";
16
+ export const LEDGER_SOURCE_MANIFEST_FILE = "sources.json";
16
17
  export const HANDOFF_FILE = "HANDOFF.md";
17
18
  export const PROJECTION_FILE = "projection.json";
18
19
  export const JOURNAL_WATCH_CURSOR_DIR = "watch-cursors";
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The closed campaign evidence copy: only the files consumed by campaign and
3
+ * eval projectors cross from the operator home into the versioned ledger.
4
+ */
5
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+
8
+ import { readMetricNodeSnapshots } from "./metrics-command.mjs";
9
+ import { CAMPAIGN_FILE, JOURNAL_FILE, LEDGER_SOURCE_MANIFEST_FILE } from "./layout.mjs";
10
+ import { readCampaign } from "./record.mjs";
11
+
12
+ /** @typedef {{runId: string, source: string}} LedgerSkip */
13
+ /** @typedef {{written: string[], skipped: LedgerSkip[]}} LedgerPreservation */
14
+ /** @typedef {{runId: string, source: string}} LedgerChange */
15
+ /** @typedef {{copied: LedgerChange[], gone: LedgerChange[]}} LedgerReledger */
16
+
17
+ const RUN_SOURCES = [
18
+ ["events.jsonl", "events.jsonl"],
19
+ ["usage.jsonl", "usage.jsonl"],
20
+ ["notify.jsonl", "notify.jsonl"],
21
+ ];
22
+
23
+ /** Fields consumed by `projectMetrics` and `projectEvalIndicators`; worker prose never crosses the ledger boundary. */
24
+ export const EVENT_PROJECTION_FIELDS = Object.freeze([
25
+ "at", "node", "to", "phase", "runtime", "verdict", "proofApproved", "passed", "override", "recovery", "error", "revisions",
26
+ ]);
27
+ /** Fields consumed by `projectMetrics`; notification messages and transport details never cross the ledger boundary. */
28
+ export const NOTIFY_PROJECTION_FIELDS = Object.freeze(["at", "dedupeKey", "attempt", "status"]);
29
+
30
+ /**
31
+ * Copy a campaign's projector sources into its versioned ledger. Missing run
32
+ * sources are reported so a pruned run cannot make the close fail or look
33
+ * complete by accident. Repeated calls overwrite the same paths.
34
+ *
35
+ * @param {string} campaignPath
36
+ * @param {string} repoRoot
37
+ * @returns {LedgerPreservation}
38
+ */
39
+ export function preserveCampaignLedger(campaignPath, repoRoot) {
40
+ const campaign = readCampaign(campaignPath);
41
+ const runsDir = resolve(campaignPath, "..", "..");
42
+ const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
43
+ mkdirSync(ledgerDir, { recursive: true });
44
+ /** @type {string[]} */
45
+ const written = [];
46
+ /** @type {LedgerSkip[]} */
47
+ const skipped = [];
48
+ const priorManifest = readSourceManifest(ledgerDir);
49
+ const absent = new Set(priorManifest.absent);
50
+ const preserved = new Set(priorManifest.preserved);
51
+ for (const name of [JOURNAL_FILE, CAMPAIGN_FILE]) {
52
+ const source = join(campaignPath, name);
53
+ if (!existsSync(source)) continue;
54
+ const destination = join(ledgerDir, name);
55
+ copyFileSync(source, destination);
56
+ written.push(destination);
57
+ }
58
+ for (const runId of campaign.linkedRunIds) {
59
+ const runDir = join(runsDir, runId);
60
+ for (const [sourceName, destinationSuffix] of RUN_SOURCES) {
61
+ const source = join(runDir, sourceName);
62
+ const destinationName = `${runId}.${destinationSuffix}`;
63
+ if (!existsSync(source)) {
64
+ skipped.push({ runId, source: sourceName });
65
+ if (!preserved.has(destinationName)) absent.add(destinationName);
66
+ continue;
67
+ }
68
+ absent.delete(destinationName);
69
+ preserved.add(destinationName);
70
+ const destination = join(ledgerDir, destinationName);
71
+ if (sourceName === "events.jsonl" || sourceName === "notify.jsonl") {
72
+ writeFileSync(destination, projectJsonl(source, sourceName === "events.jsonl" ? EVENT_PROJECTION_FIELDS : NOTIFY_PROJECTION_FIELDS));
73
+ } else {
74
+ copyFileSync(source, destination);
75
+ }
76
+ written.push(destination);
77
+ }
78
+ const nodesSource = join(runDir, "nodes");
79
+ if (!existsSync(nodesSource)) {
80
+ skipped.push({ runId, source: "nodes" });
81
+ if (!preserved.has(`${runId}.nodes.json`)) absent.add(`${runId}.nodes.json`);
82
+ continue;
83
+ }
84
+ absent.delete(`${runId}.nodes.json`);
85
+ preserved.add(`${runId}.nodes.json`);
86
+ const nodesDestination = join(ledgerDir, `${runId}.nodes.json`);
87
+ writeFileSync(nodesDestination, `${JSON.stringify(readMetricNodeSnapshots(runDir))}\n`);
88
+ written.push(nodesDestination);
89
+ }
90
+ const proposalsSource = join(campaignPath, "proposals");
91
+ if (existsSync(proposalsSource)) {
92
+ const proposalsDestination = join(ledgerDir, "proposals");
93
+ cpSync(proposalsSource, proposalsDestination, { recursive: true });
94
+ written.push(...filesUnder(proposalsSource).map((path) => join(proposalsDestination, path)));
95
+ }
96
+ const manifest = join(ledgerDir, LEDGER_SOURCE_MANIFEST_FILE);
97
+ writeFileSync(manifest, `${JSON.stringify({ schemaVersion: 1, absent: [...absent].sort(), preserved: [...preserved].sort() })}\n`);
98
+ written.push(manifest);
99
+ return { written, skipped };
100
+ }
101
+
102
+ /**
103
+ * Complete an already closed ledger from the run files still in the operator
104
+ * home. A destination is replaced only when the source strictly extends its
105
+ * bytes; this keeps a ledger's evidence stable when a run was rewritten or
106
+ * truncated after close. Missing run sources are reported as gone.
107
+ *
108
+ * @param {string} campaignPath
109
+ * @param {string} repoRoot
110
+ * @returns {LedgerReledger & {ledgerDir: string}}
111
+ */
112
+ export function reledgerCampaignLedger(campaignPath, repoRoot) {
113
+ const campaign = readCampaign(campaignPath);
114
+ const runsDir = resolve(campaignPath, "..", "..");
115
+ const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
116
+ mkdirSync(ledgerDir, { recursive: true });
117
+ /** @type {LedgerChange[]} */
118
+ const copied = [];
119
+ /** @type {LedgerChange[]} */
120
+ const gone = [];
121
+ const sourceManifest = readSourceManifest(ledgerDir);
122
+ const preserved = new Set(sourceManifest.preserved);
123
+ const absent = new Set(sourceManifest.absent);
124
+ for (const runId of campaign.linkedRunIds) {
125
+ const runDir = join(runsDir, runId);
126
+ for (const [sourceName, destinationSuffix] of RUN_SOURCES) {
127
+ const source = join(runDir, sourceName);
128
+ const change = { runId, source: sourceName };
129
+ if (!existsSync(source)) {
130
+ gone.push(change);
131
+ continue;
132
+ }
133
+ const destination = join(ledgerDir, `${runId}.${destinationSuffix}`);
134
+ const projected = sourceName === "events.jsonl" || sourceName === "notify.jsonl"
135
+ ? projectJsonl(source, sourceName === "events.jsonl" ? EVENT_PROJECTION_FIELDS : NOTIFY_PROJECTION_FIELDS)
136
+ : source;
137
+ if (sourceName === "events.jsonl" || sourceName === "notify.jsonl") {
138
+ if (writeIfStrictExtension(destination, projected)) copied.push(change);
139
+ } else if (copyIfStrictExtension(projected, destination)) copied.push(change);
140
+ if (existsSync(destination)) {
141
+ preserved.add(`${runId}.${destinationSuffix}`);
142
+ absent.delete(`${runId}.${destinationSuffix}`);
143
+ }
144
+ }
145
+ const nodesSource = join(runDir, "nodes");
146
+ const nodesChange = { runId, source: "nodes" };
147
+ if (!existsSync(nodesSource)) {
148
+ gone.push(nodesChange);
149
+ continue;
150
+ }
151
+ const nodesDestination = join(ledgerDir, `${runId}.nodes.json`);
152
+ const projection = `${JSON.stringify(readMetricNodeSnapshots(runDir))}\n`;
153
+ if (writeIfStrictExtension(nodesDestination, projection)) copied.push(nodesChange);
154
+ if (existsSync(nodesDestination)) {
155
+ preserved.add(`${runId}.nodes.json`);
156
+ absent.delete(`${runId}.nodes.json`);
157
+ }
158
+ }
159
+ const proposalsSource = join(campaignPath, "proposals");
160
+ if (existsSync(proposalsSource)) {
161
+ for (const relative of filesUnder(proposalsSource)) {
162
+ const source = join(proposalsSource, relative);
163
+ const destination = join(ledgerDir, "proposals", relative);
164
+ if (copyIfStrictExtension(source, destination)) copied.push({ runId: campaign.id, source: `proposals/${relative}` });
165
+ }
166
+ }
167
+ const ledgerCampaign = join(ledgerDir, CAMPAIGN_FILE);
168
+ if (existsSync(ledgerCampaign)) {
169
+ const existing = readJsonObject(ledgerCampaign);
170
+ if (existing?.id === campaign.id && existing.status !== "closed" && campaign.status === "closed") {
171
+ writeFileSync(ledgerCampaign, readFileSync(join(campaignPath, CAMPAIGN_FILE)));
172
+ }
173
+ } else if (existsSync(join(campaignPath, CAMPAIGN_FILE))) {
174
+ copyFileSync(join(campaignPath, CAMPAIGN_FILE), ledgerCampaign);
175
+ }
176
+ writeFileSync(join(ledgerDir, LEDGER_SOURCE_MANIFEST_FILE), `${JSON.stringify({ schemaVersion: 1, absent: [...absent].sort(), preserved: [...preserved].sort() })}\n`);
177
+ return { ledgerDir, copied, gone };
178
+ }
179
+
180
+ /**
181
+ * @param {string} source
182
+ * @param {string} destination
183
+ * @returns {boolean}
184
+ */
185
+ function copyIfStrictExtension(source, destination) {
186
+ const bytes = readFileSync(source);
187
+ return writeIfStrictExtension(destination, bytes);
188
+ }
189
+
190
+ /**
191
+ * @param {string} destination
192
+ * @param {Buffer|string} source
193
+ * @returns {boolean}
194
+ */
195
+ function writeIfStrictExtension(destination, source) {
196
+ const bytes = Buffer.isBuffer(source) ? source : Buffer.from(source);
197
+ mkdirSync(dirname(destination), { recursive: true });
198
+ if (!existsSync(destination)) {
199
+ writeFileSync(destination, bytes);
200
+ return true;
201
+ }
202
+ const existing = readFileSync(destination);
203
+ if (bytes.length <= existing.length || !bytes.subarray(0, existing.length).equals(existing)) return false;
204
+ writeFileSync(destination, bytes);
205
+ return true;
206
+ }
207
+
208
+ /** @param {string} path @param {readonly string[]} fields @returns {string} */
209
+ function projectJsonl(path, fields) {
210
+ const text = readFileSync(path, "utf8");
211
+ const lines = text.split(/\r?\n/u);
212
+ const projected = [];
213
+ for (let index = 0; index < lines.length; index += 1) {
214
+ if (!lines[index].trim()) continue;
215
+ if (index === lines.length - 1 && !text.endsWith("\n")) continue;
216
+ const value = JSON.parse(lines[index]);
217
+ if (value === null || typeof value !== "object" || Array.isArray(value)) continue;
218
+ const source = /** @type {Record<string, unknown>} */ (value);
219
+ /** @type {Record<string, unknown>} */
220
+ const record = {};
221
+ for (const field of fields) if (Object.hasOwn(source, field)) record[field] = projectedField(source[field], field);
222
+ projected.push(JSON.stringify(record));
223
+ }
224
+ return projected.length === 0 ? "" : `${projected.join("\n")}\n`;
225
+ }
226
+
227
+ /** @param {unknown} value @param {string} field @returns {unknown} */
228
+ function projectedField(value, field) {
229
+ if (field !== "override" || value === null || typeof value !== "object" || Array.isArray(value)) return value;
230
+ const object = /** @type {Record<string, unknown>} */ (value);
231
+ return Object.hasOwn(object, "kind") ? { kind: object.kind } : {};
232
+ }
233
+
234
+ /** @param {string} root @returns {{absent: string[], preserved: string[]}} */
235
+ function readSourceManifest(root) {
236
+ try {
237
+ const value = JSON.parse(readFileSync(join(root, LEDGER_SOURCE_MANIFEST_FILE), "utf8"));
238
+ return {
239
+ absent: Array.isArray(value?.absent) ? /** @type {unknown[]} */ (value.absent).filter((entry) => typeof entry === "string") : [],
240
+ preserved: Array.isArray(value?.preserved) ? /** @type {unknown[]} */ (value.preserved).filter((entry) => typeof entry === "string") : [],
241
+ };
242
+ } catch {
243
+ // An old ledger has no origin manifest; its files remain subject to the legacy missing-source rules.
244
+ return { absent: [], preserved: [] };
245
+ }
246
+ }
247
+
248
+ /** @param {string} path @returns {Record<string, unknown>|null} */
249
+ function readJsonObject(path) {
250
+ try {
251
+ const value = JSON.parse(readFileSync(path, "utf8"));
252
+ return value !== null && typeof value === "object" && !Array.isArray(value)
253
+ ? /** @type {Record<string, unknown>} */ (value)
254
+ : null;
255
+ } catch {
256
+ // A malformed old ledger record cannot prove that it is already closed.
257
+ return null;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * @param {string} root
263
+ * @returns {string[]}
264
+ */
265
+ function filesUnder(root) {
266
+ return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
267
+ if (entry.isDirectory()) return filesUnder(join(root, entry.name)).map((path) => join(entry.name, path));
268
+ return [entry.name];
269
+ });
270
+ }