@hegemonart/get-design-done 1.20.0 → 1.22.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 (69) hide show
  1. package/.claude-plugin/marketplace.json +9 -12
  2. package/.claude-plugin/plugin.json +8 -31
  3. package/CHANGELOG.md +200 -0
  4. package/README.md +48 -7
  5. package/bin/gdd-sdk +55 -0
  6. package/hooks/_hook-emit.js +81 -0
  7. package/hooks/gdd-bash-guard.js +8 -0
  8. package/hooks/gdd-decision-injector.js +2 -0
  9. package/hooks/gdd-protected-paths.js +8 -0
  10. package/hooks/gdd-trajectory-capture.js +64 -0
  11. package/hooks/hooks.json +9 -0
  12. package/package.json +19 -47
  13. package/reference/codex-tools.md +53 -0
  14. package/reference/gemini-tools.md +53 -0
  15. package/reference/registry.json +14 -0
  16. package/scripts/cli/gdd-events.mjs +283 -0
  17. package/scripts/e2e/run-headless.ts +514 -0
  18. package/scripts/lib/cli/commands/audit.ts +382 -0
  19. package/scripts/lib/cli/commands/init.ts +217 -0
  20. package/scripts/lib/cli/commands/query.ts +329 -0
  21. package/scripts/lib/cli/commands/run.ts +656 -0
  22. package/scripts/lib/cli/commands/stage.ts +468 -0
  23. package/scripts/lib/cli/index.ts +167 -0
  24. package/scripts/lib/cli/parse-args.ts +336 -0
  25. package/scripts/lib/connection-probe/index.cjs +263 -0
  26. package/scripts/lib/context-engine/index.ts +116 -0
  27. package/scripts/lib/context-engine/manifest.ts +69 -0
  28. package/scripts/lib/context-engine/truncate.ts +282 -0
  29. package/scripts/lib/context-engine/types.ts +59 -0
  30. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  31. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  32. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  33. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  34. package/scripts/lib/event-chain.cjs +177 -0
  35. package/scripts/lib/event-stream/index.ts +31 -1
  36. package/scripts/lib/event-stream/reader.ts +139 -0
  37. package/scripts/lib/event-stream/types.ts +155 -1
  38. package/scripts/lib/event-stream/writer.ts +65 -8
  39. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  40. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  41. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  42. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  43. package/scripts/lib/harness/detect.ts +90 -0
  44. package/scripts/lib/harness/index.ts +64 -0
  45. package/scripts/lib/harness/tool-map.ts +142 -0
  46. package/scripts/lib/init-runner/index.ts +396 -0
  47. package/scripts/lib/init-runner/researchers.ts +245 -0
  48. package/scripts/lib/init-runner/scaffold.ts +224 -0
  49. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  50. package/scripts/lib/init-runner/types.ts +143 -0
  51. package/scripts/lib/logger/index.ts +251 -0
  52. package/scripts/lib/logger/sinks.ts +269 -0
  53. package/scripts/lib/logger/types.ts +110 -0
  54. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  55. package/scripts/lib/pipeline-runner/index.ts +527 -0
  56. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  57. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  58. package/scripts/lib/pipeline-runner/types.ts +183 -0
  59. package/scripts/lib/redact.cjs +122 -0
  60. package/scripts/lib/session-runner/errors.ts +406 -0
  61. package/scripts/lib/session-runner/index.ts +715 -0
  62. package/scripts/lib/session-runner/transcript.ts +189 -0
  63. package/scripts/lib/session-runner/types.ts +144 -0
  64. package/scripts/lib/tool-scoping/index.ts +219 -0
  65. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  66. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  67. package/scripts/lib/tool-scoping/types.ts +77 -0
  68. package/scripts/lib/trajectory/index.cjs +126 -0
  69. package/scripts/lib/transports/ws.cjs +179 -0
