glm-coding-router 1.1.2 → 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.
package/README.md CHANGED
@@ -41,6 +41,10 @@ Claude / Codex → shell → glm-worker → claude.exe harness → Z.ai endpoint
41
41
  GLM-5.3 / GLM-5.3-Flash
42
42
  ```
43
43
 
44
+ For headless worker/review runs, the router also consumes Claude Code's stream-json output,
45
+ records a provider-neutral event history, and renders progress on stderr. Interactive
46
+ `glm-chat` / `glm-fast` sessions keep the direct pass-through path shown above.
47
+
44
48
  ## Requirements
45
49
 
46
50
  - Windows 10/11 or Linux (both verified); macOS is experimental — the suite has not been
@@ -122,10 +126,17 @@ go test ./internal/auth/...
122
126
  "@ | glm-worker
123
127
  ```
124
128
 
125
- Input priority: **stdinarguments → error**. The worker runs with
129
+ Input priority: **argumentsstdin → error**. Arguments win, and stdin is not even
130
+ read when they carry a prompt — waiting for EOF on a pipe that never closes (an agent
131
+ harness, CI, `nohup`) would hang the run before it started. The worker runs with
126
132
  `--max-turns 20 --permission-mode acceptEdits --tools Read,Glob,Grep,Edit,Write,Bash`.
127
133
  It never uses `--dangerously-skip-permissions`.
128
134
 
