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,394 @@
1
+ /**
2
+ * `operator-brief.md`: the smallest set of durable facts a fresh seat needs to
3
+ * take over a campaign, inside a hard 4 KiB budget.
4
+ *
5
+ * It is a pure function of recorded facts -- the projected state plus each
6
+ * linked run's `status.json` -- so the same facts always produce the same bytes
7
+ * and a brief can be rebuilt from the append-only journal alone. No model
8
+ * writes it: a generated summary could not be reconstructed after the seat that
9
+ * would have generated it died, which is the only case the brief exists for.
10
+ *
11
+ * The budget is a hard ceiling, not a target. A brief that overflows is the
12
+ * symptom of incomplete durable state, so what does not fit is dropped as whole
13
+ * records with a visible omission note, never by slicing the document.
14
+ */
15
+ import { BRIEF_BYTES, BRIEF_FILE, ID_CAP_FLOOR } from "./layout.mjs";
16
+ import { boundedText, compactCost, compactTokens, finite, readJsonTolerant } from "../util.mjs";
17
+ import { join } from "node:path";
18
+ import { readCampaign } from "./record.mjs";
19
+ import { readProjectionState } from "./projection.mjs";
20
+ import { writeTextAtomic } from "../run/store.mjs";
21
+
22
+ /** @typedef {import("./index.mjs").Campaign} Campaign */
23
+ /** @typedef {import("./index.mjs").JournalEntry} JournalEntry */
24
+ /** @typedef {import("./index.mjs").Projection} Projection */
25
+ /** @typedef {{id: string, exists: boolean, summary: string, controller: string, phase: string|null, done: number, total: number, costUsd: number|null, inputTokens: number|null, outputTokens: number|null, attention: {id: string, status: string, note: string}[]}} BriefRun */
26
+ /** @typedef {{campaign: Campaign, updatedAt: string, phase: string|null, checkpoints: {done: number, total: number}, runs: BriefRun[], openQuestions: JournalEntry[], transitions: JournalEntry[], budget: {costUsd: number|null, inputTokens: number|null, outputTokens: number|null}, notes: JournalEntry[], commands: string[]}} Brief */
27
+ /** @typedef {{limit: number, used: number, lines: string[]}} BriefBudget */
28
+
29
+ const BRIEF_TEXT_BYTES = 512;
30
+ const TEXT_CAPS = [BRIEF_TEXT_BYTES, 256, 128, 64, 0];
31
+ const SECTION_FLOOR_BYTES = 256;
32
+ const TRANSITION_LIMIT = 3;
33
+ const OPERATOR_NOTE_LIMIT = 5;
34
+ const RUN_LINE_LIMIT = 8;
35
+ const QUESTION_LINE_LIMIT = 8;
36
+ const ATTENTION_LINE_LIMIT = 3;
37
+ const QUIET_NODE_STATUSES = ["pending", "running", "done", "no-op"];
38
+
39
+ /**
40
+ * The facts of one brief, split from its rendering so the same facts always
41
+ * render the same markdown.
42
+ *
43
+ * @param {Campaign} campaign
44
+ * @param {Projection} state
45
+ * @param {BriefRun[]} runSummaries
46
+ * @returns {Brief}
47
+ */
48
+ export function briefFromState(campaign, state, runSummaries) {
49
+ return {
50
+ campaign,
51
+ updatedAt: state.updatedAt ?? campaign.updatedAt,
52
+ phase: runSummaries.find((run) => run.phase !== null)?.phase ?? null,
53
+ checkpoints: {
54
+ done: runSummaries.reduce((total, run) => total + run.done, 0),
55
+ total: runSummaries.reduce((total, run) => total + run.total, 0),
56
+ },
57
+ runs: runSummaries,
58
+ openQuestions: sortEntries(Object.values(state.questions)),
59
+ transitions: recentTransitions(state),
60
+ budget: totalBudget(runSummaries),
61
+ notes: state.intents.slice(-OPERATOR_NOTE_LIMIT),
62
+ commands: briefCommands(campaign.id),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * @param {string} campaignPath
68
+ * @param {Brief} brief
69
+ * @returns {string}
70
+ */
71
+ export function materializeBrief(campaignPath, brief) {
72
+ const text = fitBrief(brief);
73
+ writeTextAtomic(join(campaignPath, BRIEF_FILE), text);
74
+ return text;
75
+ }
76
+
77
+ /**
78
+ * Read the campaign's live projection and each linked run's `status.json`, then
79
+ * materialize the brief. `heartbeat.json` does not exist in this tree -- the
80
+ * projection and per-run status are the durable sources.
81
+ *
82
+ * @param {string} campaignPath
83
+ * @param {string} runsDir
84
+ * @returns {string}
85
+ */
86
+ export function renderBrief(campaignPath, runsDir) {
87
+ const campaign = readCampaign(campaignPath);
88
+ const { state } = readProjectionState(campaignPath, campaign);
89
+ return materializeBrief(campaignPath, briefFromState(campaign, state, readRunBriefs(runsDir, campaign.linkedRunIds)));
90
+ }
91
+
92
+ /**
93
+ * @param {string} runsDir
94
+ * @param {string[]} runIds
95
+ * @returns {BriefRun[]}
96
+ */
97
+ function readRunBriefs(runsDir, runIds) {
98
+ return runIds.map((runId) => runBrief(runId, readJsonTolerant(join(runsDir, runId, "status.json"))));
99
+ }
100
+
101
+ /**
102
+ * @param {string} runId
103
+ * @param {unknown} status
104
+ * @returns {BriefRun}
105
+ */
106
+ function runBrief(runId, status) {
107
+ if (!status || typeof status !== "object" || Array.isArray(status)) return missingRunBrief(runId);
108
+ const record = /** @type {Record<string, any>} */ (status);
109
+ const nodes = Array.isArray(record.nodes) ? record.nodes : [];
110
+ const usage = record.usage && typeof record.usage === "object" ? /** @type {Record<string, unknown>} */ (record.usage) : {};
111
+ const active = nodes.find((node) => node.status === "running")
112
+ ?? nodes.find((node) => !QUIET_NODE_STATUSES.includes(String(node.status)));
113
+ /** @type {string|null} */
114
+ let phase = null;
115
+ if (active) {
116
+ if (typeof active.phase === "string") phase = active.phase;
117
+ else if (typeof active.executionPhase === "string") phase = active.executionPhase;
118
+ }
119
+ return {
120
+ id: runId,
121
+ exists: true,
122
+ summary: typeof record.summary === "string" ? record.summary : "",
123
+ controller: typeof record.controller?.state === "string" ? record.controller.state : "none",
124
+ phase,
125
+ done: nodes.filter((node) => node.status === "done" || node.status === "no-op").length,
126
+ total: nodes.length,
127
+ costUsd: finite(usage.costUsd),
128
+ inputTokens: finite(usage.inputTokens),
129
+ outputTokens: finite(usage.outputTokens),
130
+ attention: nodes
131
+ .filter((node) => !QUIET_NODE_STATUSES.includes(String(node.status)))
132
+ .map((node) => ({
133
+ id: String(node.id ?? ""),
134
+ status: String(node.status ?? ""),
135
+ note: typeof node.note === "string" ? node.note : typeof node.errorCode === "string" ? node.errorCode : "",
136
+ })),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * @param {string} runId
142
+ * @returns {BriefRun}
143
+ */
144
+ function missingRunBrief(runId) {
145
+ return { id: runId, exists: false, summary: "no status.json yet", controller: "none", phase: null, done: 0, total: 0, costUsd: null, inputTokens: null, outputTokens: null, attention: [] };
146
+ }
147
+
148
+ /**
149
+ * @param {Projection} state
150
+ * @returns {JournalEntry[]}
151
+ */
152
+ function recentTransitions(state) {
153
+ /** @type {JournalEntry[]} */
154
+ const entries = [
155
+ ...state.sessions,
156
+ ...Object.values(state.decisions),
157
+ ...state.constraints,
158
+ ...state.outcomes,
159
+ ...Object.values(state.questions),
160
+ ];
161
+ if (state.next) entries.push(state.next);
162
+ return sortEntries(entries).slice(-TRANSITION_LIMIT);
163
+ }
164
+
165
+ /**
166
+ * @param {BriefRun[]} runs
167
+ * @returns {{costUsd: number|null, inputTokens: number|null, outputTokens: number|null}}
168
+ */
169
+ function totalBudget(runs) {
170
+ /** @type {{costUsd: number|null, inputTokens: number|null, outputTokens: number|null}} */
171
+ const budget = { costUsd: null, inputTokens: null, outputTokens: null };
172
+ for (const run of runs) {
173
+ if (typeof run.costUsd === "number") budget.costUsd = (budget.costUsd ?? 0) + run.costUsd;
174
+ if (typeof run.inputTokens === "number") budget.inputTokens = (budget.inputTokens ?? 0) + run.inputTokens;
175
+ if (typeof run.outputTokens === "number") budget.outputTokens = (budget.outputTokens ?? 0) + run.outputTokens;
176
+ }
177
+ return budget;
178
+ }
179
+
180
+ /**
181
+ * @param {JournalEntry[]} entries
182
+ * @returns {JournalEntry[]}
183
+ */
184
+ function sortEntries(entries) {
185
+ return [...entries].sort(compareEntries);
186
+ }
187
+
188
+ /**
189
+ * @param {JournalEntry} left
190
+ * @param {JournalEntry} right
191
+ * @returns {number}
192
+ */
193
+ function compareEntries(left, right) {
194
+ if (left.at !== right.at) return left.at < right.at ? -1 : 1;
195
+ if (left.eventId !== right.eventId) return left.eventId < right.eventId ? -1 : 1;
196
+ return 0;
197
+ }
198
+
199
+ /**
200
+ * @param {string} campaignId
201
+ * @returns {string[]}
202
+ */
203
+ function briefCommands(campaignId) {
204
+ return [
205
+ `faberun campaign show ${campaignId}`,
206
+ `faberun campaign sync ${campaignId} --session-id <session>`,
207
+ `faberun campaign ack ${campaignId} --session-id <session> --event-id <event>`,
208
+ `faberun campaign attach ${campaignId} --tool <tool> --session-id <session> (--transcript <path> | --no-transcript)`,
209
+ `faberun campaign note ${campaignId} --session-id <session> --kind <kind> --text <text>`,
210
+ `faberun campaign resolve ${campaignId} --session-id <session> --question-id <question> --text <text>`,
211
+ ];
212
+ }
213
+
214
+ // The ceiling is enforced line by line against BRIEF_BYTES, and every section
215
+ // after the current one keeps a floor so a flood of run lines or questions can
216
+ // never starve the budget, the notes or the commands. Entry text shrinks
217
+ // through descending caps (like fitHandoff) until no section is lost entirely.
218
+ /**
219
+ * @param {Brief} brief
220
+ * @returns {string}
221
+ */
222
+ function fitBrief(brief) {
223
+ for (const cap of TEXT_CAPS) {
224
+ const rendered = renderBudgeted(brief, cap);
225
+ if (rendered.criticalLost === 0) return rendered.text;
226
+ }
227
+ return renderBudgeted(brief, 0).text;
228
+ }
229
+
230
+ /**
231
+ * @param {Brief} brief
232
+ * @param {number} cap
233
+ * @returns {{text: string, criticalLost: number}}
234
+ */
235
+ function renderBudgeted(brief, cap) {
236
+ const idCap = Math.max(cap, ID_CAP_FLOOR);
237
+ const budget = /** @type {BriefBudget} */ ({ limit: BRIEF_BYTES, used: 0, lines: [] });
238
+ addLine(budget, `# campaign ${boundedText(brief.campaign.id, idCap)} brief`);
239
+ addLine(budget, "");
240
+ addLine(budget, `Updated: ${brief.updatedAt}`);
241
+ addLine(budget, "");
242
+ addLine(budget, `Goal: ${boundedText(brief.campaign.goal, cap)}`);
243
+ addLine(budget, "");
244
+ addLine(budget, `Phase: ${brief.phase === null ? "unknown" : boundedText(brief.phase, cap)} · checkpoints: ${brief.checkpoints.done}/${brief.checkpoints.total}`);
245
+ addLine(budget, "");
246
+ const runs = brief.runs.slice(-RUN_LINE_LIMIT).map((run) => runLine(run, cap, idCap));
247
+ const questions = brief.openQuestions.slice(-QUESTION_LINE_LIMIT)
248
+ .map((entry) => `- [${boundedText(entry.questionId ?? "-", idCap)}] ${boundedText(entry.text ?? "", cap)}`);
249
+ const transitions = brief.transitions.map((entry) => `- ${transitionLine(entry, cap, idCap)}`);
250
+ const notes = brief.notes.map((entry) => `- ${boundedText(entry.text ?? "", cap)} · ${boundedText(entry.sessionId ?? "-", idCap)} · ${boundedText(entry.at, idCap)}`);
251
+ const commands = brief.commands.map((command) => `- ${command}`);
252
+ let criticalLost = 0;
253
+ criticalLost += addLineSection(budget, "Current state", [`- status: ${brief.campaign.status}`, `- linked runs: ${brief.runs.length}`], runs, "run summaries", 6);
254
+ criticalLost += addLineSection(budget, "Open decisions", [], questions, "open decisions", 5);
255
+ criticalLost += addLineSection(budget, "Recent transitions", [], transitions, "transitions", 4);
256
+ criticalLost += addLineSection(budget, "Budget consumed", [], [`- ${budgetLine(brief.budget)}`], "budget lines", 3);
257
+ criticalLost += addLineSection(budget, "Operator notes", [], notes, "operator notes", 2);
258
+ criticalLost += addLineSection(budget, "Commands", [], commands, "commands", 1);
259
+ return { text: `${budget.lines.join("\n")}\n`, criticalLost };
260
+ }
261
+
262
+ /**
263
+ * @param {BriefBudget} budget
264
+ * @param {string} title
265
+ * @param {string[]} prefix
266
+ * @param {string[]} lines
267
+ * @param {string} label
268
+ * @param {number} sectionsLeft
269
+ * @returns {0|1}
270
+ */
271
+ function addLineSection(budget, title, prefix, lines, label, sectionsLeft) {
272
+ const limit = sectionLimit(sectionsLeft);
273
+ addLine(budget, `## ${title}`);
274
+ addLine(budget, "");
275
+ for (const line of prefix) addLine(budget, line);
276
+ if (!lines.length) {
277
+ addLine(budget, "- none");
278
+ addLine(budget, "");
279
+ return 0;
280
+ }
281
+ const kept = fitLines(budget, lines, label, limit);
282
+ addLine(budget, "");
283
+ return kept === 0 ? 1 : 0;
284
+ }
285
+
286
+ /**
287
+ * The absolute byte ceiling for a section: every section still to come keeps
288
+ * its floor, so the last section is never starved by the first.
289
+ *
290
+ * @param {number} sectionsLeft
291
+ * @returns {number}
292
+ */
293
+ function sectionLimit(sectionsLeft) {
294
+ return BRIEF_BYTES - SECTION_FLOOR_BYTES * Math.max(0, sectionsLeft - 1);
295
+ }
296
+
297
+ /**
298
+ * Keep the latest whole lines that fit; older ones are summarized, never
299
+ * silently dropped. Returns the number of lines actually added.
300
+ *
301
+ * @param {BriefBudget} budget
302
+ * @param {string[]} lines
303
+ * @param {string} label
304
+ * @param {number} limit
305
+ * @returns {number}
306
+ */
307
+ function fitLines(budget, lines, label, limit) {
308
+ /** @type {string[]} */
309
+ const kept = [];
310
+ let projected = budget.used;
311
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
312
+ const bytes = byteLength(lines[index]) + 1;
313
+ if (projected + bytes > limit) break;
314
+ projected += bytes;
315
+ kept.unshift(lines[index]);
316
+ }
317
+ while (kept.length) {
318
+ const note = `- ${lines.length - kept.length} earlier ${label} omitted`;
319
+ if (projected + byteLength(note) + 1 <= limit) break;
320
+ const removed = kept.shift();
321
+ if (removed === undefined) break;
322
+ projected -= byteLength(removed) + 1;
323
+ }
324
+ for (const line of kept) addLine(budget, line);
325
+ const dropped = lines.length - kept.length;
326
+ if (dropped > 0) addLine(budget, `- ${dropped} earlier ${label} omitted`);
327
+ return kept.length;
328
+ }
329
+
330
+ /**
331
+ * @param {BriefBudget} budget
332
+ * @param {string} line
333
+ * @returns {boolean}
334
+ */
335
+ function addLine(budget, line) {
336
+ const bytes = byteLength(line) + 1;
337
+ if (budget.used + bytes > budget.limit) return false;
338
+ budget.lines.push(line);
339
+ budget.used += bytes;
340
+ return true;
341
+ }
342
+
343
+ /**
344
+ * @param {string} value
345
+ * @returns {number}
346
+ */
347
+ function byteLength(value) {
348
+ return Buffer.byteLength(value, "utf8");
349
+ }
350
+
351
+ /**
352
+ * A run is one line so it is kept or dropped whole, never cut between its
353
+ * summary and its attention states.
354
+ *
355
+ * @param {BriefRun} run
356
+ * @param {number} cap
357
+ * @param {number} idCap
358
+ * @returns {string}
359
+ */
360
+ function runLine(run, cap, idCap) {
361
+ const id = boundedText(run.id, idCap);
362
+ if (!run.exists) return `- ${id}: no status.json yet`;
363
+ const attention = run.attention.slice(0, ATTENTION_LINE_LIMIT).map((node) => {
364
+ const note = node.note ? ` ${boundedText(node.note, cap)}` : "";
365
+ return `${boundedText(node.id, idCap)}: ${boundedText(node.status, cap)}${note}`;
366
+ });
367
+ const hidden = run.attention.length - ATTENTION_LINE_LIMIT;
368
+ if (hidden > 0) attention.push(`${hidden} more`);
369
+ const detail = attention.length ? ` · ${attention.join("; ")}` : "";
370
+ return `- ${id}: ${boundedText(run.summary, cap)} · checkpoints ${run.done}/${run.total} · controller ${boundedText(run.controller, cap)}${detail}`;
371
+ }
372
+
373
+ /**
374
+ * @param {JournalEntry} entry
375
+ * @param {number} cap
376
+ * @param {number} idCap
377
+ * @returns {string}
378
+ */
379
+ function transitionLine(entry, cap, idCap) {
380
+ const id = entry.decisionId ?? entry.questionId ?? entry.runId;
381
+ const label = id === undefined ? "" : `[${boundedText(id, idCap)}] `;
382
+ const text = entry.type === "session.attached"
383
+ ? `session ${boundedText(entry.sessionId ?? "-", idCap)} (${boundedText(entry.tool ?? "-", idCap)})`
384
+ : boundedText(entry.text ?? "", cap);
385
+ return `${boundedText(entry.at, idCap)} ${entry.type} ${label}${text}`;
386
+ }
387
+
388
+ /**
389
+ * @param {{costUsd: number|null, inputTokens: number|null, outputTokens: number|null}} budget
390
+ * @returns {string}
391
+ */
392
+ function budgetLine(budget) {
393
+ return `cost ${compactCost(budget.costUsd)} · input ${compactTokens(budget.inputTokens)} · output ${compactTokens(budget.outputTokens)} tokens`;
394
+ }