@@ -0,0 +1,184 @@
1
+ // scripts/lib/discuss-parallel-runner/types.ts — Plan 21-07 (SDK-19).
2
+ //
3
+ // Public type surface for the parallel discussion runner. The runner
4
+ // spawns N design-discussant variants (each a different persona / angle
5
+ // — user-journey, technical-constraint, brand-fit, accessibility)
6
+ // concurrently via `session-runner`, collects each discussant's open
7
+ // questions and concerns as a structured `DiscussionContribution`, and
8
+ // runs an aggregator pass that deduplicates, clusters by theme, and
9
+ // surfaces a single ranked question list for the user.
10
+ //
11
+ // Consumers: the `discuss` skill (standalone leaf) and the `gdd-sdk
12
+ // discuss` CLI subcommand (Plan 21-09).
13
+ //
14
+ // Design invariants (see PLAN.md Context):
15
+ // * Discussant sessions are independent — none write to shared files;
16
+ // parallelism is always safe.
17
+ // * The aggregator runs AFTER all discussants complete.
18
+ // * Error isolation: one discussant's failure never cascades into
19
+ // other discussant sessions.
20
+ // * `AggregatedQuestion.key` is SHA-256-based (stable across runs)
21
+ // per the aggregator prompt contract.
22
+
23
+ import type {
24
+ BudgetCap,
25
+ SessionResult,
26
+ SessionRunnerOptions,
27
+ } from '../session-runner/types.ts';
28
+
29
+ /**
30
+ * Named discussant variants. The default roster is the four values in
31
+ * the union below, but callers can pass any string (arbitrary custom
32
+ * discussant).
33
+ */
34
+ export type DiscussantName =
35
+ | 'user-journey'
36
+ | 'technical-constraint'
37
+ | 'brand-fit'
38
+ | 'accessibility'
39
+ | string;
40
+
41
+ /** Severity ordering is blocker > major > minor > nice-to-have. */
42
+ export type Severity = 'blocker' | 'major' | 'minor' | 'nice-to-have';
43
+
44
+ /**
45
+ * One discussant specification. The runner passes `prompt` verbatim
46
+ * through `session-runner.run()` as the prompt body.
47
+ *
48
+ * `agentPath` is optional; when absent, the runner defaults to the
49
+ * stage scope from tool-scoping (`discuss` maps to `custom` — see
50
+ * Plan 21-03). When present, the runner reads the agent markdown's
51
+ * `tools:` frontmatter via `parseAgentToolsByName` and passes the
52
+ * resolved list as `allowedTools`.
53
+ */
54
+ export interface DiscussantSpec {
55
+ name: DiscussantName;
56
+ /** Optional agent frontmatter path; missing → stage scope from tool-scoping. */
57
+ agentPath?: string;
58
+ /** Per-discussant prompt body. */
59
+ prompt: string;
60
+ }
61
+
62
+ /**
63
+ * One parsed item from a discussant's DISCUSSION COMPLETE block.
64
+ *
65
+ * `kind` discriminates questions (things the discussant wants
66
+ * answered) from concerns (things they want to flag).
67
+ *
68
+ * `tag` captures the per-item annotation from the discussant output
69
+ * (`Concern: <stakeholder>` for questions, `Area: <scope>` for
70
+ * concerns). Optional because lenient parse allows missing.
71
+ */
72
+ export interface DiscussionItem {
73
+ kind: 'question' | 'concern';
74
+ text: string;
75
+ /** Area / angle / concern tag per discussant output. */
76
+ tag?: string;
77
+ severity: Severity;
78
+ rationale?: string;
79
+ }
80
+
81
+ /**
82
+ * One discussant's complete contribution. `items` is empty when the
83
+ * session errored or the block was missing/malformed.
84
+ *
85
+ * `status`:
86
+ * * `completed` — session ended cleanly AND a DISCUSSION COMPLETE
87
+ * block was parsed successfully.
88
+ * * `parse-error` — session ended cleanly but the block was absent
89
+ * or malformed (items: []).
90
+ * * `error` — session failed (budget, turn cap, aborted, error);
91
+ * `error` populated; items: [].
92
+ */
93
+ export interface DiscussionContribution {
94
+ discussant: DiscussantName;
95
+ items: readonly DiscussionItem[];
96
+ /** Raw final_text captured for audit / aggregator input. */
97
+ raw: string;
98
+ usage: { input_tokens: number; output_tokens: number; usd_cost: number };
99
+ status: 'completed' | 'error' | 'parse-error';
100
+ error?: { code: string; message: string };
101
+ }
102
+
103
+ /**
104
+ * One aggregated (post-dedup, post-cluster) question.
105
+ *
106
+ * * `key` — SHA-256 of the normalized question text (lowercase,
107
+ * whitespace-collapsed) truncated to 8 hex chars.
108
+ * Stable across runs.
109
+ * * `raised_by` — discussants that raised (a semantic variant of)
110
+ * this question.
111
+ * * `theme` — cluster/theme name from aggregator.
112
+ * * `rank` — 0-indexed priority (0 = highest). Ranking combines
113
+ * severity + frequency per the aggregator prompt.
114
+ */
115
+ export interface AggregatedQuestion {
116
+ /** Stable key across runs (hash of normalized question text). */
117
+ key: string;
118
+ text: string;
119
+ severity: Severity;
120
+ /** Discussants that raised this question. */
121
+ raised_by: readonly DiscussantName[];
122
+ /** Cluster/theme assignment from aggregator. */
123
+ theme: string;
124
+ /** Aggregator-assigned rank (0 = highest priority). */
125
+ rank: number;
126
+ }
127
+
128
+ /**
129
+ * The aggregator's final output. `output_path` is the Markdown file
130
+ * written to disk (`.design/DISCUSSION.md` by default). `usage` is
131
+ * the aggregator session's token/cost spend — separate from the
132
+ * per-discussant usage aggregated in `DiscussRunnerResult.total_usage`.
133
+ */
134
+ export interface AggregatedDiscussion {
135
+ themes: readonly { name: string; summary: string }[];
136
+ questions: readonly AggregatedQuestion[];
137
+ /** Output path. */
138
+ output_path: string;
139
+ /** Aggregator session usage. */
140
+ usage: { input_tokens: number; output_tokens: number; usd_cost: number };
141
+ }
142
+
143
+ /**
144
+ * Options for `run()` — the top-level orchestrator entry point.
145
+ *
146
+ * * `discussants` — omit to use `DEFAULT_DISCUSSANTS` (4 variants).
147
+ * * `budget` + `maxTurnsPerDiscussant` — applied per-discussant.
148
+ * * `aggregatorBudget` + `aggregatorMaxTurns` — applied to the
149
+ * aggregator session specifically.
150
+ * * `concurrency` — defaults to 4 (matches the default roster size).
151
+ * * `runOverride` — test injection for `session-runner.run()`. All
152
+ * discussants AND the aggregator receive the SAME override so one
153
+ * mock controls the entire run.
154
+ * * `aggregatorPrompt` — replace the default aggregator prompt
155
+ * (advanced / debug use).
156
+ */
157
+ export interface DiscussRunnerOptions {
158
+ /** Discussants to run. Default: the 4-variant roster. */
159
+ discussants?: readonly DiscussantSpec[];
160
+ budget: BudgetCap;
161
+ maxTurnsPerDiscussant: number;
162
+ aggregatorBudget: BudgetCap;
163
+ aggregatorMaxTurns: number;
164
+ concurrency?: number;
165
+ runOverride?: (opts: SessionRunnerOptions) => Promise<SessionResult>;
166
+ cwd?: string;
167
+ /** Custom aggregator prompt override. */
168
+ aggregatorPrompt?: string;
169
+ }
170
+
171
+ /**
172
+ * The final return value from `run()`.
173
+ *
174
+ * * `contributions` — one per discussant, in input spec order (NOT
175
+ * completion order). Failed discussants are included with
176
+ * `status !== 'completed'`.
177
+ * * `aggregated` — the aggregator's parsed output.
178
+ * * `total_usage` — sum of per-discussant + aggregator usage.
179
+ */
180
+ export interface DiscussRunnerResult {
181
+ contributions: readonly DiscussionContribution[];
182
+ aggregated: AggregatedDiscussion;
183
+ total_usage: { input_tokens: number; output_tokens: number; usd_cost: number };
184
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * event-chain.cjs — append-only causal event chain (Plan 22-04).
3
+ *
4
+ * Lives at `.design/gep/events.jsonl` (gep = "GDD Event Provenance").
5
+ * One JSONL line per event with parent-id linkage so consumers can
6
+ * walk decision → agent-spawn → outcome chains for retroactive audit.
7
+ *
8
+ * Schema:
9
+ * {
10
+ * event_id: UUIDv4 (random)
11
+ * parent_event_id: string | null
12
+ * ts: ISO-8601
13
+ * agent: string
14
+ * decision_refs: string[] // STATE.md decision IDs (D-NN, etc.)
15
+ * outcome: string // free-form: 'pass' / 'fail' / 'rolled-back' / …
16
+ * rollback_reason: string? // present iff outcome = 'rolled-back'
17
+ * ...rest: opaque caller-supplied fields preserved verbatim
18
+ * }
19
+ *
20
+ * Why a separate file from .design/telemetry/events.jsonl:
21
+ * * the chain is a CAUSAL overlay — rows have semantic meaning
22
+ * * the general event-stream is a high-volume firehose
23
+ * * /gdd:audit --retroactive walks the chain; it should not have to
24
+ * scan a 100k-line firehose for causal rows
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const { appendFileSync, mkdirSync, readFileSync, existsSync } = require('node:fs');
30
+ const { dirname, isAbsolute, join, resolve } = require('node:path');
31
+ const { randomUUID } = require('node:crypto');
32
+
33
+ const DEFAULT_CHAIN_PATH = '.design/gep/events.jsonl';
34
+
35
+ /**
36
+ * Resolve the on-disk chain file path, honouring an absolute override.
37
+ *
38
+ * @param {{baseDir?: string, path?: string}} [opts]
39
+ * @returns {string}
40
+ */
41
+ function chainPathFor(opts = {}) {
42
+ if (opts.path) {
43
+ return isAbsolute(opts.path) ? opts.path : resolve(opts.baseDir ?? process.cwd(), opts.path);
44
+ }
45
+ const base = opts.baseDir ?? process.cwd();
46
+ return resolve(base, DEFAULT_CHAIN_PATH);
47
+ }
48
+
49
+ /**
50
+ * Append one chain event. Returns the event_id (caller may not have
51
+ * supplied one).
52
+ *
53
+ * Required fields: agent, outcome.
54
+ * Optional: parent_event_id, decision_refs, rollback_reason, plus any
55
+ * opaque extra fields which are preserved verbatim.
56
+ *
57
+ * @param {{
58
+ * event_id?: string,
59
+ * parent_event_id?: string | null,
60
+ * agent: string,
61
+ * decision_refs?: string[],
62
+ * outcome: string,
63
+ * rollback_reason?: string,
64
+ * ts?: string,
65
+ * path?: string,
66
+ * baseDir?: string,
67
+ * [k: string]: unknown,
68
+ * }} input
69
+ * @returns {string} event_id
70
+ */
71
+ function appendChainEvent(input) {
72
+ if (!input || typeof input.agent !== 'string' || input.agent.length === 0) {
73
+ throw new TypeError('appendChainEvent: agent is required');
74
+ }
75
+ if (typeof input.outcome !== 'string' || input.outcome.length === 0) {
76
+ throw new TypeError('appendChainEvent: outcome is required');
77
+ }
78
+ const event_id = input.event_id || randomUUID();
79
+ const record = {
80
+ event_id,
81
+ parent_event_id: input.parent_event_id ?? null,
82
+ ts: input.ts || new Date().toISOString(),
83
+ agent: input.agent,
84
+ decision_refs: Array.isArray(input.decision_refs) ? input.decision_refs : [],
85
+ outcome: input.outcome,
86
+ };
87
+ if (input.rollback_reason !== undefined) {
88
+ record.rollback_reason = input.rollback_reason;
89
+ }
90
+ // Preserve opaque extras (any keys not already on `record`).
91
+ for (const key of Object.keys(input)) {
92
+ if (key === 'path' || key === 'baseDir') continue;
93
+ if (!(key in record)) {
94
+ record[key] = input[key];
95
+ }
96
+ }
97
+ const path = chainPathFor({ baseDir: input.baseDir, path: input.path });
98
+ try {
99
+ mkdirSync(dirname(path), { recursive: true });
100
+ appendFileSync(path, JSON.stringify(record) + '\n', { flag: 'a' });
101
+ } catch (err) {
102
+ try {
103
+ process.stderr.write(
104
+ `[event-chain] write failed: ${err && err.message ? err.message : String(err)}\n`,
105
+ );
106
+ } catch {
107
+ /* swallow */
108
+ }
109
+ }
110
+ return event_id;
111
+ }
112
+
113
+ /**
114
+ * Read the chain file and yield each parsed record. Invalid JSON lines
115
+ * are skipped with a stderr warning.
116
+ *
117
+ * @param {{path?: string, baseDir?: string}} [opts]
118
+ * @returns {Generator<Record<string, unknown>>}
119
+ */
120
+ function* readChain(opts = {}) {
121
+ const path = chainPathFor(opts);
122
+ if (!existsSync(path)) return;
123
+ const raw = readFileSync(path, 'utf8');
124
+ let lineNum = 0;
125
+ for (const line of raw.split('\n')) {
126
+ lineNum += 1;
127
+ if (line.trim() === '') continue;
128
+ try {
129
+ yield JSON.parse(line);
130
+ } catch (err) {
131
+ try {
132
+ process.stderr.write(
133
+ `[event-chain] skipping invalid line ${lineNum} at ${path}\n`,
134
+ );
135
+ } catch {
136
+ /* swallow */
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Walk parents of `event_id` until reaching a row with no parent.
144
+ * Returns the chain in caller-→-root order, i.e. `[event, parent, …]`.
145
+ *
146
+ * Returns an empty array if the event_id is not found.
147
+ *
148
+ * @param {string} event_id
149
+ * @param {{path?: string, baseDir?: string}} [opts]
150
+ * @returns {Array<Record<string, unknown>>}
151
+ */
152
+ function walkParents(event_id, opts = {}) {
153
+ /** @type {Map<string, Record<string, unknown>>} */
154
+ const byId = new Map();
155
+ for (const ev of readChain(opts)) {
156
+ byId.set(/** @type {string} */ (ev.event_id), ev);
157
+ }
158
+ const chain = [];
159
+ /** @type {string | null | undefined} */
160
+ let id = event_id;
161
+ const visited = new Set();
162
+ while (id && byId.has(id) && !visited.has(id)) {
163
+ visited.add(id);
164
+ const ev = byId.get(id);
165
+ chain.push(ev);
166
+ id = /** @type {string | null | undefined} */ (ev.parent_event_id);
167
+ }
168
+ return chain;
169
+ }
170
+
171
+ module.exports = {
172
+ appendChainEvent,
173
+ readChain,
174
+ walkParents,
175
+ chainPathFor,
176
+ DEFAULT_CHAIN_PATH,
177
+ };
@@ -33,11 +33,31 @@ export type {
33
33
  StageExitedEvent,
34
34
  HookFiredEvent,
35
35
  ErrorEvent,
36
+ WaveStartedEvent,
37
+ WaveCompletedEvent,
38
+ BlockerAddedEvent,
39
+ DecisionAddedEvent,
40
+ MustHaveAddedEvent,
41
+ ParallelismVerdictEvent,
42
+ CostUpdateEvent,
43
+ RateLimitEvent,
44
+ ApiRetryEvent,
45
+ CompactBoundaryEvent,
46
+ McpProbeEvent,
47
+ ReflectionProposedEvent,
48
+ ConnectionStatusChangeEvent,
49
+ ToolCallStartedEvent,
50
+ ToolCallCompletedEvent,
51
+ AgentSpawnEvent,
52
+ AgentOutcomeEvent,
36
53
  } from './types.ts';
54
+ export { KNOWN_EVENT_TYPES } from './types.ts';
37
55
  export { EventBus } from './emitter.ts';
38
56
  export type { EventHandler, Unsubscribe } from './emitter.ts';
39
57
  export { EventWriter, DEFAULT_EVENTS_PATH, DEFAULT_MAX_LINE_BYTES } from './writer.ts';
40
58
  export type { WriterOptions } from './writer.ts';
59
+ export { readEvents, aggregate } from './reader.ts';
60
+ export type { ReadEventsOptions, AggregateResult } from './reader.ts';
41
61
 
42
62
  /**
43
63
  * Lazily-constructed module-level singletons. `getWriter()` honors the
@@ -61,7 +81,17 @@ let cachedHost: string | null = null;
61
81
  */
62
82
  export function getWriter(opts?: WriterOptions): EventWriter {
63
83
  if (defaultWriter === null) {
64
- defaultWriter = new EventWriter(opts ?? {});
84
+ // Honor GDD_EVENTS_PATH env var as the first-choice default path
85
+ // when the caller doesn't pass an explicit `opts.path`. Lets test
86
+ // harnesses and Plan 21-11's E2E subprocess steer the on-disk
87
+ // stream into a fixture-specific directory without chdir'ing the
88
+ // entire process. Explicit `opts.path` always wins.
89
+ const envPath: string | undefined = process.env['GDD_EVENTS_PATH'];
90
+ const finalOpts: WriterOptions =
91
+ opts?.path === undefined && envPath !== undefined && envPath.length > 0
92
+ ? { ...(opts ?? {}), path: envPath }
93
+ : (opts ?? {});
94
+ defaultWriter = new EventWriter(finalOpts);
65
95
  }
66
96
  return defaultWriter;
67
97
  }
@@ -0,0 +1,139 @@
1
+ // scripts/lib/event-stream/reader.ts — typed JSONL reader + aggregator
2
+ // (Plan 22-05).
3
+ //
4
+ // `readEvents()` is a streaming async iterator over the persisted event
5
+ // log. It uses `readline` over a `fs.createReadStream`, so the entire
6
+ // file is never held in memory — events.jsonl can grow to gigabytes
7
+ // without OOM-ing a tail consumer.
8
+ //
9
+ // `aggregate()` collects an event iterable into a structured rollup
10
+ // (counts by type / stage / cycle / agent + totals). Aggregation
11
+ // always materialises the iterator, so callers that already have very
12
+ // large logs should pre-filter via `readEvents({filter: …})` before
13
+ // aggregating.
14
+
15
+ import { createReadStream, existsSync, type ReadStream } from 'node:fs';
16
+ import { isAbsolute, resolve } from 'node:path';
17
+ import { createInterface } from 'node:readline';
18
+
19
+ import type { BaseEvent } from './types.ts';
20
+ import { DEFAULT_EVENTS_PATH } from './writer.ts';
21
+
22
+ /** Options for {@link readEvents}. */
23
+ export interface ReadEventsOptions {
24
+ /** Source file path. Default: `.design/telemetry/events.jsonl` (resolved against cwd). */
25
+ path?: string;
26
+ /** Resolution base for relative `path`. Default: `process.cwd()`. */
27
+ baseDir?: string;
28
+ /** Match by event type — string is exact-equal, RegExp is `.test(type)`. */
29
+ type?: string | RegExp;
30
+ /** Custom predicate; runs after `type` filter if both are supplied. */
31
+ predicate?: (ev: BaseEvent) => boolean;
32
+ /** Inclusive lower bound on `timestamp` (ISO-8601 string). */
33
+ since?: string;
34
+ /** Inclusive upper bound on `timestamp` (ISO-8601 string). */
35
+ until?: string;
36
+ }
37
+
38
+ /** Result shape from {@link aggregate}. */
39
+ export interface AggregateResult {
40
+ byType: Record<string, number>;
41
+ byStage: Record<string, number>;
42
+ byCycle: Record<string, number>;
43
+ byAgent: Record<string, number>;
44
+ totals: {
45
+ count: number;
46
+ error_count: number;
47
+ truncated_count: number;
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Resolve the read path the same way the writer does: absolute paths
53
+ * win; relative paths resolve against `baseDir` (defaults to cwd).
54
+ */
55
+ function resolveReadPath(opts: ReadEventsOptions): string {
56
+ const raw = opts.path ?? DEFAULT_EVENTS_PATH;
57
+ if (isAbsolute(raw)) return raw;
58
+ return resolve(opts.baseDir ?? process.cwd(), raw);
59
+ }
60
+
61
+ /**
62
+ * Stream events from the JSONL log line-by-line. Invalid JSON lines
63
+ * are skipped silently — the writer guarantees well-formed output, so
64
+ * a malformed line is a data-corruption signal that should not crash
65
+ * a tail consumer.
66
+ *
67
+ * Filters apply in this order: type → predicate → since/until. The
68
+ * type filter is the cheapest, so it short-circuits first.
69
+ */
70
+ export async function* readEvents(
71
+ opts: ReadEventsOptions = {},
72
+ ): AsyncIterable<BaseEvent> {
73
+ const path = resolveReadPath(opts);
74
+ if (!existsSync(path)) return;
75
+
76
+ const stream: ReadStream = createReadStream(path, { encoding: 'utf8' });
77
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
78
+
79
+ const typeRe = opts.type instanceof RegExp ? opts.type : null;
80
+ const typeStr = typeof opts.type === 'string' ? opts.type : null;
81
+
82
+ try {
83
+ for await (const line of rl) {
84
+ if (line.trim() === '') continue;
85
+ let ev: BaseEvent;
86
+ try {
87
+ ev = JSON.parse(line) as BaseEvent;
88
+ } catch {
89
+ continue;
90
+ }
91
+ if (typeStr !== null && ev.type !== typeStr) continue;
92
+ if (typeRe !== null && !typeRe.test(ev.type)) continue;
93
+ if (opts.predicate && !opts.predicate(ev)) continue;
94
+ if (opts.since !== undefined && ev.timestamp < opts.since) continue;
95
+ if (opts.until !== undefined && ev.timestamp > opts.until) continue;
96
+ yield ev;
97
+ }
98
+ } finally {
99
+ rl.close();
100
+ stream.close();
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Synchronous aggregator that drains an iterable of events and rolls
106
+ * them up by type / stage / cycle / agent. `agent` is read from
107
+ * `payload.agent` (the trajectory + cost-update + agent.spawn shapes
108
+ * all expose it there).
109
+ */
110
+ export async function aggregate(
111
+ events: AsyncIterable<BaseEvent> | Iterable<BaseEvent>,
112
+ ): Promise<AggregateResult> {
113
+ const result: AggregateResult = {
114
+ byType: {},
115
+ byStage: {},
116
+ byCycle: {},
117
+ byAgent: {},
118
+ totals: { count: 0, error_count: 0, truncated_count: 0 },
119
+ };
120
+
121
+ /** @param {Record<string, number>} bucket @param {string} key */
122
+ const inc = (bucket: Record<string, number>, key: string) => {
123
+ bucket[key] = (bucket[key] ?? 0) + 1;
124
+ };
125
+
126
+ for await (const ev of events as AsyncIterable<BaseEvent>) {
127
+ result.totals.count += 1;
128
+ inc(result.byType, ev.type);
129
+ if (ev.type === 'error') result.totals.error_count += 1;
130
+ if (ev._truncated === true) result.totals.truncated_count += 1;
131
+ if (ev.stage) inc(result.byStage, String(ev.stage));
132
+ if (ev.cycle) inc(result.byCycle, ev.cycle);
133
+ const payload = ev.payload as Record<string, unknown> | undefined;
134
+ if (payload && typeof payload['agent'] === 'string') {
135
+ inc(result.byAgent, payload['agent'] as string);
136
+ }
137
+ }
138
+ return result;
139
+ }