135
+ Routing flags (v2): `--model main|fast` pins the config slot for this run (it does not
136
+ bypass an enforced refusal), `--force` overrides one, `--refresh-quota` re-reads the
137
+ Z.ai quota instead of the 60 s cache — see [Quota-aware routing](#quota-aware-routing-v2).
138
+ Like `--profile`, they belong to the wrapper and are consumed before the prompt is read.
139
+
129
140
  ## glm-review
130
141
 
131
142
  Read-only worker for repository exploration, call-graph discovery, duplicate detection,
@@ -190,7 +201,7 @@ your work; the footer prints the path and the merge command:
190
201
  [glm-router] next: inspect it, then merge glm/delegate/backend (or discard with git worktree remove)
191
202
  ```
192
203
 
193
- - Prompt priority is stdinarguments, same as `glm-worker`.
204
+ - Prompt priority is argumentsstdin, same as `glm-worker`.
194
205
  - Profiles: `--profile test` explicitly, or — when omitted — a profile literally
195
206
  named after the delegate (`delegate test` → the `test` profile) if one exists.
196
207
  - `--remove` deletes the worktree **after a successful run only**; plain
@@ -261,6 +272,93 @@ glm-router usage
261
272
 
262
273
  `--json` emits the same data machine-readably. No key configured → `ERROR [10]`.
263
274
 
275
+ ## Run observability (v2)
276
+
277
+ Every `glm-worker` / `glm-review` run — and every MCP `glm_worker` / `glm_review` call —
278
+ is instrumented: the child runs with `--output-format stream-json`, events are recorded
279
+ under `<configDir>/runs/`, and progress renders live on **stderr**. Stdout stays exactly
280
+ the final assistant text, so pipes, orchestrators, and `benchmark` keep working unchanged.
281
+
282
+ - `runs/history/YYYY-MM-DD/<runId>/` holds `events.jsonl` (one JSON event per line) and
283
+ `summary.json`; `runs/active/` registers live runs with a heartbeat.
284
+ - Progress modes: `rich` (box + turn tree, TTY only), `nested` (one `[GLM] …` line per
285
+ significant event — the default when stderr is piped), `off`. `--no-progress`,
286
+ `--quiet`, or `CI=true` force `off`; `GLM_ROUTER_PROGRESS=off|rich|nested` and
287
+ `GLM_ROUTER_NESTED=1` override config; `ui.mode` is the standing default.
288
+ - `GLM_ROUTER_OBSERVE=off` restores the exact v1 path (also automatic when the caller
289
+ passes its own `--output-format`, as `benchmark` does).
290
+
291
+ ```powershell
292
+ glm-router runs # id, state, model, started, duration, turns, files
293
+ glm-router runs --active --limit 5
294
+ glm-router runs show <runId> # metadata, summary, per-turn tool tree
295
+ glm-router runs logs <runId> # events.jsonl, one line per event (--json = raw)
296
+ glm-router runs clean --dry-run --orphans # preview retention prune + orphan reap
297
+ glm-router watch # attach to the newest active run, follow live
298
+ glm-router dashboard # quota + active runs + recent runs
299
+ ```
300
+
301
+ - `runs show` accepts a unique id suffix; the whole family supports `--json`.
302
+ - `runs clean --older-than 30d` prunes by age, `--orphans` reaps active runs whose
303
+ process is gone; history is also pruned at run start (`history.retentionDays: 30`,
304
+ `history.maxRuns: 1000` by default).
305
+ - `watch [run-id] [--from-start]` renders through the same renderer as a live run; no active
306
+ run → a message, exit 0.
307
+ - `dashboard` repaints every `--interval` seconds (default 2) on a TTY; piped, it
308
+ prints one snapshot and exits. Ctrl+C quits the live view.
309
+
310
+ **Checkpoints and handoff bundles.** A run that dies with work on disk — child failure,
311
+ crash, kill — always leaves a bundle in `<runDir>/handoff/`: `checkpoint.json` (phase,
312
+ completed turns, pending work, files changed, validations owed), `diff.patch` (the real
313
+ `git diff`; the router never runs `git add`, so untracked files are listed separately),
314
+ `handoff.md`, and `handoff.json`. The bundle path is printed to stderr. Outside a git
315
+ repo the bundle is still written, minus the patch.
316
+
317
+ ## Quota-aware routing (v2)
318
+
319
+ Before spawning, the router reads the Z.ai quota (cached 60 s), classifies the task, and
320
+ estimates its cost (p90 from `cost-samples.jsonl` history, else a built-in baseline).
321
+ The binding window (5-hour vs weekly, whichever is lower) picks a zone: HEALTHY runs the
322
+ main model; CONSERVE, HANDOFF_READY, and CRITICAL prefer the fast one. If main does not
323
+ fit the usable budget but fast does, the run is downgraded — never the reverse. Endpoint
324
+ unreachable, no key, or an empty payload → `confidence: "unknown"` → run normally and
325
+ warn once on stderr: a monitoring outage never blocks work.
326
+
327
+ Defaults in 2.0.0: `quotaAware: true`, but `refuseOnCritical: false` and
328
+ `handoffOnLowQuota: false` — the shipped router observes, downgrades, and warns; it
329
+ never refuses a run and never kills a live child. Every `summary.json` records
330
+ `routingAdvice` (`zone`, `wouldRefuse`, `estimatedCost`, `actualCredits`), the evidence
331
+ for revisiting those switches later.
332
+
333
+ ```json
334
+ {
335
+ "routing": {
336
+ "quotaAware": true, "refuseOnCritical": false, "handoffOnLowQuota": false,
337
+ "reserveRatio": 0.10, "safetyFactor": 1.3,
338
+ "preferFlashBelow": 0.30, "handoffReadyBelow": 0.15, "criticalBelow": 0.08,
339
+ "pollIntervalSec": 60, "quotaCacheTtlSec": 60
340
+ },
341
+ "history": { "retentionDays": 30, "maxRuns": 1000 },
342
+ "ui": { "mode": "auto", "color": true }
343
+ }
344
+ ```
345
+
346
+ Ratio fields must satisfy `0 < x < 1` and stay ordered
347
+ (`criticalBelow < handoffReadyBelow < preferFlashBelow`), else `ERROR [11]`.
348
+
349
+ **Exit 41 / 42 — unfinished, not crashed.** Both mean "work preserved", and both print a
350
+ `HandoffResult` JSON on stdout:
351
+
352
+ - **41 `QUOTA_INSUFFICIENT`** — preflight refused to spawn anything (reachable only
353
+ with `refuseOnCritical: true`). Nothing ran, and no run-history or repository files
354
+ were written; `--model fast` may fit the budget, `--force` overrides the refusal.
355
+ - **42 `HANDOFF_REQUIRED`** — a live run was stopped at a safe tool boundary and handed
356
+ back (reachable only with `handoffOnLowQuota: true`); the JSON carries `handoff_path`.
357
+
358
+ An orchestrator reads 41/42 as "continue in the same worktree", never as "the worker
359
+ broke". A child that fails on its own still exits 40 — the handoff bundle is written
360
+ anyway.
361
+
264
362
  ## Agent skills (Claude Code + Codex)
265
363
 
266
364
  `glm-router skill install` writes the `glm-delegation` SKILL.md into **both**
@@ -292,7 +390,9 @@ glm-router mcp remove # claude mcp remove -s user glm-coding-router
292
390
 
293
391
  Tool-level failures return `isError` results (missing key, no claude, outside
294
392
  a git repo, unreachable endpoint); the server never prints anything to stdout
295
- except JSON-RPC frames.
393
+ except JSON-RPC frames. MCP-driven runs are recorded like any other (registry on,
394
+ progress renderer off), so they appear in `glm-router runs` and `dashboard` while
395
+ the protocol channel stays clean.
296
396
 
297
397
  ## CLI reference
298
398
 
@@ -307,6 +407,9 @@ glm-router config set models.main glm-5.3
307
407
  glm-router delegate <name> run a GLM worker in an isolated git worktree
308
408
  glm-router benchmark measure the Claude+GLM stack on built-in tasks
309
409
  glm-router usage Z.ai quota snapshot + local benchmark totals
410
+ glm-router runs inspect recorded runs (show / logs / clean subcommands)
411
+ glm-router watch [run-id] attach to an active run and follow its progress
412
+ glm-router dashboard quota + active runs + recent runs
310
413
  glm-router mcp optional MCP server registration (glm-mcp)
311
414
  glm-router project init CLAUDE.md / AGENTS.md managed blocks (--dry-run supported)
312
415
  glm-router project remove
@@ -382,6 +485,10 @@ The key is never cached to disk.
382
485
  | The worker creates files but never runs the tests | Its Bash allowlist is empty. `glm-router config show` → `worker.allowedBash`; the default list covers common test commands |
383
486
  | `glm-*` not on PATH after install | Reopen the terminal; check `npm config get prefix` is on PATH |
384
487
  | `ERROR [MANAGED_BLOCK_CORRUPT]` | Fix the marker pair in the named file manually, then re-run |
488
+ | Exit 41 `QUOTA_INSUFFICIENT` | Preflight refused the run (only with `routing.refuseOnCritical: true`). Wait for the window to reset, use `--model fast`, or `--force` |
489
+ | Exit 42 `HANDOFF_REQUIRED` | Not a crash — the run handed off with a bundle. Read `handoff_path` in the stdout JSON and continue in the same worktree |
490
+ | A run lists as FAILED with no summary | It died mid-run; `runs show <id>` rebuilds the summary from `events.jsonl`, `runs clean --orphans` reaps stale active entries |
491
+ | `runs/` history grows large | `glm-router runs clean --older-than 30d`, or tune `history.retentionDays` / `history.maxRuns` |
385
492
 
386
493
  Run `glm-router doctor` (add `--network` to probe the Z.ai endpoint) for a full diagnosis.
387
494
 
@@ -418,8 +525,9 @@ npm test
418
525
  npm publish
419
526
  ```
420
527
 
421
- `prepublishOnly` runs build + tests. The package ships only `dist/`; the four binaries
422
- (`glm-router`, `glm-chat`, `glm-fast`, `glm-worker`, `glm-review`) are declared in `bin`.
528
+ `prepublishOnly` runs build + tests. The package ships only `dist/`; the six binaries
529
+ (`glm-router`, `glm-chat`, `glm-fast`, `glm-worker`, `glm-review`, `glm-mcp`) are declared
530
+ in `bin`.
423
531
 
424
532
  ## License
425
533
 
@@ -7,9 +7,11 @@ import { Errors, formatGlmError, GlmRouterError } from "../core/errors.js";
7
7
  import { isMainModule } from "../core/main-guard.js";
8
8
  import { logger, redact } from "../core/logging.js";
9
9
  import { applyProfile, extractProfileFlag } from "../core/profile.js";
10
+ import { extractRoutingFlags } from "../core/routing-flags.js";
10
11
  import { readStdin, resolvePrompt } from "../core/prompt.js";
11
12
  import { spawnAgent } from "../core/process.js";
12
13
  import { resolveZaiApiKey } from "../core/zai-key.js";
14
+ import { runInstrumented, shouldObserve } from "../runs/worker-run.js";
13
15
  /** Read-only review surface (spec §17) — no Edit, Write, or Bash. */
14
16
  export const REVIEW_TOOLS = "Read,Glob,Grep";
15
17
  /**
@@ -36,7 +38,10 @@ export function buildReviewArgs(prompt, config) {
36
38
  * discovery, duplicate detection, dependency inspection, and review.
37
39
  */
38
40
  export async function runReview(argv) {
39
- const { rest, profile } = extractProfileFlag(argv);
41
+ const { rest: withoutProfile, profile } = extractProfileFlag(argv);
42
+ // Phase E flags come off before resolvePrompt: whatever is still in
43
+ // `rest` at that point becomes the prompt.
44
+ const { rest, model, force, refreshQuota } = extractRoutingFlags(withoutProfile);
40
45
  const prompt = await resolvePrompt(rest, readStdin, "glm-review");
41
46
  const config = applyProfile(loadConfig(), profile);
42
47
  const resolved = resolveZaiApiKey();
@@ -47,12 +52,32 @@ export async function runReview(argv) {
47
52
  const args = buildReviewArgs(prompt, config);
48
53
  const env = createGlmEnv(config, resolved.key);
49
54
  logger.debug(redact(`spawning ${claudePath} ${args.join(" ")}`, [resolved.key]));
50
- return spawnAgent(claudePath, {
55
+ // v2 spec Phase D (C4): observe unless the caller opted out or already asked
56
+ // for a specific --output-format. The legacy path stays byte-identical.
57
+ if (!shouldObserve(args, process.env)) {
58
+ return spawnAgent(claudePath, {
59
+ args,
60
+ cwd: process.cwd(),
61
+ env,
62
+ interactive: false,
63
+ });
64
+ }
65
+ const observed = await runInstrumented({
66
+ kind: "review",
67
+ prompt,
51
68
  args,
69
+ claudePath,
70
+ config,
71
+ secrets: [resolved.key],
52
72
  cwd: process.cwd(),
53
73
  env,
54
- interactive: false,
74
+ // Phase E; instrumented runs only, same as glm-worker.
75
+ zaiKey: resolved.key,
76
+ requestedModel: model,
77
+ force,
78
+ refreshQuota,
55
79
  });
80
+ return observed.code;
56
81
  }
57
82
  if (isMainModule(import.meta.url)) {
58
83
  runReview(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
@@ -7,9 +7,11 @@ import { Errors, formatGlmError, GlmRouterError } from "../core/errors.js";
7
7
  import { isMainModule } from "../core/main-guard.js";
8
8
  import { logger, redact } from "../core/logging.js";
9
9
  import { applyProfile, extractProfileFlag } from "../core/profile.js";
10
+ import { extractRoutingFlags } from "../core/routing-flags.js";
10
11
  import { resolvePrompt } from "../core/prompt.js";
11
12
  import { spawnAgent } from "../core/process.js";
12
13
  import { resolveZaiApiKey } from "../core/zai-key.js";
14
+ import { runInstrumented, shouldObserve } from "../runs/worker-run.js";
13
15
  /** Worker tool surface (spec §16). */
14
16
  export const WORKER_TOOLS = "Read,Glob,Grep,Edit,Write,Bash";
15
17
  /** Same surface minus Bash, used when no Bash command is allowed. */
@@ -61,12 +63,15 @@ export function extractNoBashFlag(argv) {
61
63
  }
62
64
  /**
63
65
  * glm-worker (spec §15, §16): headless implementation worker.
64
- * Prompt priority: stdinarguments → error. Never uses
66
+ * Prompt priority: argumentsstdin → error. Never uses
65
67
  * --dangerously-skip-permissions.
66
68
  */
67
69
  export async function runWorker(argv) {
68
70
  const { rest: withoutProfile, profile } = extractProfileFlag(argv);
69
- const { rest, noBash } = extractNoBashFlag(withoutProfile);
71
+ const { rest: withoutBashFlag, noBash } = extractNoBashFlag(withoutProfile);
72
+ // Phase E flags come off last, and before resolvePrompt: everything still in
73
+ // `rest` at that point becomes the prompt.
74
+ const { rest, model, force, refreshQuota } = extractRoutingFlags(withoutBashFlag);
70
75
  const prompt = await resolvePrompt(rest);
71
76
  const loaded = applyProfile(loadConfig(), profile);
72
77
  const config = noBash
@@ -80,12 +85,33 @@ export async function runWorker(argv) {
80
85
  const args = buildWorkerArgs(prompt, config);
81
86
  const env = createGlmEnv(config, resolved.key);
82
87
  logger.debug(redact(`spawning ${claudePath} ${args.join(" ")}`, [resolved.key]));
83
- return spawnAgent(claudePath, {
88
+ // v2 spec Phase D (C4): observe unless the caller opted out or already asked
89
+ // for a specific --output-format. The legacy path stays byte-identical.
90
+ if (!shouldObserve(args, process.env)) {
91
+ return spawnAgent(claudePath, {
92
+ args,
93
+ cwd: process.cwd(),
94
+ env,
95
+ interactive: false,
96
+ });
97
+ }
98
+ const observed = await runInstrumented({
99
+ kind: "worker",
100
+ prompt,
84
101
  args,
102
+ claudePath,
103
+ config,
104
+ secrets: [resolved.key],
85
105
  cwd: process.cwd(),
86
106
  env,
87
- interactive: false,
107
+ // Phase E. The legacy path above never reaches here, so these flags apply
108
+ // to instrumented runs only — routing needs the event stream it observes.
109
+ zaiKey: resolved.key,
110
+ requestedModel: model,
111
+ force,
112
+ refreshQuota,
88
113
  });
114
+ return observed.code;
89
115
  }
90
116
  if (isMainModule(import.meta.url)) {
91
117
  runWorker(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
@@ -0,0 +1,218 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { logger } from "../core/logging.js";
4
+ import { costSamplesPath } from "../core/paths.js";
5
+ /**
6
+ * Priority-ordered keyword table: the first row with a case-insensitive WHOLE
7
+ * WORD match wins, which is what makes "fix the failing test" a bugfix rather
8
+ * than a tests task — repair beats coverage when both match.
9
+ *
10
+ * Whole words, not substrings: a substring table reads "docker" as docs and
11
+ * "fixture" as bugfix, and this classifier is not cosmetic — it picks the cost
12
+ * row that decides `wouldRefuse`, which is the evidence D3 says 2.1's
13
+ * refuse-by-default decision will be argued from. Noise here becomes a wrong
14
+ * answer to "how often would the refusal have been wrong?". Word forms are
15
+ * therefore listed explicitly; an unlisted form falls through to a later row
16
+ * or to "other", and guessing low is the conservative failure.
17
+ */
18
+ const KEYWORDS = [
19
+ [
20
+ "bugfix",
21
+ ["fix", "fixes", "fixed", "fixing", "bug", "bugs", "broken", "regression", "regressions",
22
+ "crash", "crashes", "crashing", "error", "errors", "defect", "defects", "repair"],
23
+ ],
24
+ ["tests", ["test", "tests", "testing", "spec", "specs", "coverage", "vitest", "jest", "pytest"]],
25
+ [
26
+ "docs",
27
+ ["doc", "docs", "document", "documents", "documentation", "docstring", "docstrings",
28
+ "readme", "comment", "comments", "changelog"],
29
+ ],
30
+ [
31
+ "refactor",
32
+ ["refactor", "refactors", "refactoring", "rename", "renames", "renaming", "extract",
33
+ "cleanup", "clean up", "restructure", "simplify", "migrate", "migration"],
34
+ ],
35
+ [
36
+ "explore",
37
+ ["explore", "investigate", "find", "search", "understand", "audit", "review", "analyze",
38
+ "analyse", "analysis"],
39
+ ],
40
+ [
41
+ "crud",
42
+ ["add", "create", "implement", "implements", "implementing", "endpoint", "endpoints",
43
+ "model", "models", "schema", "schemas", "crud", "build", "write"],
44
+ ],
45
+ ];
46
+ /** Whole-word matcher for one row; `clean up` shows why a plain split() is not enough. */
47
+ const MATCHERS = KEYWORDS.map(([kind, words]) => [
48
+ kind,
49
+ new RegExp(`\\b(?:${words.map(escapeRegExp).join("|")})\\b`, "i"),
50
+ ]);
51
+ function escapeRegExp(text) {
52
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ }
54
+ /**
55
+ * Deterministic keyword classifier — no model call, so preflight stays free,
56
+ * instant and reproducible. The prompt itself is never persisted here (C3);
57
+ * only the resulting kind reaches `cost-samples.jsonl`.
58
+ */
59
+ export function classifyTask(prompt) {
60
+ for (const [kind, matcher] of MATCHERS) {
61
+ if (matcher.test(prompt)) {
62
+ return kind;
63
+ }
64
+ }
65
+ return "other";
66
+ }
67
+ /**
68
+ * Appends one JSON line to cost-samples.jsonl. Never throws: cost history is
69
+ * an optimization the next run can live without, and the run that earned the
70
+ * sample has already done its work by the time this is called.
71
+ */
72
+ export function recordSample(home, sample) {
73
+ const file = costSamplesPath(home);
74
+ try {
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ fs.appendFileSync(file, JSON.stringify(sample) + "\n", "utf8");
77
+ }
78
+ catch (error) {
79
+ logger.debug(`estimator: could not record cost sample (${errorMessage(error)})`);
80
+ }
81
+ }
82
+ /**
83
+ * Reads the cost history. A missing file is "no history yet" and a malformed
84
+ * line is skipped, not fatal — the same tolerate-the-wreckage contract as the
85
+ * run store, because a truncated final line is normal crash wreckage.
86
+ */
87
+ export function readSamples(home) {
88
+ let text;
89
+ try {
90
+ text = fs.readFileSync(costSamplesPath(home), "utf8");
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ const samples = [];
96
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
97
+ if (line.trim() === "") {
98
+ continue;
99
+ }
100
+ try {
101
+ const parsed = JSON.parse(line);
102
+ if (isSampleLike(parsed)) {
103
+ samples.push(parsed);
104
+ }
105
+ else {
106
+ logger.debug(`estimator: line ${index + 1} in ${costSamplesPath(home)} is not a sample`);
107
+ }
108
+ }
109
+ catch {
110
+ logger.debug(`estimator: skipping malformed line ${index + 1} in ${costSamplesPath(home)}`);
111
+ }
112
+ }
113
+ return samples;
114
+ }
115
+ /**
116
+ * True only when the quota delta between two snapshots can be attributed to
117
+ * exactly one run. All four rules must hold:
118
+ *
119
+ * - both snapshots have confidence "exact" or "cached" — an "unknown" side
120
+ * makes the delta fiction;
121
+ * - fiveHour.resetAt is unchanged — a window reset mid-run makes the delta
122
+ * meaningless (used drops to 0 and the difference goes negative);
123
+ * - the credit delta (end.used - start.used) is >= 0 — a negative delta
124
+ * means a reset or a server correction, not a cost;
125
+ * - activeRunCount is exactly 1 — concurrent runs make credit attribution
126
+ * meaningless, so those runs record nothing rather than a wrong number.
127
+ */
128
+ export function isCleanMeasurement(input) {
129
+ const trusted = (confidence) => confidence === "exact" || confidence === "cached";
130
+ return (trusted(input.startSnapshot.confidence) &&
131
+ trusted(input.endSnapshot.confidence) &&
132
+ input.startSnapshot.fiveHour.resetAt === input.endSnapshot.fiveHour.resetAt &&
133
+ input.endSnapshot.fiveHour.used - input.startSnapshot.fiveHour.used >= 0 &&
134
+ input.activeRunCount === 1);
135
+ }
136
+ /**
137
+ * Main-model baseline, p50/p90 in plan credits. Transcribed from doc §13 and
138
+ * NEVER MEASURED on this stack — decision D3 keeps preflight refusal off in
139
+ * 2.0.0 precisely because this table is unmeasured; a wrongly-high row would
140
+ * refuse runs the quota could have afforded.
141
+ */
142
+ const BASELINE_MAIN = {
143
+ explore: { p50: 15, p90: 28 },
144
+ crud: { p50: 42, p90: 66 },
145
+ tests: { p50: 30, p90: 50 },
146
+ docs: { p50: 12, p90: 22 },
147
+ refactor: { p50: 45, p90: 75 },
148
+ bugfix: { p50: 35, p90: 60 },
149
+ other: { p50: 35, p90: 60 },
150
+ };
151
+ /** Fast models are assumed to cost 40% of the main row, rounded. */
152
+ const FAST_MODEL_RATIO = 0.4;
153
+ /** History starts winning at this many samples; below it the noise would outrank the baseline. */
154
+ const MIN_HISTORY_SAMPLES = 5;
155
+ /**
156
+ * Estimated credits for one run of a task kind on a model. History wins when
157
+ * at least MIN_HISTORY_SAMPLES samples match BOTH the task kind and the model;
158
+ * otherwise the baseline table answers, with `samples: 0` and
159
+ * `source: "baseline"`.
160
+ *
161
+ * Main vs fast is decided by comparing `model` with the `fastModel`
162
+ * ARGUMENT — this module is deliberately config-free so it stays pure and
163
+ * testable, and callers (preflight, part 2) pass config.models.fast in.
164
+ */
165
+ export function estimateCost(home, taskKind, model, fastModel) {
166
+ const credits = readSamples(home)
167
+ .filter((entry) => entry.taskKind === taskKind && entry.model === model)
168
+ .map((entry) => entry.credits);
169
+ if (credits.length >= MIN_HISTORY_SAMPLES) {
170
+ const sorted = [...credits].sort((a, b) => a - b);
171
+ return {
172
+ p50: nearestRank(sorted, 0.5),
173
+ p90: nearestRank(sorted, 0.9),
174
+ samples: sorted.length,
175
+ source: "history",
176
+ };
177
+ }
178
+ const main = BASELINE_MAIN[taskKind];
179
+ if (model === fastModel) {
180
+ return {
181
+ p50: Math.round(main.p50 * FAST_MODEL_RATIO),
182
+ p90: Math.round(main.p90 * FAST_MODEL_RATIO),
183
+ samples: 0,
184
+ source: "baseline",
185
+ };
186
+ }
187
+ return { p50: main.p50, p90: main.p90, samples: 0, source: "baseline" };
188
+ }
189
+ /**
190
+ * Nearest-rank percentile, deliberately NOT interpolation: sort ascending,
191
+ * take index ceil(p * n) - 1, clamped into [0, n-1]. For p90 with n = 5 that
192
+ * is literally the maximum sample. Interpolating would invent costs between
193
+ * samples that never happened; the rank is the honest, conservative reading.
194
+ * Do not "fix" this into interpolation later.
195
+ */
196
+ function nearestRank(sortedAsc, p) {
197
+ const index = Math.min(sortedAsc.length - 1, Math.max(0, Math.ceil(p * sortedAsc.length) - 1));
198
+ return sortedAsc[index];
199
+ }
200
+ /**
201
+ * Same philosophy as the run store's isEventLike: "parses and has the fields
202
+ * estimateCost reads" (taskKind, model, credits). A line that parses but
203
+ * carries none of those cannot feed the estimator and is dropped; anything
204
+ * richer is history written by a future version and is kept.
205
+ */
206
+ function isSampleLike(value) {
207
+ if (typeof value !== "object" || value === null) {
208
+ return false;
209
+ }
210
+ const candidate = value;
211
+ return (typeof candidate.taskKind === "string" &&
212
+ typeof candidate.model === "string" &&
213
+ typeof candidate.credits === "number" &&
214
+ Number.isFinite(candidate.credits));
215
+ }
216
+ function errorMessage(error) {
217
+ return error instanceof Error ? error.message : String(error);
218
+ }