glm-coding-router 1.1.1 → 2.0.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 (39) hide show
  1. package/README.md +534 -419
  2. package/dist/bin/glm-review.js +46 -4
  3. package/dist/bin/glm-worker.js +37 -4
  4. package/dist/budget/estimator.js +218 -0
  5. package/dist/budget/manager.js +223 -0
  6. package/dist/cli.js +38 -0
  7. package/dist/commands/benchmark.js +4 -0
  8. package/dist/commands/dashboard.js +348 -0
  9. package/dist/commands/runs.js +568 -0
  10. package/dist/commands/usage.js +1 -40
  11. package/dist/commands/watch.js +289 -0
  12. package/dist/core/agent-args.js +20 -0
  13. package/dist/core/config.js +61 -0
  14. package/dist/core/errors.js +24 -0
  15. package/dist/core/paths.js +32 -0
  16. package/dist/core/process.js +83 -0
  17. package/dist/core/prompt.js +18 -5
  18. package/dist/core/routing-flags.js +59 -0
  19. package/dist/core/zai-quota.js +46 -0
  20. package/dist/events/bus.js +64 -0
  21. package/dist/events/claude-adapter.js +416 -0
  22. package/dist/events/types.js +9 -0
  23. package/dist/handoff/bundle.js +203 -0
  24. package/dist/handoff/parent-handoff.js +48 -0
  25. package/dist/mcp/server.js +45 -1
  26. package/dist/routing/glm-routing.js +131 -0
  27. package/dist/runs/checkpoint.js +204 -0
  28. package/dist/runs/drain.js +165 -0
  29. package/dist/runs/heartbeat.js +45 -0
  30. package/dist/runs/registry.js +350 -0
  31. package/dist/runs/store.js +186 -0
  32. package/dist/runs/ulid.js +112 -0
  33. package/dist/runs/worker-run.js +672 -0
  34. package/dist/templates/agents-block.js +9 -0
  35. package/dist/templates/claude-block.js +9 -0
  36. package/dist/templates/glm-delegation-skill.js +76 -65
  37. package/dist/tui/progress.js +338 -0
  38. package/dist/tui/render.js +78 -0
  39. package/package.json +1 -1
