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,46 @@
1
+ /**
2
+ * The Z.ai monitor endpoint lives in core because four readers need it —
3
+ * usage, dashboard, the MCP server and the Phase E budget manager — and a
4
+ * budget module must not import from a command module: commands sit on top
5
+ * of core, never underneath it.
6
+ */
7
+ /** Z.ai monitor API used by their own dashboard (specs/usage.md). */
8
+ export const ZAI_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
9
+ /** Window labels for the observed enum values (specs/usage.md); unknown values stay generic. */
10
+ export function describeWindow(limit) {
11
+ if (limit.unit === 3 && typeof limit.number === "number") {
12
+ return `${limit.number}-hour window`;
13
+ }
14
+ if (limit.unit === 6 && limit.number === 1) {
15
+ return "weekly";
16
+ }
17
+ return `window unit=${String(limit.unit)} x ${String(limit.number)}`;
18
+ }
19
+ /** Fetch and validate the Z.ai quota snapshot. Never logs the Authorization header. */
20
+ export async function fetchZaiQuota(key, fetchImpl) {
21
+ let response;
22
+ try {
23
+ response = await fetchImpl(ZAI_QUOTA_URL, {
24
+ method: "GET",
25
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
26
+ signal: AbortSignal.timeout(10_000),
27
+ });
28
+ }
29
+ catch (error) {
30
+ throw new Error(`Z.ai monitor endpoint unreachable (${error instanceof Error ? error.message : "network error"})`);
31
+ }
32
+ if (!response.ok) {
33
+ throw new Error(`Z.ai monitor endpoint returned HTTP ${response.status}`);
34
+ }
35
+ let body;
36
+ try {
37
+ body = (await response.json());
38
+ }
39
+ catch {
40
+ throw new Error("Z.ai monitor endpoint returned a non-JSON body");
41
+ }
42
+ if (body.code !== 200 || typeof body.data !== "object" || body.data === null) {
43
+ throw new Error(`Z.ai monitor endpoint rejected the request (${body.msg ?? `code ${String(body.code)}`})`);
44
+ }
45
+ return body.data;
46
+ }
@@ -0,0 +1,64 @@
1
+ import { logger } from "../core/logging.js";
2
+ /**
3
+ * Not a `createEventBus` parameter: v2 has exactly one provider, and a
4
+ * parameter would invite a wrong value into the persisted history (H2).
5
+ */
6
+ const PROVIDER = "zai.zcode";
7
+ /**
8
+ * Build a bus for one run. `taskId` defaults to the `runId` while there is no
9
+ * task graph (hedge H1); `role` defaults to `"worker"`. `deps.now` exists so
10
+ * tests can pin `ts` deterministically; production omits it and gets the real
11
+ * clock.
12
+ */
13
+ export function createEventBus(runId, deps) {
14
+ const taskId = deps?.taskId ?? runId;
15
+ const role = deps?.role ?? "worker";
16
+ const now = deps?.now;
17
+ const listeners = new Set();
18
+ let seq = 0;
19
+ let closed = false;
20
+ return {
21
+ runId,
22
+ taskId,
23
+ emit(event) {
24
+ const stamped = {
25
+ ...event,
26
+ runId,
27
+ taskId,
28
+ provider: PROVIDER,
29
+ role,
30
+ seq: ++seq,
31
+ ts: (now?.() ?? new Date()).toISOString(),
32
+ };
33
+ if (!closed) {
34
+ for (const listener of listeners) {
35
+ try {
36
+ listener(stamped);
37
+ }
38
+ catch (error) {
39
+ // A broken renderer must never kill a run (specs/v2-architecture.md,
40
+ // Phase A): swallow, keep dispatching, leave a debug trace.
41
+ logger.debug(`event subscriber threw on ${stamped.type} seq ${stamped.seq}: ${errorMessage(error)}`);
42
+ }
43
+ }
44
+ }
45
+ return stamped;
46
+ },
47
+ subscribe(listener) {
48
+ if (closed) {
49
+ return () => { };
50
+ }
51
+ listeners.add(listener);
52
+ return () => {
53
+ listeners.delete(listener);
54
+ };
55
+ },
56
+ close() {
57
+ closed = true;
58
+ listeners.clear();
59
+ },
60
+ };
61
+ }
62
+ function errorMessage(error) {
63
+ return error instanceof Error ? error.message : String(error);
64
+ }
@@ -0,0 +1,416 @@
1
+ import { logger, redact } from "../core/logging.js";
2
+ /**
3
+ * Claude Code `--output-format stream-json` adapter (specs/v2-architecture.md,
4
+ * Phase A).
5
+ *
6
+ * Maps NDJSON stdout lines — and retry-looking stderr lines — onto the
7
+ * canonical event model as UNSTAMPED `EventInput[]`: the bus is the only
8
+ * authority for `runId`/`taskId`/`provider`/`role`/`seq`/`ts`, so a producer
9
+ * physically cannot forge a sequence number.
10
+ *
11
+ * The stream-json schema is not a stable public API (A0), so the mapper is
12
+ * defensive by construction: any unrecognized `type`, missing field or wrong
13
+ * shape yields zero events plus one debug log — never a throw. A malformed
14
+ * line must not kill a run; that is the single most important property of
15
+ * this file, and even an adapter bug degrades the same way (see the
16
+ * try/catch in {@link adaptClaudeMessage}).
17
+ */
18
+ /** The only tool detail allowed past this boundary (contract C3). */
19
+ const SUMMARY_MAX_CHARS = 120;
20
+ /**
21
+ * 83–88% of stream lines are `thinking_tokens` counters (A0). One Heartbeat
22
+ * per second is the cap that keeps `events.jsonl` from being ~85% counter
23
+ * spam while still proving the run is alive.
24
+ */
25
+ const HEARTBEAT_MIN_INTERVAL_MS = 1000;
26
+ /**
27
+ * A command counts as validation only when a runner starts one of its
28
+ * segments. The pattern used to match anywhere in the string, so
29
+ * `ls eslint.config.* vitest.config.*` was reported as a test run purely
30
+ * because a FILENAME contained "vitest" (seen live on 2026-09-20).
31
+ */
32
+ const VALIDATION_SEGMENT = /^(npm|pnpm|yarn) (run )?(test|lint|typecheck)\b|^npx \s*(vitest|jest)\b|^(vitest|jest|pytest|tsc)\b|^go test\b|^cargo test\b|^python3? (-m (pytest|unittest)\b|\S*test\S*\.py)/;
33
+ /** Shell operators that separate one executed command from the next. */
34
+ const SEGMENT_SPLIT = /&&|\|\||;|\|/;
35
+ /** True when any segment of the command line starts with a test/lint runner. */
36
+ function isValidationCommand(command) {
37
+ return command
38
+ .split(SEGMENT_SPLIT)
39
+ .some((segment) => VALIDATION_SEGMENT.test(segment.trim()));
40
+ }
41
+ /** Tools whose ok completion changes a file, and therefore emits `FileChanged`. */
42
+ const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
43
+ /**
44
+ * Matched explicitly, never loosely: stderr also carries structured
45
+ * diagnostics such as `[claude-code:unrecognized_model] {"model":…}` on
46
+ * perfectly successful runs, and classifying those as retries would corrupt
47
+ * the retry counters the estimator feeds on (A0).
48
+ */
49
+ const RETRY_LINE = /\bretr(?:y|ying|ies)\b|\brate[ _-]?limit|\boverload|too many requests|service unavailable|api error:?\s*\b(?:429|500|502|503|504|529)\b/i;
50
+ /**
51
+ * The adapter is I/O-free, so it has no secret list of its own to strip.
52
+ * Summaries still pass through `redact()` so the boundary exists here — when
53
+ * the spawn path (Phase D) can thread real secrets in, this is the one place
54
+ * they apply.
55
+ */
56
+ const NO_SECRETS = [];
57
+ export function createAdapterState(cwd) {
58
+ return {
59
+ turn: 0,
60
+ pending: new Map(),
61
+ // 0 means "never beaten": any wall clock is already past it, so the first
62
+ // counter line of a run establishes liveness immediately.
63
+ lastHeartbeatMs: 0,
64
+ cwd,
65
+ sawResultSinceLastToolUse: false,
66
+ filesChanged: new Set(),
67
+ };
68
+ }
69
+ /**
70
+ * Pure: same input + state produces the same events. Mutates `state` (that is
71
+ * its job — pending tools, turn counter, heartbeat throttle), but touches no
72
+ * clock, file or network besides the injected `now`.
73
+ */
74
+ export function adaptClaudeMessage(raw, state, now) {
75
+ try {
76
+ return mapMessage(raw, state, now ?? Date.now);
77
+ }
78
+ catch (error) {
79
+ // The mappers below already shape-check everything; this catch is for
80
+ // adapter bugs, so the "malformed line must not kill a run" property
81
+ // holds even when the bug is ours.
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ logger.debug(`claude-adapter: dropping message that failed to map: ${message}`);
84
+ return [];
85
+ }
86
+ }
87
+ /**
88
+ * Wraps the pure mapper with NDJSON line buffering (partial chunks, `\r\n`,
89
+ * blank lines) and the stderr retry sink. One adapter instance per run: the
90
+ * buffered tail and the state it carries are per-stream.
91
+ */
92
+ export function createStreamAdapter(cwd, deps) {
93
+ const state = createAdapterState(cwd);
94
+ const now = deps?.now;
95
+ let buffered = "";
96
+ const consumeLine = (line) => {
97
+ const trimmed = line.replace(/\r$/, "").trim();
98
+ if (trimmed === "") {
99
+ return [];
100
+ }
101
+ try {
102
+ return adaptClaudeMessage(JSON.parse(trimmed), state, now);
103
+ }
104
+ catch {
105
+ logger.debug("claude-adapter: stdout line is not valid JSON");
106
+ return [];
107
+ }
108
+ };
109
+ return {
110
+ onStdoutLine(line) {
111
+ return consumeLine(line);
112
+ },
113
+ // Only `\n`-terminated lines are consumed — the `\r` of a `\r\n` pair
114
+ // rides along in the line and is stripped — so a JSON object split
115
+ // across two chunks still parses exactly once, when complete.
116
+ onStdoutChunk(chunk) {
117
+ buffered += chunk;
118
+ const events = [];
119
+ let newlineAt = buffered.indexOf("\n");
120
+ while (newlineAt >= 0) {
121
+ const line = buffered.slice(0, newlineAt);
122
+ buffered = buffered.slice(newlineAt + 1);
123
+ events.push(...consumeLine(line));
124
+ newlineAt = buffered.indexOf("\n");
125
+ }
126
+ return events;
127
+ },
128
+ onStderrLine(line) {
129
+ return mapStderrLine(line);
130
+ },
131
+ };
132
+ }
133
+ function mapMessage(raw, state, now) {
134
+ if (!isRecord(raw)) {
135
+ logger.debug("claude-adapter: ignoring non-object stream line");
136
+ return [];
137
+ }
138
+ switch (raw.type) {
139
+ case "system":
140
+ return mapSystemMessage(raw, state, now);
141
+ case "assistant":
142
+ return mapAssistantMessage(raw, state, now);
143
+ case "user":
144
+ return mapUserMessage(raw, state, now);
145
+ case "result":
146
+ return mapResultMessage(raw, state);
147
+ default:
148
+ logger.debug(`claude-adapter: unrecognized stream message type ${JSON.stringify(raw.type)}`);
149
+ return [];
150
+ }
151
+ }
152
+ function mapSystemMessage(raw, state, now) {
153
+ switch (raw.subtype) {
154
+ case "init":
155
+ return mapInit(raw, state);
156
+ case "thinking_tokens":
157
+ return mapThinkingTokens(state, now);
158
+ case "permission_denied":
159
+ return mapPermissionDenied(raw, state);
160
+ // User hooks fire inside the stream (A0); they are neither tool activity
161
+ // nor errors, so they are recognized and dropped without a log line.
162
+ case "hook_started":
163
+ case "hook_response":
164
+ return [];
165
+ default:
166
+ logger.debug(`claude-adapter: unrecognized system subtype ${JSON.stringify(raw.subtype)}`);
167
+ return [];
168
+ }
169
+ }
170
+ function mapInit(raw, state) {
171
+ const sessionId = asString(raw.session_id);
172
+ const model = asString(raw.model);
173
+ if (sessionId === undefined || model === undefined) {
174
+ logger.debug("claude-adapter: system/init missing session_id or model");
175
+ return [];
176
+ }
177
+ state.sessionId = sessionId;
178
+ const tools = Array.isArray(raw.tools)
179
+ ? raw.tools.filter((tool) => typeof tool === "string")
180
+ : [];
181
+ return [{ type: "AgentInitialized", sessionId, model, tools }];
182
+ }
183
+ function mapThinkingTokens(state, now) {
184
+ const at = now();
185
+ if (at - state.lastHeartbeatMs < HEARTBEAT_MIN_INTERVAL_MS) {
186
+ return [];
187
+ }
188
+ state.lastHeartbeatMs = at;
189
+ return [{ type: "Heartbeat", state: "working", turn: state.turn }];
190
+ }
191
+ function mapPermissionDenied(raw, state) {
192
+ const toolUseId = asString(raw.tool_use_id);
193
+ const tool = asString(raw.tool_name);
194
+ const reason = asString(raw.decision_reason) ?? asString(raw.message);
195
+ if (toolUseId === undefined || tool === undefined || reason === undefined) {
196
+ logger.debug("claude-adapter: system/permission_denied missing tool_use_id, tool_name or reason");
197
+ return [];
198
+ }
199
+ // The denied tool never ran: drop the pending entry so the error
200
+ // tool_result that follows maps to nothing instead of a bogus ToolCompleted.
201
+ state.pending.delete(toolUseId);
202
+ return [{ type: "ToolDenied", turn: state.turn, toolUseId, tool, reason: sanitize(reason) }];
203
+ }
204
+ function mapAssistantMessage(raw, state, now) {
205
+ const message = raw.message;
206
+ if (!isRecord(message) || !Array.isArray(message.content)) {
207
+ logger.debug("claude-adapter: assistant message without a content array");
208
+ return [];
209
+ }
210
+ const events = [];
211
+ for (const block of message.content) {
212
+ if (!isRecord(block)) {
213
+ continue;
214
+ }
215
+ switch (block.type) {
216
+ // Raw model reasoning: dropped at the boundary, never summarized,
217
+ // never persisted (contract C3, A0).
218
+ case "thinking":
219
+ break;
220
+ // Final answer text is the spawn path's business (stdout, contract C1);
221
+ // no event ever carries an LLM response body.
222
+ case "text":
223
+ break;
224
+ case "tool_use":
225
+ events.push(...mapToolUse(block, state, now));
226
+ break;
227
+ default:
228
+ logger.debug(`claude-adapter: unrecognized assistant content block ${JSON.stringify(block.type)}`);
229
+ break;
230
+ }
231
+ }
232
+ return events;
233
+ }
234
+ function mapToolUse(block, state, now) {
235
+ const toolUseId = asString(block.id);
236
+ const tool = asString(block.name);
237
+ if (toolUseId === undefined || tool === undefined) {
238
+ logger.debug("claude-adapter: tool_use block without id or name");
239
+ return [];
240
+ }
241
+ const input = isRecord(block.input) ? block.input : {};
242
+ const command = asString(input.command);
243
+ const summary = summarizeToolUse(tool, input, state.cwd);
244
+ const isValidation = tool === "Bash" && command !== undefined && isValidationCommand(command);
245
+ const events = [];
246
+ // Turn derivation (A0): one turn per tool_result → next tool_use cycle.
247
+ // A TurnStarted per assistant message would roughly double every count,
248
+ // because the stream emits thinking / tool_use / text as separate messages.
249
+ if (state.turn === 0 || state.sawResultSinceLastToolUse) {
250
+ state.turn += 1;
251
+ state.sawResultSinceLastToolUse = false;
252
+ events.push({ type: "TurnStarted", turn: state.turn });
253
+ }
254
+ if (isValidation) {
255
+ events.push({ type: "ValidationStarted", turn: state.turn, command: summary });
256
+ }
257
+ events.push({ type: "ToolStarted", turn: state.turn, toolUseId, tool, summary });
258
+ state.pending.set(toolUseId, { tool, summary, startedMs: now(), isValidation, turn: state.turn });
259
+ return events;
260
+ }
261
+ function mapUserMessage(raw, state, now) {
262
+ const message = raw.message;
263
+ if (!isRecord(message) || !Array.isArray(message.content)) {
264
+ logger.debug("claude-adapter: user message without a content array");
265
+ return [];
266
+ }
267
+ const events = [];
268
+ for (const block of message.content) {
269
+ if (!isRecord(block) || block.type !== "tool_result") {
270
+ continue;
271
+ }
272
+ // Any tool_result closes a turn's tool cycle — even one with no matching
273
+ // pending entry — so the next tool_use still opens a new turn.
274
+ state.sawResultSinceLastToolUse = true;
275
+ events.push(...mapToolResult(block, state, now));
276
+ }
277
+ return events;
278
+ }
279
+ function mapToolResult(block, state, now) {
280
+ const toolUseId = asString(block.tool_use_id);
281
+ if (toolUseId === undefined) {
282
+ logger.debug("claude-adapter: tool_result block without tool_use_id");
283
+ return [];
284
+ }
285
+ const pending = state.pending.get(toolUseId);
286
+ if (pending === undefined) {
287
+ // Expected right after a permission_denied dropped the entry, or on
288
+ // schema drift; either way there is nothing truthful to report.
289
+ logger.debug(`claude-adapter: tool_result for unknown tool_use_id ${toolUseId}`);
290
+ return [];
291
+ }
292
+ state.pending.delete(toolUseId);
293
+ const ok = block.is_error !== true;
294
+ const durationMs = Math.max(0, now() - pending.startedMs);
295
+ const events = [
296
+ { type: "ToolCompleted", turn: pending.turn, toolUseId, tool: pending.tool, ok, durationMs },
297
+ ];
298
+ if (pending.isValidation) {
299
+ events.push({ type: "ValidationCompleted", turn: pending.turn, command: pending.summary, ok, durationMs });
300
+ }
301
+ if (ok && FILE_TOOLS.has(pending.tool)) {
302
+ // The summary for these tools *is* the repo-relative path, which is why
303
+ // PendingTool needs no copy of the raw input (C3).
304
+ events.push({
305
+ type: "FileChanged",
306
+ turn: pending.turn,
307
+ path: pending.summary,
308
+ op: pending.tool === "Write" ? "write" : "edit",
309
+ });
310
+ state.filesChanged.add(pending.summary);
311
+ }
312
+ return events;
313
+ }
314
+ function mapResultMessage(raw, state) {
315
+ if (raw.subtype === "success") {
316
+ const turns = asFiniteNumber(raw.num_turns);
317
+ const durationMs = asFiniteNumber(raw.duration_ms);
318
+ if (turns === undefined || durationMs === undefined) {
319
+ logger.debug("claude-adapter: success result missing num_turns or duration_ms");
320
+ return [];
321
+ }
322
+ const usage = isRecord(raw.usage) ? raw.usage : {};
323
+ // Token counts are real (they come from the API response).
324
+ // `total_cost_usd` / `modelUsage[].costUSD` are deliberately never read:
325
+ // Claude Code computes them with Anthropic's price table for a model it
326
+ // does not know, so for this stack they are fiction (A0). GLM bills in
327
+ // plan credits; the quota delta stays the only cost truth.
328
+ return [
329
+ {
330
+ type: "RunCompleted",
331
+ turns,
332
+ durationMs,
333
+ filesChanged: state.filesChanged.size,
334
+ tokensIn: asFiniteNumber(usage.input_tokens) ?? 0,
335
+ tokensOut: asFiniteNumber(usage.output_tokens) ?? 0,
336
+ },
337
+ ];
338
+ }
339
+ return [
340
+ {
341
+ type: "RunFailed",
342
+ reason: sanitize(asString(raw.terminal_reason) ?? asString(raw.subtype) ?? "unknown"),
343
+ exitCode: 1,
344
+ },
345
+ ];
346
+ }
347
+ function mapStderrLine(line) {
348
+ const trimmed = line.trim();
349
+ if (trimmed === "" || !RETRY_LINE.test(trimmed)) {
350
+ // Everything else is a passthrough diagnostic for the spawn path to
351
+ // forward, not an event.
352
+ return [];
353
+ }
354
+ const attempt = /\battempt (\d+)\b/i.exec(trimmed);
355
+ return attempt === null
356
+ ? [{ type: "ApiRetry", reason: sanitize(trimmed) }]
357
+ : [{ type: "ApiRetry", attempt: Number(attempt[1]), reason: sanitize(trimmed) }];
358
+ }
359
+ function summarizeToolUse(tool, input, cwd) {
360
+ let text;
361
+ switch (tool) {
362
+ case "Read":
363
+ case "Edit":
364
+ case "Write":
365
+ case "MultiEdit": {
366
+ const filePath = asString(input.file_path);
367
+ text = filePath === undefined ? tool : toRepoRelative(filePath, cwd);
368
+ break;
369
+ }
370
+ case "Bash": {
371
+ const command = asString(input.command);
372
+ text = command === undefined ? tool : firstLine(command);
373
+ break;
374
+ }
375
+ case "Grep":
376
+ case "Glob": {
377
+ const pattern = asString(input.pattern);
378
+ text = pattern === undefined ? tool : pattern;
379
+ break;
380
+ }
381
+ default:
382
+ text = tool;
383
+ }
384
+ return sanitize(text);
385
+ }
386
+ /**
387
+ * Repo-relative when the path is under the cwd, untouched otherwise. A plain
388
+ * prefix check rather than `path.relative`, so the POSIX paths in the
389
+ * captured fixtures and Windows paths on a live run behave identically and
390
+ * the adapter stays platform-free.
391
+ */
392
+ function toRepoRelative(filePath, cwd) {
393
+ for (const sep of ["/", "\\"]) {
394
+ const prefix = cwd.endsWith(sep) ? cwd : cwd + sep;
395
+ if (filePath.startsWith(prefix)) {
396
+ return filePath.slice(prefix.length);
397
+ }
398
+ }
399
+ return filePath;
400
+ }
401
+ function firstLine(command) {
402
+ return command.split(/\r?\n/)[0];
403
+ }
404
+ /** Every user-facing string from the adapter is redacted and short (C3). */
405
+ function sanitize(text) {
406
+ return redact(text, NO_SECRETS).slice(0, SUMMARY_MAX_CHARS);
407
+ }
408
+ function isRecord(value) {
409
+ return typeof value === "object" && value !== null && !Array.isArray(value);
410
+ }
411
+ function asString(value) {
412
+ return typeof value === "string" ? value : undefined;
413
+ }
414
+ function asFiniteNumber(value) {
415
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
416
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The canonical v2 event model (specs/v2-architecture.md, Phase A).
3
+ *
4
+ * One discriminated union feeds every v2 consumer — run store, stderr
5
+ * renderer, checkpoint builder, drain controller — so these shapes are the
6
+ * contract the whole observability stack is built on. Types only: there is
7
+ * deliberately no runtime code in this file.
8
+ */
9
+ export {};