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,516 @@
1
+ /**
2
+ * `HANDOFF.md`: the campaign's state rendered for the next session to read
3
+ * first, inside a byte budget.
4
+ *
5
+ * The budget is the whole design. A handoff that grows without bound stops being
6
+ * read, so sections compete: critical first with a guaranteed floor, then linked
7
+ * runs, then the low-priority tail, each fitted line by line. What does not fit
8
+ * is dropped visibly, never truncated mid-record.
9
+ */
10
+ import { CRITICAL_FLOOR_BYTES, GOAL_TEXT_BYTES, HANDOFF_BYTES, HANDOFF_FILE, HANDOFF_LIMIT, ID_CAP_FLOOR, JOURNAL_TEXT_BYTES, RENDER_NOTE_BYTES } from "./layout.mjs";
11
+ import { basename, dirname, join } from "node:path";
12
+ import { boundedText } from "../util.mjs";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { writeTextAtomic } from "../run/store.mjs";
15
+ import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
16
+
17
+ /** @typedef {import("./index.mjs").Campaign} Campaign */
18
+ /** @typedef {import("./index.mjs").Handoff} Handoff */
19
+ /** @typedef {import("./index.mjs").JournalEntry} JournalEntry */
20
+ /** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
21
+ /** @typedef {import("./index.mjs").Projection} Projection */
22
+ /** @typedef {import("./index.mjs").RunSummary} RunSummary */
23
+
24
+ /**
25
+ * @param {Handoff} handoff
26
+ * @returns {string}
27
+ */
28
+ export function fitHandoff(handoff) {
29
+ // The internal LineBudget already bounds every candidate to 16 KiB, so the
30
+ // descending caps exist to shrink entry text until no critical entry is lost,
31
+ // not to shrink the document. Return the first candidate with zero critical
32
+ // loss; cap 0 always preserves at least a bare line per critical entry
33
+ // because every rendered fragment, including identifiers, is bounded.
34
+ for (const cap of [JOURNAL_TEXT_BYTES, 1024, 512, 256, 128, 64, 32, 0]) {
35
+ const rendered = renderBudgeted(handoff, cap);
36
+ if (rendered.criticalLost === 0) return rendered.text;
37
+ }
38
+ return renderBudgeted(handoff, 0).text;
39
+ }
40
+ /**
41
+ * @param {Campaign} campaign
42
+ * @param {Projection} state
43
+ * @param {string} runsDir
44
+ * @returns {Handoff}
45
+ */
46
+ export function handoffFromState(campaign, state, runsDir) {
47
+ return {
48
+ campaign,
49
+ updatedAt: state.updatedAt ?? campaign.updatedAt,
50
+ linkedRuns: campaign.linkedRunIds.map((runId) => runSummary(join(runsDir, runId))),
51
+ activeDecisions: Object.values(state.decisions),
52
+ constraints: state.constraints,
53
+ intents: lastN(state.intents),
54
+ outcomes: lastN(state.outcomes),
55
+ nextEntry: state.next,
56
+ questions: Object.values(state.questions),
57
+ sessions: state.sessions,
58
+ totals: {
59
+ decisions: Object.keys(state.decisions).length,
60
+ constraints: state.constraints.length,
61
+ intents: state.intents.length,
62
+ outcomes: state.outcomes.length,
63
+ questions: Object.keys(state.questions).length,
64
+ sessions: state.sessions.length,
65
+ },
66
+ evicted: state.evicted,
67
+ };
68
+ }
69
+ /**
70
+ * @param {string} campaignPath
71
+ * @param {Handoff} handoff
72
+ * @returns {string}
73
+ */
74
+ export function materializeHandoff(campaignPath, handoff) {
75
+ const text = fitHandoff(handoff);
76
+ writeTextAtomic(join(campaignPath, HANDOFF_FILE), text);
77
+ return text;
78
+ }
79
+ /**
80
+ * @param {Handoff} handoff
81
+ * @param {number} cap
82
+ * @returns {{text: string, criticalLost: number}}
83
+ */
84
+ function renderBudgeted(handoff, cap) {
85
+ const {
86
+ campaign, linkedRuns, activeDecisions, constraints, intents, outcomes, nextEntry, questions, sessions,
87
+ totals = { decisions: 0, constraints: 0, intents: 0, outcomes: 0, questions: 0, sessions: 0 },
88
+ evicted = { sessions: 0, decisions: 0, constraints: 0, questions: 0, intents: 0, outcomes: 0 },
89
+ } = handoff;
90
+ // Identifiers and attention notes keep a floor so critical entries stay
91
+ // recognizable even when the descending text caps reach zero.
92
+ const noteCap = Math.max(Math.min(RENDER_NOTE_BYTES, cap), ID_CAP_FLOOR);
93
+ const idCap = Math.max(cap, ID_CAP_FLOOR);
94
+ const budget = new LineBudget(HANDOFF_BYTES);
95
+ addSection(budget, [
96
+ `# campaign ${boundedText(campaign.id, idCap)} handoff`,
97
+ "",
98
+ `Updated: ${handoff.updatedAt}`,
99
+ "",
100
+ "## Goal",
101
+ "",
102
+ boundedText(campaign.goal, GOAL_TEXT_BYTES),
103
+ "",
104
+ ]);
105
+ // Each critical section reserves a floor for every section that still
106
+ // follows, so one greedy section can never starve a later critical one
107
+ // (including attention-needed linked runs); low-priority history takes
108
+ // whatever remains.
109
+ const attentionPresent = hasAttentionRuns(linkedRuns);
110
+ let sectionsLeft = 5 + (attentionPresent ? 1 : 0);
111
+ const nextSection = () => {
112
+ const left = sectionsLeft;
113
+ sectionsLeft -= 1;
114
+ return left;
115
+ };
116
+ let criticalLost = 0;
117
+ // Stamped like every other journal section: this is the entry most likely to
118
+ // go stale, because it names a run that later runs supersede. Without the
119
+ // timestamp a reader cannot tell it apart from the current frontier.
120
+ criticalLost += criticalSection(budget, "Latest next action", nextEntry ? [nextEntry] : [], (entry) => entryLine(entry, idCap, cap), 0, nextSection(), CRITICAL_FLOOR_BYTES);
121
+ criticalLost += criticalSection(budget, "Session lineage", sessions, (entry) => sessionLine(entry, idCap, cap), totals.sessions - sessions.length + (evicted.sessions ?? 0), nextSection(), CRITICAL_FLOOR_BYTES);
122
+ criticalLost += criticalSection(budget, "Active decisions", activeDecisions, (entry) => decisionLine(entry, idCap, cap), totals.decisions - activeDecisions.length + (evicted.decisions ?? 0), nextSection(), CRITICAL_FLOOR_BYTES);
123
+ criticalLost += criticalSection(budget, "User constraints", constraints, (entry) => entryLine(entry, idCap, cap), totals.constraints - constraints.length + (evicted.constraints ?? 0), nextSection(), CRITICAL_FLOOR_BYTES);
124
+ criticalLost += criticalSection(budget, "Open questions", questions, (entry) => entryLine(entry, idCap, cap), totals.questions - questions.length + (evicted.questions ?? 0), nextSection(), CRITICAL_FLOOR_BYTES);
125
+ criticalLost += renderLinkedRuns(budget, linkedRuns, noteCap, idCap, attentionPresent ? nextSection() : null, CRITICAL_FLOOR_BYTES);
126
+ lowPriorityLines(budget, "Recent user intents", intents, (entry) => entryLine(entry, idCap, cap), totals.intents - intents.length + (evicted.intents ?? 0));
127
+ lowPriorityLines(budget, "Attempts and outcomes", outcomes, (entry) => outcomeLine(entry, idCap, cap), totals.outcomes - outcomes.length + (evicted.outcomes ?? 0));
128
+ return { text: `${budget.lines.join("\n")}\n`, criticalLost };
129
+ }
130
+ /**
131
+ * @param {RunSummary[]} linkedRuns
132
+ * @returns {boolean}
133
+ */
134
+ function hasAttentionRuns(linkedRuns) {
135
+ return linkedRuns.some((run) => !run.exists || run.unreadable || run.attention.length > 0);
136
+ }
137
+ /**
138
+ * @param {LineBudget} budget
139
+ * @param {string[]} lines
140
+ * @returns {boolean}
141
+ */
142
+ function addSection(budget, lines) {
143
+ if (!lines.length) return true;
144
+ let total = 0;
145
+ for (const line of lines) total += Buffer.byteLength(line, "utf8") + 1;
146
+ if (budget.used + total > budget.limit) return false;
147
+ for (const line of lines) budget.add(line);
148
+ return true;
149
+ }
150
+ // Critical sections must never be dropped wholesale: when the remaining budget
151
+ // cannot hold every entry, keep the latest entries that fit and note the rest.
152
+ // Returns 1 when a non-empty section loses every entry (nothing but the
153
+ // placeholder was rendered), 0 otherwise; a section that keeps its latest
154
+ // entries plus an omission note is considered preserved. The floor keeps later
155
+ // critical sections from being starved by earlier greedy ones.
156
+ /**
157
+ * @param {LineBudget} budget
158
+ * @param {string} title
159
+ * @param {JournalEntry[]} entries
160
+ * @param {(entry: JournalEntry) => string} lineFor
161
+ * @param {number} omittedCount
162
+ * @param {number} sectionsLeft
163
+ * @param {number} floor
164
+ * @returns {0|1}
165
+ */
166
+ function criticalSection(budget, title, entries, lineFor, omittedCount, sectionsLeft, floor) {
167
+ budget.add(`## ${title}`);
168
+ budget.add("");
169
+ if (!entries.length) {
170
+ budget.add("None.");
171
+ budget.add("");
172
+ return 0;
173
+ }
174
+ const kept = budgetedLines(budget, entries.map((entry) => `- ${lineFor(entry)}`), title.toLowerCase(), omittedCount, criticalLimit(budget, sectionsLeft, floor));
175
+ budget.add("");
176
+ return kept === 0 ? 1 : 0;
177
+ }
178
+ /**
179
+ * @param {LineBudget} budget
180
+ * @param {number} sectionsLeft
181
+ * @param {number} floor
182
+ * @returns {number}
183
+ */
184
+ function criticalLimit(budget, sectionsLeft, floor) {
185
+ // An absolute byte ceiling: budgetedLines already compares against the used
186
+ // budget, so the floor reservation must not subtract it a second time.
187
+ return budget.limit - floor * Math.max(0, sectionsLeft - 1);
188
+ }
189
+ /**
190
+ * @param {LineBudget} budget
191
+ * @param {string} title
192
+ * @param {JournalEntry[]} entries
193
+ * @param {(entry: JournalEntry) => string} lineFor
194
+ * @param {number} omittedCount
195
+ */
196
+ function lowPriorityLines(budget, title, entries, lineFor, omittedCount) {
197
+ if (!budget.addAll([`## ${title}`, ""])) return;
198
+ if (!entries.length) {
199
+ budget.addAll(["None.", ""]);
200
+ return;
201
+ }
202
+ budgetedLines(budget, entries.map((entry) => `- ${lineFor(entry)}`), title.toLowerCase(), omittedCount);
203
+ budget.add("");
204
+ }
205
+ // Keep the latest complete lines that fit; older lines are summarized, never
206
+ // silently dropped. Returns the number of entries actually rendered.
207
+ /**
208
+ * @param {LineBudget} budget
209
+ * @param {string[]} lines
210
+ * @param {string} label
211
+ * @param {number} omittedCount
212
+ * @param {number} limit
213
+ * @returns {number}
214
+ */
215
+ function budgetedLines(budget, lines, label, omittedCount, limit = budget.limit) {
216
+ let kept = 0;
217
+ let projected = budget.used;
218
+ while (kept < lines.length) {
219
+ const bytes = Buffer.byteLength(lines[lines.length - 1 - kept], "utf8") + 1;
220
+ if (projected + bytes > limit) break;
221
+ projected += bytes;
222
+ kept += 1;
223
+ }
224
+ const dropped = lines.length - kept + omittedCount;
225
+ if (dropped > 0) {
226
+ // Reserve room for the omission note so it is never silently dropped.
227
+ while (kept > 0 && projected + Buffer.byteLength(`- ${dropped} earlier ${label} omitted`, "utf8") + 1 > limit) {
228
+ kept -= 1;
229
+ projected -= Buffer.byteLength(lines[lines.length - 1 - kept], "utf8") + 1;
230
+ }
231
+ }
232
+ if (kept === 0) {
233
+ budget.add(`- (none fits the remaining budget)`);
234
+ } else {
235
+ for (let index = lines.length - kept; index < lines.length; index += 1) budget.add(lines[index]);
236
+ }
237
+ if (dropped > 0) budget.add(`- ${dropped} earlier ${label} omitted`);
238
+ return kept;
239
+ }
240
+ // Linked runs are critical when a run needs attention: losing the whole
241
+ // section or every line of an attention group counts as critical loss so the
242
+ // cap loop shrinks text until attention-needed states survive. A partially
243
+ // kept group (summary plus latest detail lines plus an omission note) is
244
+ // considered preserved, matching the other critical sections.
245
+ /**
246
+ * @param {LineBudget} budget
247
+ * @param {RunSummary[]} linkedRuns
248
+ * @param {number} noteCap
249
+ * @param {number} idCap
250
+ * @param {number|null} sectionsLeft
251
+ * @param {number} floor
252
+ * @returns {0|1}
253
+ */
254
+ function renderLinkedRuns(budget, linkedRuns, noteCap, idCap, sectionsLeft, floor) {
255
+ const runs = buildRunsGroups(linkedRuns, noteCap, idCap);
256
+ if (runs.empty) {
257
+ addSection(budget, ["## Linked runs", "", "No linked runs yet.", ""]);
258
+ return 0;
259
+ }
260
+ const limit = sectionsLeft === null || sectionsLeft === undefined ? budget.limit : criticalLimit(budget, sectionsLeft, floor);
261
+ const headingFits = addSection(budget, ["## Linked runs", ""]);
262
+ const attentionKept = headingFits ? budgetedGroupLines(budget, runs.attention, "attention-needed run states", limit) : 0;
263
+ if (runs.attention.length && attentionKept === 0) return 1;
264
+ if (headingFits) budgetedGroupLines(budget, runs.regular, "run summaries", limit);
265
+ budget.add("");
266
+ return 0;
267
+ }
268
+ // Runs are rendered as whole groups (summary line plus node detail lines) so a
269
+ // run is never cut midway. When a group does not fully fit, its summary line
270
+ // and the latest detail lines are kept and the rest is summarized. Returns the
271
+ // number of lines actually added to the budget.
272
+ /**
273
+ * @param {LineBudget} budget
274
+ * @param {string[][]} groups
275
+ * @param {string} label
276
+ * @param {number} limit
277
+ * @returns {number}
278
+ */
279
+ function budgetedGroupLines(budget, groups, label, limit = budget.limit) {
280
+ const rendered = [];
281
+ let projected = budget.used;
282
+ let omitted = 0;
283
+ let added = 0;
284
+ for (let index = groups.length - 1; index >= 0; index -= 1) {
285
+ const lines = groups[index];
286
+ if (projected + totalLineBytes(lines) <= limit) {
287
+ projected += totalLineBytes(lines);
288
+ rendered.unshift(lines);
289
+ added += lines.length;
290
+ continue;
291
+ }
292
+ const kept = fitGroupLines(lines, projected, limit);
293
+ if (kept.length) {
294
+ projected += totalLineBytes(kept);
295
+ rendered.unshift(kept);
296
+ added += kept.length;
297
+ omitted += lines.length - kept.length;
298
+ continue;
299
+ }
300
+ omitted += lines.length;
301
+ for (let older = index - 1; older >= 0; older -= 1) omitted += groups[older].length;
302
+ break;
303
+ }
304
+ if (omitted > 0) {
305
+ // Reserve room for the omission note so it is never silently dropped.
306
+ while (rendered.length && projected + Buffer.byteLength(`- ${omitted} earlier ${label} omitted`, "utf8") + 1 > limit) {
307
+ const lines = rendered.shift() ?? [];
308
+ projected -= totalLineBytes(lines);
309
+ added -= lines.length;
310
+ omitted += lines.length;
311
+ }
312
+ }
313
+ for (const lines of rendered) {
314
+ for (const line of lines) budget.add(line);
315
+ }
316
+ if (omitted > 0) budget.add(`- ${omitted} earlier ${label} omitted`);
317
+ return added;
318
+ }
319
+ /**
320
+ * @param {string[]} lines
321
+ * @param {number} projected
322
+ * @param {number} limit
323
+ * @returns {string[]}
324
+ */
325
+ function fitGroupLines(lines, projected, limit) {
326
+ const leadBytes = Buffer.byteLength(lines[0], "utf8") + 1;
327
+ if (projected + leadBytes > limit) return [];
328
+ const detail = [];
329
+ projected += leadBytes;
330
+ for (let index = lines.length - 1; index > 0; index -= 1) {
331
+ const bytes = Buffer.byteLength(lines[index], "utf8") + 1;
332
+ if (projected + bytes > limit) break;
333
+ projected += bytes;
334
+ detail.unshift(lines[index]);
335
+ }
336
+ return [lines[0], ...detail];
337
+ }
338
+ /**
339
+ * @param {string[]} lines
340
+ * @returns {number}
341
+ */
342
+ function totalLineBytes(lines) {
343
+ let bytes = 0;
344
+ for (const line of lines) bytes += Buffer.byteLength(line, "utf8") + 1;
345
+ return bytes;
346
+ }
347
+ /**
348
+ * @param {JournalEntry} entry
349
+ * @param {number} idCap
350
+ * @param {number} textCap
351
+ * @returns {string}
352
+ */
353
+ function sessionLine(entry, idCap, textCap) {
354
+ const tool = boundedText(entry.tool, idCap);
355
+ const sessionId = boundedText(entry.sessionId, idCap);
356
+ const transcript = entry.transcriptUnavailable ? "unavailable" : boundedText(entry.transcript, Math.max(idCap, 128));
357
+ const format = boundedText(entry.format ?? "-", idCap);
358
+ const cursor = boundedText(entry.cursor ?? "-", idCap);
359
+ return `${tool} ${sessionId} · transcript: ${transcript} · format: ${format} · cursor: ${cursor}`;
360
+ }
361
+ /**
362
+ * @param {JournalEntry} entry
363
+ * @param {number} idCap
364
+ * @param {number} textCap
365
+ * @returns {string}
366
+ */
367
+ function decisionLine(entry, idCap, textCap) {
368
+ return `[${boundedText(entry.decisionId, idCap)}] ${boundedText(entry.text, textCap)} · ${boundedText(entry.sessionId, idCap)} · ${boundedText(entry.at, idCap)}`;
369
+ }
370
+ /**
371
+ * @param {JournalEntry} entry
372
+ * @param {number} idCap
373
+ * @param {number} textCap
374
+ * @returns {string}
375
+ */
376
+ function entryLine(entry, idCap, textCap) {
377
+ return `${boundedText(entry.text, textCap)} · ${boundedText(entry.sessionId, idCap)} · ${boundedText(entry.at, idCap)}`;
378
+ }
379
+ /**
380
+ * @param {JournalEntry} entry
381
+ * @param {number} idCap
382
+ * @param {number} textCap
383
+ * @returns {string}
384
+ */
385
+ function outcomeLine(entry, idCap, textCap) {
386
+ const run = entry.runId ? `run ${boundedText(entry.runId, idCap)}: ` : "";
387
+ return `${run}${boundedText(entry.text, textCap)} · ${boundedText(entry.sessionId, idCap)} · ${boundedText(entry.at, idCap)}`;
388
+ }
389
+ /**
390
+ * @param {RunSummary[]} linkedRuns
391
+ * @param {number} noteCap
392
+ * @param {number} idCap
393
+ * @returns {{empty: boolean, attention: string[][], regular: string[][]}}
394
+ */
395
+ function buildRunsGroups(linkedRuns, noteCap, idCap) {
396
+ if (!linkedRuns.length) return { empty: true, attention: [], regular: [] };
397
+ const attention = [];
398
+ const regular = [];
399
+ for (const run of linkedRuns) {
400
+ const id = boundedText(run.id, idCap);
401
+ if (!run.exists) {
402
+ attention.push([`- ${id}: run directory missing`]);
403
+ continue;
404
+ }
405
+ if (run.unreadable) {
406
+ attention.push([`- ${id}: unreadable · ${boundedText(run.unreadable, noteCap)}`]);
407
+ continue;
408
+ }
409
+ if (!run.total) {
410
+ regular.push([`- ${id}: no node states yet`]);
411
+ continue;
412
+ }
413
+ const lines = [`- ${id}: ${boundedText(run.summary, idCap)}`];
414
+ if (run.attention.length) {
415
+ for (const node of run.attention) {
416
+ const nodeId = boundedText(node.id, idCap);
417
+ const status = boundedText(node.status, idCap);
418
+ lines.push(` - ${nodeId}: ${status}${node.note ? ` · ${boundedText(node.note, noteCap)}` : ""}`);
419
+ }
420
+ attention.push(lines);
421
+ } else {
422
+ regular.push(lines);
423
+ }
424
+ }
425
+ return { empty: false, attention, regular };
426
+ }
427
+ /**
428
+ * @param {string} runDir
429
+ * @returns {RunSummary}
430
+ */
431
+ function runSummary(runDir) {
432
+ const id = basename(runDir);
433
+ const nodeDir = dirname(nodeSnapshotPath(runDir, "placeholder"));
434
+ if (!existsSync(nodeDir)) return { id, exists: false, total: 0, summary: "", attention: [], unreadable: null };
435
+ let nodes;
436
+ try {
437
+ nodes = listNodeSnapshots(runDir)
438
+ .sort()
439
+ .map((name) => /** @type {JsonObject} */ (JSON.parse(readFileSync(nodeSnapshotPath(runDir, name.slice(0, -".json".length)), "utf8"))));
440
+ } catch (error) {
441
+ return { id, exists: true, total: 0, summary: "unreadable", attention: [], unreadable: `cannot read node states: ${error instanceof Error ? error.message : String(error)}` };
442
+ }
443
+ if (!nodes.length) return { id, exists: true, total: 0, summary: "no node states yet", attention: [], unreadable: null };
444
+ const counts = new Map();
445
+ for (const node of nodes) counts.set(/** @type {string} */ (node.status), (counts.get(/** @type {string} */ (node.status)) ?? 0) + 1);
446
+ const summary = `${nodes.length} nodes · ${[...counts].map(([status, count]) => `${count} ${status}`).join(" · ")}`;
447
+ const attention = nodes
448
+ .filter((node) => !["pending", "running", "done"].includes(/** @type {string} */ (node.status)))
449
+ .map((node) => {
450
+ const gate = /** @type {JsonObject|null|undefined} */ (node.gate);
451
+ const error = /** @type {JsonObject|null|undefined} */ (node.error);
452
+ const blockedBy = /** @type {unknown[]|undefined} */ (node.blockedBy);
453
+ const note = typeof gate?.summary === "string" ? gate.summary
454
+ : typeof error?.message === "string" ? error.message
455
+ : blockedBy?.length ? `blocked by ${blockedBy.join(", ")}`
456
+ : typeof node.phase === "string" ? node.phase : "";
457
+ return {
458
+ id: /** @type {string} */ (node.id),
459
+ status: /** @type {string} */ (node.status),
460
+ note,
461
+ };
462
+ });
463
+ return { id, exists: true, total: nodes.length, summary, attention, unreadable: null };
464
+ }
465
+ class LineBudget {
466
+ /**
467
+ * @param {number} limit
468
+ */
469
+ constructor(limit) {
470
+ /** @type {number} */
471
+ this.limit = limit;
472
+ /** @type {number} */
473
+ this.used = 0;
474
+ /** @type {string[]} */
475
+ this.lines = [];
476
+ }
477
+
478
+ /**
479
+ * @param {string} line
480
+ * @returns {boolean}
481
+ */
482
+ fits(line) {
483
+ return this.used + Buffer.byteLength(line, "utf8") + 1 <= this.limit;
484
+ }
485
+
486
+ /**
487
+ * @param {string} line
488
+ * @returns {boolean}
489
+ */
490
+ add(line) {
491
+ if (!this.fits(line)) return false;
492
+ this.lines.push(line);
493
+ this.used += Buffer.byteLength(line, "utf8") + 1;
494
+ return true;
495
+ }
496
+
497
+ /**
498
+ * @param {string[]} lines
499
+ * @returns {number}
500
+ */
501
+ addAll(lines) {
502
+ let added = 0;
503
+ for (const line of lines) {
504
+ if (!this.add(line)) break;
505
+ added += 1;
506
+ }
507
+ return added;
508
+ }
509
+ }
510
+ /**
511
+ * @param {JournalEntry[]} entries
512
+ * @returns {JournalEntry[]}
513
+ */
514
+ function lastN(entries) {
515
+ return entries.slice(-HANDOFF_LIMIT);
516
+ }