@@ -0,0 +1,203 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { logger } from "../core/logging.js";
4
+ import { gitTopLevel, runGit } from "../core/git.js";
5
+ import { atomicWriteFile } from "../project/atomic-write.js";
6
+ import { CHECKPOINT_FILE_NAME, writeCheckpoint } from "../runs/checkpoint.js";
7
+ /** Where the bundle lands: `<runDir>/handoff/` (doc §17). */
8
+ export const HANDOFF_DIR_NAME = "handoff";
9
+ /** The one provider v2 knows (H2/D5), hardcoded into the H5 handoff block. */
10
+ const ZAI_PROVIDER = "zai.zcode";
11
+ /** H5: v3 §18's cross-provider handoff targets the orchestrator, by name. */
12
+ const ORCHESTRATOR_PROVIDER = "anthropic.claude-code";
13
+ /**
14
+ * Writes the handoff bundle (doc §17): `checkpoint.json`, `diff.patch`,
15
+ * `handoff.md`, `handoff.json` under `<runDir>/handoff/`.
16
+ *
17
+ * **Never fails the run.** Returns null only when the bundle directory itself
18
+ * cannot be created; every git call and every file write is individually
19
+ * guarded, so one degraded section (no diff, no branch, a lost file) becomes a
20
+ * debug log while the rest of the bundle still reaches disk. The caller prints
21
+ * the bundle path and carries on regardless — the work on disk is the thing
22
+ * being rescued here, and it is already safe.
23
+ *
24
+ * `diff.patch` is plain `git diff` — tracked changes only, never `git add`
25
+ * (this repo's no-automatic-git rule is exactly why untracked files get their
26
+ * own heading in `handoff.md` instead of being staged into the diff). Plain
27
+ * `git diff` rather than `git diff HEAD` deliberately: the router never stages
28
+ * anything, and on an unborn HEAD `git diff` still exits 0 where `HEAD` would
29
+ * fail and needlessly drop the diff section.
30
+ *
31
+ * C3: every task-derived string here (title, completed lines, validation
32
+ * commands, file paths) comes from the checkpoint, whose fields were already
33
+ * redacted and capped upstream. No prompt body, tool result or LLM response is
34
+ * ever read, and nothing from the environment is written.
35
+ */
36
+ export async function writeHandoffBundle(input) {
37
+ const dir = path.join(input.runDir, HANDOFF_DIR_NAME);
38
+ try {
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ }
41
+ catch (error) {
42
+ logger.debug(`handoff bundle: creating ${dir} failed: ${errorMessage(error)}`);
43
+ return null;
44
+ }
45
+ const handoffMd = path.join(dir, "handoff.md");
46
+ const handoffJson = path.join(dir, "handoff.json");
47
+ const checkpointJson = path.join(dir, CHECKPOINT_FILE_NAME);
48
+ const diffFile = path.join(dir, "diff.patch");
49
+ const git = input.runGit ?? runGit;
50
+ const workspace = await workspaceInfo(input.cwd, git);
51
+ const diff = workspace.isRepo ? await trackedDiff(input.cwd, git) : null;
52
+ const diffPatch = diff !== null && guardedWrite(diffFile, diff) ? diffFile : null;
53
+ // Reuses the never-throwing writer so the bundle's copy is byte-identical to
54
+ // the one the run directory itself may already hold.
55
+ writeCheckpoint(dir, input.checkpoint);
56
+ guardedWrite(handoffMd, renderMarkdown(input, workspace, diffPatch !== null));
57
+ guardedWrite(handoffJson, JSON.stringify({
58
+ // Doc §18's shape, verbatim in key order...
59
+ status: "handoff_required",
60
+ run_id: input.runId,
61
+ reason: input.reason,
62
+ completed: input.checkpoint.completed,
63
+ pending: input.checkpoint.pending,
64
+ handoff_path: handoffMd,
65
+ // ...then hedge H5: v3 §17's from/to and v4 §20's workspace block, so
66
+ // a v2 bundle stays readable by v3/v4 without a migration pass.
67
+ from: { provider: ZAI_PROVIDER, role: input.role },
68
+ to: { provider: ORCHESTRATOR_PROVIDER, role: "orchestrator" },
69
+ workspace: { repo: workspace.repo, worktree: workspace.worktree, branch: workspace.branch },
70
+ bundle: { checkpoint: checkpointJson, diff: diffPatch, markdown: handoffMd },
71
+ }, null, 2) + "\n");
72
+ return { dir, handoffMd, handoffJson, checkpointJson, diffPatch };
73
+ }
74
+ /**
75
+ * Everything C3 allows a human to read: the title (the checkpoint's first
76
+ * pending entry), the run id and reason, done/remaining/files/validation from
77
+ * the checkpoint, and the git facts. Untracked files get their OWN heading
78
+ * because they are invisible to `diff.patch` — a reader who assumes the patch
79
+ * is the whole story silently loses them.
80
+ */
81
+ function renderMarkdown(input, workspace, diffWritten) {
82
+ const checkpoint = input.checkpoint;
83
+ const title = checkpoint.pending.length > 0 ? checkpoint.pending[0] : "continue the task";
84
+ const workspaceLine = workspace.isRepo
85
+ ? `${workspace.repo ?? "?"} on ${workspace.branch ?? "(detached HEAD)"} — ${workspace.worktree ?? input.cwd}`
86
+ : "not a git repository";
87
+ const lines = [
88
+ `# Handoff: ${title}`,
89
+ "",
90
+ `- Run: ${input.runId}`,
91
+ `- Reason: ${input.reason}`,
92
+ `- Role: ${input.role} (model ${input.model})`,
93
+ `- Workspace: ${workspaceLine}`,
94
+ "",
95
+ "## Done",
96
+ ...bullets(checkpoint.completed, "(no completed turns recorded)"),
97
+ "",
98
+ "## Remaining",
99
+ ...bullets(checkpoint.pending, "(nothing recorded — see the run's events)"),
100
+ "",
101
+ "## Files changed",
102
+ ...bullets(checkpoint.filesChanged, "(none)"),
103
+ ];
104
+ if (workspace.isRepo) {
105
+ // The heading is the warning: these files are NOT inside diff.patch.
106
+ lines.push("", "## Untracked files (not in diff.patch)", ...bullets(workspace.untracked, "(none)"));
107
+ }
108
+ lines.push("", "## Validation still owed", ...bullets(checkpoint.validationPending, "(none)"), "", "## Diff");
109
+ if (!workspace.isRepo) {
110
+ lines.push("Not a git repository — no `diff.patch` was written.");
111
+ }
112
+ else if (!diffWritten) {
113
+ lines.push("Tracked changes could not be captured — `diff.patch` was not written. Run `git diff` yourself.");
114
+ }
115
+ else {
116
+ lines.push("Tracked changes are in `diff.patch`. Untracked files are the ones listed above, not in the patch.");
117
+ }
118
+ lines.push("", "## Next step", "", `Continue the task in the same worktree (\`${input.cwd}\`) — pick up from "Remaining" above instead of re-running the worker from scratch.`, "");
119
+ return lines.join("\n");
120
+ }
121
+ function bullets(items, empty) {
122
+ return items.length > 0 ? items.map((item) => `- ${item}`) : [`- ${empty}`];
123
+ }
124
+ /** One guard per git question, so a broken answer degrades alone. */
125
+ async function workspaceInfo(cwd, git) {
126
+ let topLevel;
127
+ try {
128
+ // gitTopLevel rejects (rather than resolving) when git itself is missing,
129
+ // so even repo detection is inside the guard.
130
+ topLevel = await gitTopLevel(cwd, git);
131
+ }
132
+ catch (error) {
133
+ logger.debug(`handoff bundle: repo detection in ${cwd} failed: ${errorMessage(error)}`);
134
+ topLevel = undefined;
135
+ }
136
+ if (topLevel === undefined) {
137
+ return { isRepo: false, repo: null, worktree: null, branch: null, untracked: [] };
138
+ }
139
+ const branch = await currentBranch(cwd, git);
140
+ const untracked = await untrackedFiles(cwd, git);
141
+ return { isRepo: true, repo: path.basename(topLevel), worktree: topLevel, branch, untracked };
142
+ }
143
+ async function currentBranch(cwd, git) {
144
+ try {
145
+ const result = await git(["branch", "--show-current"], cwd);
146
+ if (result.code !== 0) {
147
+ logger.debug(`handoff bundle: branch lookup in ${cwd} exited ${result.code}`);
148
+ return null;
149
+ }
150
+ // Empty output is a detached HEAD — truthful as null, not as "".
151
+ const branch = result.stdout.trim();
152
+ return branch.length > 0 ? branch : null;
153
+ }
154
+ catch (error) {
155
+ logger.debug(`handoff bundle: branch lookup in ${cwd} failed: ${errorMessage(error)}`);
156
+ return null;
157
+ }
158
+ }
159
+ async function untrackedFiles(cwd, git) {
160
+ try {
161
+ const result = await git(["status", "--porcelain", "-z"], cwd);
162
+ if (result.code !== 0) {
163
+ logger.debug(`handoff bundle: status in ${cwd} exited ${result.code}`);
164
+ return [];
165
+ }
166
+ // -z: NUL-terminated, unquoted — spaces in filenames survive intact.
167
+ return result.stdout
168
+ .split("\0")
169
+ .filter((entry) => entry.startsWith("?? "))
170
+ .map((entry) => entry.slice(3));
171
+ }
172
+ catch (error) {
173
+ logger.debug(`handoff bundle: status in ${cwd} failed: ${errorMessage(error)}`);
174
+ return [];
175
+ }
176
+ }
177
+ async function trackedDiff(cwd, git) {
178
+ try {
179
+ const result = await git(["diff"], cwd);
180
+ if (result.code !== 0) {
181
+ logger.debug(`handoff bundle: git diff in ${cwd} exited ${result.code}`);
182
+ return null;
183
+ }
184
+ return result.stdout;
185
+ }
186
+ catch (error) {
187
+ logger.debug(`handoff bundle: git diff in ${cwd} failed: ${errorMessage(error)}`);
188
+ return null;
189
+ }
190
+ }
191
+ function guardedWrite(file, content) {
192
+ try {
193
+ atomicWriteFile(file, content);
194
+ return true;
195
+ }
196
+ catch (error) {
197
+ logger.debug(`handoff bundle: writing ${file} failed: ${errorMessage(error)}`);
198
+ return false;
199
+ }
200
+ }
201
+ function errorMessage(error) {
202
+ return error instanceof Error ? error.message : String(error);
203
+ }
@@ -0,0 +1,48 @@
1
+ import { ExitCode } from "../core/errors.js";
2
+ import { logger } from "../core/logging.js";
3
+ /**
4
+ * Emit a handoff to the parent session and return its exit code (D2).
5
+ *
6
+ * **stdout carries the JSON and nothing else.** Contract C1 reserves stdout
7
+ * for the run's final answer, and a handed-off run has none — it did not
8
+ * finish. Printing a partial answer next to the JSON would give an
9
+ * orchestrator two things to parse and no way to tell which is authoritative,
10
+ * so the JSON *is* the output of a run that ends this way.
11
+ *
12
+ * 41 and 42 differ only in what already happened: 41 is a preflight refusal
13
+ * that spawned nothing, 42 a live run stopped at a safe boundary with its work
14
+ * preserved in a bundle. Neither is a crash, which is exactly what the
15
+ * orchestrator templates now say.
16
+ */
17
+ export function writeHandoffResult(streams, result, humanSummary, exitCode = ExitCode.HandoffRequired) {
18
+ streams.stdout.write(JSON.stringify(result) + "\n");
19
+ try {
20
+ streams.stderr.write(humanSummary.join("\n") + "\n");
21
+ }
22
+ catch (error) {
23
+ // The machine-readable half already landed; losing the prose must not
24
+ // change the exit code the parent reads.
25
+ logger.debug(`parent handoff: writing the human summary failed: ${errorMessage(error)}`);
26
+ }
27
+ return exitCode;
28
+ }
29
+ /** The `[Router]` block a human reads on stderr when a live run hands off (doc §14, §18). */
30
+ export function handoffSummaryLines(input) {
31
+ const lines = [
32
+ `[Router] handing the task back to the parent session (${input.reason}).`,
33
+ `[Router] run ${input.runId} stopped at a safe boundary; its work is on disk.`,
34
+ ];
35
+ if (input.completed.length > 0) {
36
+ lines.push("[Router] done so far:", ...input.completed.map((entry) => `[Router] ${entry}`));
37
+ }
38
+ if (input.pending.length > 0) {
39
+ lines.push("[Router] still to do:", ...input.pending.map((entry) => `[Router] ${entry}`));
40
+ }
41
+ lines.push(input.bundlePath === null
42
+ ? "[Router] no bundle was written (nothing had changed on disk yet)."
43
+ : `[Router] bundle: ${input.bundlePath}`, "[Router] continue in the SAME worktree; do not re-run the worker until quota resets.");
44
+ return lines;
45
+ }
46
+ function errorMessage(error) {
47
+ return error instanceof Error ? error.message : String(error);
48
+ }
@@ -1,4 +1,5 @@
1
1
  import os from "node:os";
