faberun 0.7.0 → 0.9.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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Planning contract templates: the one-node `mode: "discovery"` contracts a
3
+ * plan pipeline runs outside the control session — draft, review, revise (a
4
+ * draft carrying the reviewer's findings), spec authoring, and spec review.
5
+ * Separate from freeze.mjs (which turns an already-built plan into a
6
+ * validated contract on disk) because this module never touches the
7
+ * filesystem or a model: it only assembles the JSON object `validateContract`
8
+ * accepts, from exactly the inputs each role may see. The reviewer's packet
9
+ * is the enforced case: it carries the spec, the repository facts and the
10
+ * artefact under review, never the author's packet, transcript or summary.
11
+ */
12
+ import { assertObject, rejectUnknown, requireId, requireString, requireStringArray } from "../contract/assert.mjs";
13
+ import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../contract/index.mjs";
14
+ import { validateDefinitionOfDone } from "../contract/definition-of-done.mjs";
15
+ import { validateVerificationCommands } from "../contract/verification.mjs";
16
+
17
+ /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
18
+ /** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
19
+ /** @typedef {"low"|"standard"|"high"} RiskTier */
20
+ /** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
21
+ /** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[]}} PlanOutputNode */
22
+ /** @typedef {{nodes: PlanOutputNode[], justification?: string}} PlanOutput */
23
+ /** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
24
+
25
+ /**
26
+ * The taskKind catalogue a draft or revise classifies against. Exported here,
27
+ * not read from a separate document, so `TASK_KIND_CATALOGUE_PATH` (this
28
+ * module's own repo-relative path) is a real, always-present file a
29
+ * closed-context worker can be told to read for the authoritative list.
30
+ */
31
+ export const TASK_KINDS = Object.freeze(["docs", "implement", "test", "refactor", "infra", "judge"]);
32
+
33
+ /** The risk tiers a draft or revise classifies against. */
34
+ export const RISK_TIERS = Object.freeze(["low", "standard", "high"]);
35
+
36
+ /** This module's own repo-relative path: the taskKind catalogue's home. */
37
+ export const TASK_KIND_CATALOGUE_PATH = "src/plan/template.mjs";
38
+
39
+ /**
40
+ * Which of the caller's `runtimeDefaults` roles resolves this contract's
41
+ * single node. A draft or revise is authored by the worker role; a review or
42
+ * spec-review is graded by the judge role — there is no gate on this
43
+ * single-node contract, so the role only decides which runtime id the node
44
+ * itself carries.
45
+ *
46
+ * @type {Record<PlanningKind, "worker"|"judge">}
47
+ */
48
+ const KIND_ROLE = Object.freeze({
49
+ draft: "worker",
50
+ revise: "worker",
51
+ "spec-author": "worker",
52
+ review: "judge",
53
+ "spec-review": "judge",
54
+ });
55
+
56
+ /** @type {Record<PlanningKind, string[]>} */
57
+ const REQUIRED_INPUTS = Object.freeze({
58
+ draft: ["specPath", "repoFactsPath"],
59
+ revise: ["specPath", "repoFactsPath", "findingsPath"],
60
+ review: ["specPath", "repoFactsPath", "planPath"],
61
+ "spec-author": ["notesPath"],
62
+ "spec-review": ["specPath"],
63
+ });
64
+
65
+ const PLAN_OUTPUT_SHAPE = "{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, definitionOfDone, verification}], justification?}";
66
+ const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
67
+
68
+ /** @type {Record<PlanningKind, string>} */
69
+ const OBJECTIVES = Object.freeze({
70
+ draft: "Draft an execution plan for this phase: classify every node's taskKind and riskTier from the spec and the repository facts, and propose the dependency graph.",
71
+ revise: "Revise the plan to resolve every one of the reviewer's findings, keeping the same classification and graph shape as a fresh draft.",
72
+ review: "Review this plan against the spec and the repository facts, and report only findings.",
73
+ "spec-author": "Turn free notes into a structured spec document following the spec format.",
74
+ "spec-review": "Review this spec for traceability and completeness, and report only findings.",
75
+ });
76
+
77
+ /** @type {Record<PlanningKind, string[]>} */
78
+ const INSTRUCTIONS = Object.freeze({
79
+ draft: [
80
+ `Consult ${TASK_KIND_CATALOGUE_PATH}'s exported TASK_KINDS before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
81
+ `Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
82
+ "Never name a runtime, harness, model, or vendor anywhere in output.plan. taskKind and riskTier are the only classification a draft makes; a routing table assigns a runtime afterward, from those two fields alone.",
83
+ ],
84
+ revise: [
85
+ "Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
86
+ `Consult ${TASK_KIND_CATALOGUE_PATH}'s exported TASK_KINDS before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
87
+ `Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
88
+ "Never name a runtime, harness, model, or vendor anywhere in output.plan.",
89
+ ],
90
+ review: [
91
+ "You are given only the spec, the repository facts, and the plan under review; you have not seen how the plan was produced or any reasoning behind it. Review the artefact alone.",
92
+ `Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
93
+ "severity must be one of critical, major, minor. Every finding's nodeId must name a node id that actually appears in the plan under review.",
94
+ ],
95
+ "spec-author": [
96
+ "Read the notes and turn them into a structured spec document: front matter, Intent, Requirements (each with a stable R<n> id and a proof), Non-goals, Constraints, Success criteria, and Risks.",
97
+ "Return exactly one worker-result JSON object. Put the authored spec text, as one markdown document, in output.spec and nothing else in output.",
98
+ ],
99
+ "spec-review": [
100
+ "You are given only the spec under review; you have not seen the author's notes or reasoning. Review the document alone.",
101
+ `Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
102
+ "severity must be one of critical, major, minor. Every finding's nodeId must name the requirement id, or section heading, it concerns.",
103
+ ],
104
+ });
105
+
106
+ /** @type {Record<PlanningKind, string[]>} */
107
+ const NON_GOALS = Object.freeze({
108
+ draft: ["Assigning a runtime, harness, or model to any node."],
109
+ revise: ["Assigning a runtime, harness, or model to any node.", "Reopening a finding the reviewer did not raise."],
110
+ review: ["Proposing a replacement plan.", "Assigning a runtime, harness, or model to any node."],
111
+ "spec-author": ["Declaring nodes, phases, or architecture in the spec."],
112
+ "spec-review": ["Proposing a replacement spec."],
113
+ });
114
+
115
+ /**
116
+ * @param {PlanningKind} kind
117
+ * @param {PlanningContractInputs} inputs
118
+ * @returns {string[]}
119
+ */
120
+ function readFilesForKind(kind, inputs) {
121
+ if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), TASK_KIND_CATALOGUE_PATH];
122
+ if (kind === "revise") {
123
+ return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), TASK_KIND_CATALOGUE_PATH, /** @type {string} */ (inputs.findingsPath)];
124
+ }
125
+ if (kind === "review") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.planPath)];
126
+ if (kind === "spec-author") return [/** @type {string} */ (inputs.notesPath)];
127
+ return [/** @type {string} */ (inputs.specPath)];
128
+ }
129
+
130
+ /**
131
+ * Build one of the planning pipeline's one-node discovery contracts. Pure:
132
+ * no file is read or written, and no model is invoked. The returned object is
133
+ * the raw, not-yet-validated contract JSON `validateContract` accepts.
134
+ *
135
+ * @param {PlanningKind} kind
136
+ * @param {PlanningContractInputs} inputs
137
+ * @returns {JsonObject}
138
+ */
139
+ export function buildPlanningContract(kind, inputs) {
140
+ const required = REQUIRED_INPUTS[kind];
141
+ if (!required) throw new TypeError(`buildPlanningContract: unknown kind ${kind}`);
142
+ requireId(inputs.campaignId, "inputs.campaignId");
143
+ requireString(inputs.phase, "inputs.phase");
144
+ if (!Number.isInteger(inputs.n) || inputs.n <= 0) throw new TypeError("inputs.n must be a positive integer");
145
+ assertObject(inputs.runtimes, "inputs.runtimes");
146
+ assertObject(inputs.runtimeDefaults ?? {}, "inputs.runtimeDefaults");
147
+ const inputRecord = /** @type {Record<string, unknown>} */ (inputs);
148
+ for (const field of required) requireString(inputRecord[field], `inputs.${field}`);
149
+
150
+ const readFiles = readFilesForKind(kind, inputs);
151
+ const role = KIND_ROLE[kind];
152
+ const runtimeId = (inputs.runtimeDefaults ?? {})[role];
153
+
154
+ /** @type {JsonObject} */
155
+ const taskPacket = {
156
+ mode: "discovery",
157
+ objective: OBJECTIVES[kind],
158
+ instructions: INSTRUCTIONS[kind],
159
+ readFiles,
160
+ writeFiles: [],
161
+ symbols: [],
162
+ decisions: [],
163
+ nonGoals: NON_GOALS[kind],
164
+ verification: [],
165
+ };
166
+
167
+ return {
168
+ schemaVersion: PROTOCOL_SCHEMA_VERSION,
169
+ contractVersion: CONTRACT_VERSION,
170
+ id: `${inputs.campaignId}-plan-${inputs.phase}-${kind}-${inputs.n}`,
171
+ campaignId: inputs.campaignId,
172
+ goal: inputs.goal ?? `Plan ${kind} for phase ${inputs.phase}`,
173
+ cwd: inputs.cwd ?? ".",
174
+ runtimes: inputs.runtimes,
175
+ runtimeDefaults: inputs.runtimeDefaults ?? {},
176
+ nodes: [
177
+ {
178
+ id: kind,
179
+ type: kind,
180
+ phase: inputs.phase,
181
+ dependsOn: [],
182
+ ...(runtimeId === undefined ? {} : { runtime: runtimeId }),
183
+ taskPacket,
184
+ gate: false,
185
+ },
186
+ ],
187
+ };
188
+ }
189
+
190
+ const PLAN_FIELDS = new Set(["nodes", "justification"]);
191
+ const PLAN_NODE_FIELDS = new Set(["id", "objective", "taskKind", "riskTier", "dependsOn", "readFiles", "writeFiles", "definitionOfDone", "verification"]);
192
+
193
+ /**
194
+ * Validate a draft or revise worker's `output.plan`. Rejects a node naming a
195
+ * runtime, harness, model, or vendor (an unknown field, since a plan node's
196
+ * shape never includes one) and a node missing taskKind or riskTier.
197
+ *
198
+ * @param {unknown} plan
199
+ * @returns {PlanOutput}
200
+ */
201
+ export function validatePlanOutput(plan) {
202
+ assertObject(plan, "plan");
203
+ const record = /** @type {Record<string, unknown>} */ (plan);
204
+ rejectUnknown(record, PLAN_FIELDS, "plan");
205
+ if (!Array.isArray(record.nodes) || record.nodes.length === 0) {
206
+ throw new TypeError("plan.nodes must be a non-empty array");
207
+ }
208
+ const nodes = record.nodes.map((node, index) => {
209
+ const label = `plan.nodes[${index}]`;
210
+ assertObject(node, label);
211
+ const nodeRecord = /** @type {Record<string, unknown>} */ (node);
212
+ rejectUnknown(nodeRecord, PLAN_NODE_FIELDS, label);
213
+ requireId(nodeRecord.id, `${label}.id`);
214
+ requireString(nodeRecord.objective, `${label}.objective`);
215
+ if (typeof nodeRecord.taskKind !== "string" || !TASK_KINDS.includes(nodeRecord.taskKind)) {
216
+ throw new TypeError(`${label}.taskKind must be one of ${TASK_KINDS.join(", ")}`);
217
+ }
218
+ if (typeof nodeRecord.riskTier !== "string" || !RISK_TIERS.includes(nodeRecord.riskTier)) {
219
+ throw new TypeError(`${label}.riskTier must be one of ${RISK_TIERS.join(", ")}`);
220
+ }
221
+ const dependsOn = nodeRecord.dependsOn ?? [];
222
+ requireStringArray(dependsOn, `${label}.dependsOn`);
223
+ const readFiles = nodeRecord.readFiles ?? [];
224
+ requireStringArray(readFiles, `${label}.readFiles`);
225
+ const writeFiles = nodeRecord.writeFiles ?? [];
226
+ requireStringArray(writeFiles, `${label}.writeFiles`);
227
+ const definitionOfDone = validateDefinitionOfDone(nodeRecord.definitionOfDone ?? [], `${label}.definitionOfDone`);
228
+ const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${label}.verification`);
229
+ return /** @type {PlanOutputNode} */ ({
230
+ id: /** @type {string} */ (nodeRecord.id),
231
+ objective: /** @type {string} */ (nodeRecord.objective),
232
+ taskKind: /** @type {string} */ (nodeRecord.taskKind),
233
+ riskTier: /** @type {RiskTier} */ (nodeRecord.riskTier),
234
+ dependsOn: /** @type {string[]} */ (dependsOn),
235
+ readFiles: /** @type {string[]} */ (readFiles),
236
+ writeFiles: /** @type {string[]} */ (writeFiles),
237
+ definitionOfDone,
238
+ verification,
239
+ });
240
+ });
241
+ if (record.justification !== undefined) requireString(record.justification, "plan.justification");
242
+ return {
243
+ nodes,
244
+ ...(record.justification === undefined ? {} : { justification: /** @type {string} */ (record.justification) }),
245
+ };
246
+ }
247
+
248
+ const FINDING_FIELDS = new Set(["id", "severity", "nodeId", "text"]);
249
+ const FINDING_SEVERITIES = new Set(["critical", "major", "minor"]);
250
+
251
+ /**
252
+ * Validate a review or spec-review worker's `output.findings`. A finding
253
+ * without `severity` or `nodeId` is invalid.
254
+ *
255
+ * @param {unknown} findings
256
+ * @returns {PlanFindingOutput[]}
257
+ */
258
+ export function validateFindings(findings) {
259
+ if (!Array.isArray(findings)) throw new TypeError("findings must be an array");
260
+ return findings.map((finding, index) => {
261
+ const label = `findings[${index}]`;
262
+ assertObject(finding, label);
263
+ const record = /** @type {Record<string, unknown>} */ (finding);
264
+ rejectUnknown(record, FINDING_FIELDS, label);
265
+ requireId(record.id, `${label}.id`);
266
+ if (typeof record.severity !== "string" || !FINDING_SEVERITIES.has(record.severity)) {
267
+ throw new TypeError(`${label}.severity must be one of critical, major, minor`);
268
+ }
269
+ requireString(record.nodeId, `${label}.nodeId`);
270
+ requireString(record.text, `${label}.text`);
271
+ return /** @type {PlanFindingOutput} */ ({
272
+ id: /** @type {string} */ (record.id),
273
+ severity: /** @type {"critical"|"major"|"minor"} */ (record.severity),
274
+ nodeId: /** @type {string} */ (record.nodeId),
275
+ text: /** @type {string} */ (record.text),
276
+ });
277
+ });
278
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Owns sampling the seat's allowance -- a coarse, harness-reported rate-limit
3
+ * signal -- from one minimal live invocation, and the delta between two
4
+ * samples. Separate from every other file in `seat/` because this is the one
5
+ * that spends a real call, however small; `seat/harnesses.mjs` only launches
6
+ * and detects, `seat/index.mjs` only composes tmux with that registry.
7
+ *
8
+ * Measured 2026-09-17 against `claude -p 'Reply with exactly OK and use no
9
+ * tools.' --output-format stream-json --verbose`, at a cost of
10
+ * `total_cost_usd: 0.27848` for that one probe (see `protocol.mjs`'s
11
+ * `extractClaudeAllowance` header for the raw measured line and the scale
12
+ * evidence): the stream carries one `rate_limit_event` line, not a field on
13
+ * the terminal `result` event, and no other operator harness (codex, zcode,
14
+ * dsh, agy) has been measured. `defaultInvoke` below only ever spawns a
15
+ * process for `claude`; every other harness name returns null without
16
+ * spending a call, which is also what happens once a claude call runs but the
17
+ * adapter finds no signal on it (`normalize(...).allowance` with every member
18
+ * null). Every sampling call site pays this cost again: `campaign init`
19
+ * (once) and `plan freeze` (once per phase) each spend one probe when the
20
+ * sampled harness is claude.
21
+ */
22
+ import { spawn as nodeSpawn } from "node:child_process";
23
+ import { getHarness } from "../harnesses/index.mjs";
24
+
25
+ // `window` is optional on the type (not every construction site names one --
26
+ // `plan/pipeline.mjs` rebuilds a start sample from journal fields that predate
27
+ // this field) even though every live sample from `sampleAllowance` always
28
+ // carries it, `null` included.
29
+ /** @typedef {{remaining: number|null, limit: number|null, resetsAt: string|null, window?: string|null}} Allowance */
30
+ /** @typedef {{stdout: string, exitCode: number|null, signal: string|null}} InvokeResult */
31
+
32
+ const ALLOWANCE_PROBE_PROMPT = "Reply with exactly OK and use no tools.";
33
+ const ALLOWANCE_PROBE_TIMEOUT_MS = 30_000;
34
+ // The signal is account-wide (see protocol.mjs's extractClaudeAllowance
35
+ // header), not per-model, so the probe's own model choice does not affect
36
+ // what it measures; claude-sonnet-5 is the cheapest claude model this
37
+ // registry already names (src/engine/runtime-discovery.mjs), which keeps a
38
+ // spent probe call cheap without inventing an unlisted model string.
39
+ const ALLOWANCE_PROBE_MODEL = "claude-sonnet-5";
40
+
41
+ /**
42
+ * The argv/executable for one minimal claude probe, built through the claude
43
+ * adapter's own `command` so the probe pays the same preamble discipline
44
+ * (`claudePreambleArgs`: no skills, no MCP, no settings, a bare tool list)
45
+ * every worker invocation already pays instead of a hand-written argv that
46
+ * skips it -- measured 2026-09-01 on that discipline alone, ~4,300
47
+ * uncached input tokens per turn against 65,170 with the ambient
48
+ * configuration (see `harnesses/claude/index.mjs`).
49
+ *
50
+ * @returns {import("../harnesses/index.mjs").HarnessCommand}
51
+ */
52
+ function claudeProbeCommand() {
53
+ return getHarness("claude").command({ harness: "claude", model: ALLOWANCE_PROBE_MODEL }, ALLOWANCE_PROBE_PROMPT, {});
54
+ }
55
+
56
+ /**
57
+ * One minimal claude call, its stdout handed back raw for the adapter's own
58
+ * `normalize` to parse. Every other harness resolves to null without
59
+ * spawning anything: the signal has only ever been measured on claude's
60
+ * stream, and probing a harness that is known to expose nothing would spend a
61
+ * call for no reading.
62
+ *
63
+ * @param {string} harness
64
+ * @param {{spawn?: typeof nodeSpawn}} [options]
65
+ * @returns {Promise<InvokeResult|null>}
66
+ */
67
+ export function defaultInvoke(harness, { spawn = nodeSpawn } = {}) {
68
+ if (harness !== "claude") return Promise.resolve(null);
69
+ const command = claudeProbeCommand();
70
+ return new Promise((settle) => {
71
+ let child;
72
+ try {
73
+ child = spawn(command.executable, command.args, {
74
+ stdio: ["pipe", "pipe", "pipe"],
75
+ });
76
+ } catch {
77
+ settle(null);
78
+ return;
79
+ }
80
+ let stdout = "";
81
+ let settled = false;
82
+ const timer = setTimeout(() => {
83
+ try {
84
+ child.kill("SIGTERM");
85
+ } catch {
86
+ // ESRCH: the child is already gone.
87
+ }
88
+ }, ALLOWANCE_PROBE_TIMEOUT_MS);
89
+ // A missing or early-dying binary can make the write below fail with
90
+ // EPIPE on the stdin stream itself, which is a different EventEmitter
91
+ // from the child process object below and needs its own listener (see
92
+ // `engine/process.mjs`'s stdin-transport branch for the same guard).
93
+ child.stdin.on("error", () => {});
94
+ child.stdin.end(command.input ?? "");
95
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
96
+ child.stderr.on("data", () => {});
97
+ child.once("error", () => {
98
+ if (settled) return;
99
+ settled = true;
100
+ clearTimeout(timer);
101
+ settle(null);
102
+ });
103
+ child.once("close", (exitCode, signalName) => {
104
+ if (settled) return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ settle({ stdout, exitCode, signal: signalName });
108
+ });
109
+ });
110
+ }
111
+
112
+ /**
113
+ * The current seat allowance, from one minimal invocation, or null when the
114
+ * harness is unset, the invocation fails, or the harness's adapter reports no
115
+ * signal. Never throws: an allowance sample is advisory, and a probe failure
116
+ * must not fail the campaign operation sampling it.
117
+ *
118
+ * @param {{harness: string|null, invoke?: (harness: string) => Promise<InvokeResult|null>|InvokeResult|null}} options
119
+ * @returns {Promise<Allowance|null>}
120
+ */
121
+ export async function sampleAllowance({ harness, invoke = defaultInvoke }) {
122
+ if (!harness) return null;
123
+ /** @type {InvokeResult|null} */
124
+ let raw;
125
+ try {
126
+ raw = await invoke(harness);
127
+ } catch {
128
+ return null;
129
+ }
130
+ if (!raw || typeof raw.stdout !== "string") return null;
131
+ try {
132
+ const envelope = /** @type {{allowance?: Allowance|null}} */ (getHarness(harness).normalize(raw.stdout, raw.exitCode ?? null, raw.signal ?? null));
133
+ // The adapter always returns the shape (never omits it), with every
134
+ // member null when its stream carried no signal: that all-null shape and
135
+ // "no signal" are the same fact, so both collapse to null here.
136
+ return envelope.allowance && envelope.allowance.remaining !== null ? envelope.allowance : null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The change in remaining allowance between two samples, or null when either
144
+ * sample is absent, itself carries no remaining figure, or the two samples
145
+ * name different rate-limit windows: a `five_hour` utilization minus a
146
+ * `seven_day` one is not a delta, even though both are 0..1 fractions that
147
+ * subtract without error.
148
+ *
149
+ * @param {Allowance|null} start
150
+ * @param {Allowance|null} freeze
151
+ * @returns {number|null}
152
+ */
153
+ export function allowanceDelta(start, freeze) {
154
+ if (!start || !freeze || start.remaining === null || freeze.remaining === null) return null;
155
+ if (!start.window || !freeze.window || start.window !== freeze.window) return null;
156
+ return freeze.remaining - start.remaining;
157
+ }
158
+
159
+ /**
160
+ * The four sampled fields a `seat.allowance` journal event carries, from a
161
+ * sample or its absence: every member null when there is no sample, so a
162
+ * call site building the event never repeats this null-coalescing itself. The
163
+ * one place both `campaign init`'s start write and `plan freeze`'s freeze
164
+ * write get these fields from, alongside `appendSeatAllowanceEvent`
165
+ * (`campaign/journal.mjs`) being the one place either write actually happens.
166
+ *
167
+ * @param {Allowance|null} sample
168
+ * @returns {{remaining: number|null, limit: number|null, resetsAt: string|null, window: string|null}}
169
+ */
170
+ export function allowanceEventFields(sample) {
171
+ return {
172
+ remaining: sample?.remaining ?? null,
173
+ limit: sample?.limit ?? null,
174
+ resetsAt: sample?.resetsAt ?? null,
175
+ window: sample?.window ?? null,
176
+ };
177
+ }