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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- 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
|
+
}
|