2
+ import { Writable } from "node:stream";
2
3
  import { buildWorkerArgs } from "../bin/glm-worker.js";
3
4
  import { buildReviewArgs } from "../bin/glm-review.js";
4
5
  import { loadConfig } from "../core/config.js";
@@ -11,7 +12,9 @@ import { applyProfile } from "../core/profile.js";
11
12
  import { version } from "../core/version.js";
12
13
  import { createDelegateWorktree, delegateBranch, removeDelegateWorktree, rollbackDelegateBranch, validateDelegateName, } from "../core/worktree.js";
13
14
  import { resolveZaiApiKey } from "../core/zai-key.js";
14
- import { aggregateLocalUsage, describeWindow, fetchZaiQuota } from "../commands/usage.js";
15
+ import { runInstrumented, shouldObserve } from "../runs/worker-run.js";
16
+ import { aggregateLocalUsage } from "../commands/usage.js";
17
+ import { describeWindow, fetchZaiQuota } from "../core/zai-quota.js";
15
18
  const PROMPT_PROPERTY = { type: "string", description: "The task prompt for the GLM agent." };
16
19
  export const MCP_TOOLS = [
17
20
  {
@@ -80,6 +83,47 @@ async function runAgent(prompt, profile, kind, deps) {
80
83
  }
81
84
  const claudePath = locateClaude(config, env);
82
85
  const args = kind === "worker" ? buildWorkerArgs(prompt, config) : buildReviewArgs(prompt, config);
86
+ // An injected `spawn` pins the v1 capture path: that caller owns child
87
+ // execution and expects the captured stdout/stderr (C4). Production passes
88
+ // none and observes by default.
89
+ if (deps.spawn === undefined && shouldObserve(args, env)) {
90
+ // C2 (v2 spec Phase D): MCP's stdout is the JSON-RPC channel, so the run is
91
+ // instrumented with the renderer off and both streams captured — the final
92
+ // text comes back as the tool result string, and the run still lands in the
93
+ // registry/history. is-error stays derived from the exit code, exactly like
94
+ // the capture path below.
95
+ let stdoutText = "";
96
+ let stderrText = "";
97
+ const stderr = new Writable({
98
+ write: (chunk, _encoding, callback) => {
99
+ stderrText += String(chunk);
100
+ callback();
101
+ },
102
+ });
103
+ const result = await runInstrumented({
104
+ kind,
105
+ prompt,
106
+ args,
107
+ claudePath,
108
+ config,
109
+ secrets: [resolved.key],
110
+ cwd: deps.cwd ?? process.cwd(),
111
+ env: createGlmEnv(config, resolved.key, env),
112
+ home,
113
+ progress: "off",
114
+ stdout: {
115
+ write: (text) => {
116
+ stdoutText += text;
117
+ },
118
+ },
119
+ stderr,
120
+ });
121
+ const text = stdoutText.trim() || "(no output)";
122
+ if (result.code !== 0) {
123
+ return { text: `${text}\n[worker exited ${result.code}]${stderrText ? `\n${tail(stderrText.trim())}` : ""}`, isError: true };
124
+ }
125
+ return { text, isError: false };
126
+ }
83
127
  const spawn = deps.spawn ?? spawnAgentCapture;
84
128
  const captured = await spawn(claudePath, {
85
129
  args,
@@ -0,0 +1,131 @@
1
+ import { zoneFor } from "../budget/manager.js";
2
+ /**
3
+ * DEVIATION FROM THE SPEC, deliberate: the spec's signature says `estimate`
4
+ * (singular), but its own refusal rule needs both models' p90 — wouldRefuse
5
+ * is true only when the cost does not fit for main AND fast (doc §14: always
6
+ * try Flash before giving up). One estimate cannot express that, and deriving
7
+ * the fast estimate by scaling the main one would bake the estimator's
8
+ * baseline ratio (FAST_MODEL_RATIO, 0.4) into the router, so this takes both
9
+ * and the caller computes each with estimateCost.
10
+ *
11
+ * Pure: every input arrives as an argument — no files, no network, no clock,
12
+ * no logging, and the process is never exited from here. The rules, in order:
13
+ *
14
+ * 1. `quotaAware: false` or `confidence: "unknown"` fails OPEN — a monitoring
15
+ * outage must never block work: action "run", the requested model (or
16
+ * main), wouldRefuse false, zone as computed.
17
+ * 2. zone = zoneFor(snapshot, config.routing).
18
+ * 3. The BINDING window is whichever of fiveHour/weekly has the lower
19
+ * remainingRatio — the same window zoneFor's min() picks, so the budget
20
+ * arithmetic and the zone can never disagree. reserve = reserveRatio ×
21
+ * binding.limit; usableBudget = max(0, binding.remaining − reserve).
22
+ * 4. Zone preference (doc §12): HEALTHY → main; CONSERVE, HANDOFF_READY and
23
+ * CRITICAL → fast. CRITICAL is in the fast arm because with the shipped
24
+ * default it still runs, and a nearly-empty quota should run cheap.
25
+ * 5. `requestedModel` PINS the model, overriding the zone preference. It does
26
+ * NOT bypass an enforced refusal — only `force` does.
27
+ * 6. wouldRefuse when the zone is CRITICAL, or when NEITHER model's
28
+ * p90 × safetyFactor fits usableBudget.
29
+ * 7. wouldRefuse becomes `return_to_parent` only when `refuseOnCritical` is
30
+ * true and `force` is absent.
31
+ * 8. "downgrade" when the fast model was chosen without a pin (the route
32
+ * changed underneath the caller); otherwise "run".
33
+ *
34
+ * `refuseOnCritical` ships FALSE in 2.0.0 (decision D3), and this function is
35
+ * built to run with it off: the estimator's baseline table has never been
36
+ * measured on this stack, so a wrongly-high row would refuse runs the quota
37
+ * could have afforded, and the user would only find --force after being
38
+ * blocked. With the switch off, wouldRefuse is simply reported and the caller
39
+ * logs a BudgetWarning and runs anyway. Do not flip the default here; flip it
40
+ * in config once the routingAdvice evidence exists.
41
+ *
42
+ * The affordability fallback is one-way (main → fast), mirroring doc §14's
43
+ * "always try Flash before giving up": when the unpinned choice is main and
44
+ * only fast fits, the route downgrades to fast. There is no fast → main
45
+ * upgrade — when a zone below HEALTHY asks for the fast model, the zone (not
46
+ * the estimate) is what protects the remaining quota.
47
+ */
48
+ export function decideRoute(input) {
49
+ const { snapshot, estimates, config, requestedModel, force } = input;
50
+ const routing = config.routing;
51
+ const zone = zoneFor(snapshot, routing);
52
+ const usableBudget = usableBudgetOf(snapshot, routing.reserveRatio);
53
+ const costOf = (estimate) => estimate.p90 * routing.safetyFactor;
54
+ const fits = (estimate) => costOf(estimate) <= usableBudget;
55
+ // Rule 1 — fail-open. usableBudget/estimatedCost are still reported (an
56
+ // unknown snapshot honestly yields 0: we know no budget), but they gate
57
+ // nothing here.
58
+ if (!routing.quotaAware || snapshot.confidence === "unknown") {
59
+ const slot = requestedModel ?? "main";
60
+ return {
61
+ action: "run",
62
+ model: config.models[slot],
63
+ zone,
64
+ reason: routing.quotaAware ? "quota confidence unknown, failing open" : "quota-aware routing is disabled",
65
+ usableBudget,
66
+ estimatedCost: costOf(estimates[slot]),
67
+ wouldRefuse: false,
68
+ };
69
+ }
70
+ const preferred = zone === "HEALTHY" ? "main" : "fast";
71
+ let slot = requestedModel ?? preferred;
72
+ let fellBackToFit = false;
73
+ if (requestedModel === undefined && slot === "main" && !fits(estimates.main) && fits(estimates.fast)) {
74
+ slot = "fast";
75
+ fellBackToFit = true;
76
+ }
77
+ const wouldRefuse = zone === "CRITICAL" || (!fits(estimates.main) && !fits(estimates.fast));
78
+ // Rule 7 — D3: with the shipped refuseOnCritical: false this stays false and
79
+ // wouldRefuse is only reported. force bypasses the enforced form only.
80
+ const enforced = wouldRefuse && routing.refuseOnCritical && force !== true;
81
+ return {
82
+ action: enforced
83
+ ? "return_to_parent"
84
+ : slot === "fast" && requestedModel === undefined
85
+ ? "downgrade"
86
+ : "run",
87
+ model: config.models[slot],
88
+ zone,
89
+ reason: enforced
90
+ ? zone === "CRITICAL"
91
+ ? "zone CRITICAL, refusal enforced"
92
+ : "neither model's estimated cost fits the usable budget"
93
+ : requestedModel !== undefined
94
+ ? `--model ${requestedModel} pinned`
95
+ : fellBackToFit
96
+ ? "estimated main cost exceeds usable budget, trying the fast model"
97
+ : `zone ${zone} prefers the ${preferred} model`,
98
+ usableBudget,
99
+ estimatedCost: costOf(estimates[slot]),
100
+ wouldRefuse,
101
+ };
102
+ }
103
+ /**
104
+ * The window the budget arithmetic must respect: the one with the lower
105
+ * remainingRatio, i.e. the same window zoneFor's min() already picked — so
106
+ * the credits and the zone are computed against one window, never two that
107
+ * could disagree. On an exact ratio tie min() is indifferent, so the smaller
108
+ * limit binds: it leaves less absolute headroom, which is the conservative
109
+ * reading. (confidence "unknown" never reaches here with real numbers —
110
+ * decideRoute fails open first — and its zero windows tie harmlessly.)
111
+ */
112
+ /**
113
+ * Credits this run may actually spend: the binding window's remaining, less
114
+ * the untouchable reserve, floored at 0.
115
+ *
116
+ * Exported because the Phase F drain controller re-asks the same question
117
+ * every poll, and two implementations of one piece of arithmetic would drift
118
+ * — preflight and mid-run draining have to agree on what "affordable" means
119
+ * or the router contradicts itself halfway through a run.
120
+ */
121
+ export function usableBudgetOf(snapshot, reserveRatio) {
122
+ const binding = bindingWindow(snapshot);
123
+ return Math.max(0, binding.remaining - reserveRatio * binding.limit);
124
+ }
125
+ function bindingWindow(snapshot) {
126
+ const { fiveHour, weekly } = snapshot;
127
+ if (fiveHour.remainingRatio !== weekly.remainingRatio) {
128
+ return fiveHour.remainingRatio < weekly.remainingRatio ? fiveHour : weekly;
129
+ }
130
+ return fiveHour.limit <= weekly.limit ? fiveHour : weekly;
131
+ }
@@ -0,0 +1,204 @@
1
+ import path from "node:path";
2
+ import { logger } from "../core/logging.js";
3
+ import { atomicWriteFile } from "../project/atomic-write.js";
4
+ /** Doc §16's shape, rebuilt from events only (doc §17). */
5
+ export const CHECKPOINT_FILE_NAME = "checkpoint.json";
6
+ /** Tools whose appearance in a turn marks that turn as implementation work. */
7
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
8
+ /** C3: the widest any human-readable checkpoint line may ever be. */
9
+ const LINE_MAX_CHARS = 120;
10
+ /**
11
+ * Rebuilds a checkpoint from an event stream — the pure half of Phase F. Never
12
+ * throws and never reads a prompt: every string it returns is either a
13
+ * redacted tool summary (the adapter already enforced C3) or the redacted,
14
+ * 120-char `taskTitle` the `RunStarted` event already persisted.
15
+ *
16
+ * `pending` is the C3-safe reading of a spec that contradicted itself: Phase F
17
+ * first says it is "derived from the prompt's checklist lines if present", then
18
+ * that the checkpoint is "rebuilt from events only". Both cannot hold, and the
19
+ * first would put prompt text into `checkpoint.json` under `<configDir>/runs/`,
20
+ * which the C3 sweep walks. Events win:
21
+ *
22
+ * - with a `RunStarted`: the run's `taskTitle`, plus one entry per validation
23
+ * still owed (`validationPending`);
24
+ * - without one: the single entry `"continue the task"`.
25
+ *
26
+ * Do not "restore" prompt parsing here — if a richer pending list is ever
27
+ * wanted, the event model is where it must arrive (e.g. a task-spec event), not
28
+ * the prompt body.
29
+ */
30
+ export function buildCheckpoint(events) {
31
+ let taskTitle;
32
+ let lastTurnStarted = 0;
33
+ let lastToolTurn = 0;
34
+ let terminalSeen = false;
35
+ const turns = new Map();
36
+ const filesChanged = [];
37
+ const seenFiles = new Set();
38
+ for (const event of events) {
39
+ switch (event.type) {
40
+ case "RunStarted":
41
+ // First wins: a multi-segment run (error_max_turns + continue) replays
42
+ // its opener, and the original title is the one the registry recorded.
43
+ if (taskTitle === undefined) {
44
+ taskTitle = event.taskTitle;
45
+ }
46
+ break;
47
+ case "TurnStarted":
48
+ lastTurnStarted = Math.max(lastTurnStarted, event.turn);
49
+ break;
50
+ case "ToolStarted": {
51
+ const state = turnState(turns, event.turn);
52
+ state.toolLines.push(`${event.tool} ${event.summary}`);
53
+ if (EDIT_TOOLS.has(event.tool)) {
54
+ state.hasEditTool = true;
55
+ }
56
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
57
+ break;
58
+ }
59
+ case "ToolCompleted": {
60
+ const state = turnState(turns, event.turn);
61
+ // The completion alone is enough evidence of an edit: a stream whose
62
+ // `ToolStarted` line was lost to a crash still classifies truthfully.
63
+ if (EDIT_TOOLS.has(event.tool)) {
64
+ state.hasEditTool = true;
65
+ }
66
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
67
+ break;
68
+ }
69
+ case "FileChanged":
70
+ turnState(turns, event.turn).hasEditTool = true;
71
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
72
+ if (!seenFiles.has(event.path)) {
73
+ seenFiles.add(event.path);
74
+ filesChanged.push(event.path);
75
+ }
76
+ break;
77
+ case "ValidationStarted":
78
+ turnState(turns, event.turn).validationStarts.push(event.command);
79
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
80
+ break;
81
+ case "ValidationCompleted": {
82
+ const state = turnState(turns, event.turn);
83
+ (event.ok ? state.validationOk : state.validationFailed).push(event.command);
84
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
85
+ break;
86
+ }
87
+ case "ToolDenied":
88
+ // A denied tool ran nothing, so it proves neither edits nor validation;
89
+ // its ToolStarted summary is already in toolLines (A0: reportable).
90
+ lastToolTurn = Math.max(lastToolTurn, event.turn);
91
+ break;
92
+ case "RunCompleted":
93
+ case "RunFailed":
94
+ case "RunCancelled":
95
+ terminalSeen = true;
96
+ break;
97
+ default:
98
+ break;
99
+ }
100
+ }
101
+ const ordered = [...turns.entries()].sort((a, b) => a[0] - b[0]);
102
+ // Phase comes from the LAST turn with tool activity — a TurnStarted alone is
103
+ // a counter, not activity, so a stream truncated right after one keeps the
104
+ // phase of the turn that was actually doing something.
105
+ const lastActive = lastToolTurn > 0 ? turns.get(lastToolTurn) : undefined;
106
+ const phase = lastActive !== undefined && lastActive.validationStarts.length > 0
107
+ ? "validation"
108
+ : lastActive !== undefined && lastActive.hasEditTool
109
+ ? "implementation"
110
+ : "exploration";
111
+ const completed = [];
112
+ for (const [turn, state] of ordered) {
113
+ // Finished = a later turn started, or the run reached a terminal event.
114
+ // The final turn of a stream with no terminal event is the one in progress
115
+ // when the handoff happened, so it stays out of `completed`.
116
+ if (state.toolLines.length > 0 && (turn < lastTurnStarted || terminalSeen)) {
117
+ completed.push(`turn ${turn}: ${state.toolLines.join(", ")}`.slice(0, LINE_MAX_CHARS));
118
+ }
119
+ }
120
+ const validationPending = owedValidations(ordered);
121
+ const pending = taskTitle === undefined
122
+ ? // No RunStarted at all: the stream lost its opener (or is empty), so
123
+ // there is no title to carry — one honest entry, nothing fabricated.
124
+ ["continue the task"]
125
+ : [taskTitle.trim().length > 0 ? taskTitle : "continue the task", ...validationPending];
126
+ return {
127
+ // Mirrors summarize(): the first event's runId is the run the stream
128
+ // belongs to, and "" is the only answer an empty stream has.
129
+ runId: events.length > 0 ? events[0].runId : "",
130
+ phase,
131
+ completed,
132
+ pending,
133
+ filesChanged,
134
+ validationPending,
135
+ };
136
+ }
137
+ /**
138
+ * Writes `checkpoint.json` into a run directory and returns its path, or null
139
+ * on any failure — logged at debug, never thrown. A checkpoint is an aid: the
140
+ * run it describes has already happened, and losing the aid must not cost the
141
+ * run its exit path.
142
+ */
143
+ export function writeCheckpoint(runDirPath, checkpoint) {
144
+ const file = path.join(runDirPath, CHECKPOINT_FILE_NAME);
145
+ try {
146
+ atomicWriteFile(file, JSON.stringify(checkpoint, null, 2) + "\n");
147
+ return file;
148
+ }
149
+ catch (error) {
150
+ logger.debug(`writeCheckpoint: writing ${file} failed: ${errorMessage(error)}`);
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Validations still owed: every started command with no ok completion in the
156
+ * SAME turn (the adapter ties a completion to the turn that started it), plus
157
+ * every completion that reported `ok: false`. A denied validation falls under
158
+ * the first clause — the denial never produces a completion, and A0 settled
159
+ * that a denied validation is a real, reportable outcome rather than an error,
160
+ * so it must resurface here instead of vanishing. Deduplicated by command,
161
+ * first-owed order: the reader wants which commands to run, not how many times
162
+ * the run failed to run them.
163
+ */
164
+ function owedValidations(orderedTurns) {
165
+ const owed = [];
166
+ const seen = new Set();
167
+ const add = (command) => {
168
+ if (!seen.has(command)) {
169
+ seen.add(command);
170
+ owed.push(command);
171
+ }
172
+ };
173
+ for (const [, state] of orderedTurns) {
174
+ for (const command of new Set(state.validationStarts)) {
175
+ if (occurrences(state.validationOk, command) < occurrences(state.validationStarts, command)) {
176
+ add(command);
177
+ }
178
+ }
179
+ for (const command of state.validationFailed) {
180
+ add(command);
181
+ }
182
+ }
183
+ return owed;
184
+ }
185
+ function turnState(turns, turn) {
186
+ let state = turns.get(turn);
187
+ if (state === undefined) {
188
+ state = { toolLines: [], validationStarts: [], validationOk: [], validationFailed: [], hasEditTool: false };
189
+ turns.set(turn, state);
190
+ }
191
+ return state;
192
+ }
193
+ function occurrences(commands, command) {
194
+ let count = 0;
195
+ for (const entry of commands) {
196
+ if (entry === command) {
197
+ count += 1;
198
+ }
199
+ }
200
+ return count;
201
+ }
202
+ function errorMessage(error) {
203
+ return error instanceof Error ? error.message : String(error);
204
+ }