@hegemonart/get-design-done 1.21.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.
@@ -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
@@ -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
+ }
@@ -112,6 +112,112 @@ export type ErrorEvent = BaseEvent & {
112
112
  payload: { code: string; message: string; kind: string };
113
113
  };
114
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // Phase 22 — pre-registered subtypes expansion (Plan 22-01)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Wave orchestration — Plan 21 parallel-mapper / wave execution. */
120
+ export type WaveStartedEvent = BaseEvent & {
121
+ type: 'wave.started';
122
+ payload: { wave: string; plan_count: number };
123
+ };
124
+ export type WaveCompletedEvent = BaseEvent & {
125
+ type: 'wave.completed';
126
+ payload: { wave: string; duration_ms: number; outcome: 'pass' | 'fail' };
127
+ };
128
+
129
+ /** STATE.md mutation lifecycle (Plan 20-03). */
130
+ export type BlockerAddedEvent = BaseEvent & {
131
+ type: 'blocker.added';
132
+ payload: { id: string; summary: string; source: string };
133
+ };
134
+ export type DecisionAddedEvent = BaseEvent & {
135
+ type: 'decision.added';
136
+ payload: { id: string; summary: string; source: string };
137
+ };
138
+ export type MustHaveAddedEvent = BaseEvent & {
139
+ type: 'must_have.added';
140
+ payload: { id: string; summary: string; source: string };
141
+ };
142
+
143
+ /** Parallelism decision engine output — Plan 21 explore-parallel-runner. */
144
+ export type ParallelismVerdictEvent = BaseEvent & {
145
+ type: 'parallelism.verdict';
146
+ payload: { task_ids: string[]; verdict: 'parallel' | 'sequential'; reason: string };
147
+ };
148
+
149
+ /** Phase 10.1 cost-telemetry event-stream sink. */
150
+ export type CostUpdateEvent = BaseEvent & {
151
+ type: 'cost.update';
152
+ payload: { agent: string; tier: string; usd: number; tokens_in: number; tokens_out: number };
153
+ };
154
+
155
+ /** Rate-guard / backoff stream (Plan 20-10, 20-11). */
156
+ export type RateLimitEvent = BaseEvent & {
157
+ type: 'rate_limit';
158
+ payload: { provider: string; reset_at: string; remaining: number };
159
+ };
160
+ export type ApiRetryEvent = BaseEvent & {
161
+ type: 'api.retry';
162
+ payload: { provider: string; attempt: number; delay_ms: number; reason: string };
163
+ };
164
+
165
+ /** Context-window churn; emitted by `hooks/context-exhaustion.ts`. */
166
+ export type CompactBoundaryEvent = BaseEvent & {
167
+ type: 'compact.boundary';
168
+ payload: { tokens_before: number; tokens_after: number };
169
+ };
170
+
171
+ /** MCP liveness probe from connection-probe primitive (Plan 22-08). */
172
+ export type McpProbeEvent = BaseEvent & {
173
+ type: 'mcp.probe';
174
+ payload: { name: string; status: 'ok' | 'degraded' | 'down'; latency_ms?: number };
175
+ };
176
+
177
+ /** Reflector proposal (Phase 11 post-cycle reflector → event stream). */
178
+ export type ReflectionProposedEvent = BaseEvent & {
179
+ type: 'reflection.proposed';
180
+ payload: { kind: string; target_file: string; summary: string };
181
+ };
182
+
183
+ /** Connection state transitions emitted by `connection-probe` (Plan 22-08). */
184
+ export type ConnectionStatusChangeEvent = BaseEvent & {
185
+ type: 'connection.status_change';
186
+ payload: { name: string; from: string; to: string };
187
+ };
188
+
189
+ /** Per-tool-call trajectory (Plan 22-03). */
190
+ export type ToolCallStartedEvent = BaseEvent & {
191
+ type: 'tool_call.started';
192
+ payload: { tool: string; args_hash: string };
193
+ };
194
+ export type ToolCallCompletedEvent = BaseEvent & {
195
+ type: 'tool_call.completed';
196
+ payload: {
197
+ tool: string;
198
+ args_hash: string;
199
+ result_hash: string;
200
+ latency_ms: number;
201
+ status: 'ok' | 'error';
202
+ };
203
+ };
204
+
205
+ /** Agent-level lifecycle (Plan 21 pipeline-runner / subagent spawn). */
206
+ export type AgentSpawnEvent = BaseEvent & {
207
+ type: 'agent.spawn';
208
+ payload: { agent: string; task_id?: string; tier?: string };
209
+ };
210
+ export type AgentOutcomeEvent = BaseEvent & {
211
+ type: 'agent.outcome';
212
+ payload: {
213
+ agent: string;
214
+ task_id?: string;
215
+ outcome: 'pass' | 'fail' | 'halted';
216
+ duration_ms: number;
217
+ cost_usd?: number;
218
+ };
219
+ };
220
+
115
221
  /**
116
222
  * Union of all pre-registered event types. Not a closed enum at the
117
223
  * envelope level — callers can emit unknown types — but downstream
@@ -124,4 +230,52 @@ export type KnownEvent =
124
230
  | StageEnteredEvent
125
231
  | StageExitedEvent
126
232
  | HookFiredEvent
127
- | ErrorEvent;
233
+ | ErrorEvent
234
+ | WaveStartedEvent
235
+ | WaveCompletedEvent
236
+ | BlockerAddedEvent
237
+ | DecisionAddedEvent
238
+ | MustHaveAddedEvent
239
+ | ParallelismVerdictEvent
240
+ | CostUpdateEvent
241
+ | RateLimitEvent
242
+ | ApiRetryEvent
243
+ | CompactBoundaryEvent
244
+ | McpProbeEvent
245
+ | ReflectionProposedEvent
246
+ | ConnectionStatusChangeEvent
247
+ | ToolCallStartedEvent
248
+ | ToolCallCompletedEvent
249
+ | AgentSpawnEvent
250
+ | AgentOutcomeEvent;
251
+
252
+ /**
253
+ * Runtime list of all pre-registered event `type` strings. Used by the
254
+ * Phase 22 baseline test and the CLI transport's `--list-types`
255
+ * subcommand.
256
+ */
257
+ export const KNOWN_EVENT_TYPES: readonly string[] = [
258
+ 'state.mutation',
259
+ 'state.transition',
260
+ 'stage.entered',
261
+ 'stage.exited',
262
+ 'hook.fired',
263
+ 'error',
264
+ 'wave.started',
265
+ 'wave.completed',
266
+ 'blocker.added',
267
+ 'decision.added',
268
+ 'must_have.added',
269
+ 'parallelism.verdict',
270
+ 'cost.update',
271
+ 'rate_limit',
272
+ 'api.retry',
273
+ 'compact.boundary',
274
+ 'mcp.probe',
275
+ 'reflection.proposed',
276
+ 'connection.status_change',
277
+ 'tool_call.started',
278
+ 'tool_call.completed',
279
+ 'agent.spawn',
280
+ 'agent.outcome',
281
+ ] as const;
@@ -21,11 +21,64 @@
21
21
  // `payload` with `{ _truncated_placeholder: true }`, then re-serialize
22
22
  // and stamp `_truncated: true` on the line.
23
23
 
24
- import { appendFileSync, mkdirSync } from 'node:fs';
24
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
25
25
  import { dirname, resolve, isAbsolute, join } from 'node:path';
26
+ import { createRequire } from 'node:module';
26
27
 
27
28
  import type { BaseEvent } from './types.ts';
28
29
 
30
+ // Phase 22 Plan 22-02: write-time secret scrubbing. `redact()` deep-walks
31
+ // the event and replaces secret-shaped strings with `[REDACTED:<type>]`
32
+ // placeholders before serialization. Loaded via createRequire so the
33
+ // CommonJS `redact.cjs` interops cleanly. We avoid `import.meta.url` —
34
+ // tsc's Node16 module mode classifies this .ts file as CJS output for
35
+ // typecheck purposes (even though it runs as ESM under
36
+ // `--experimental-strip-types`), and `import.meta` is forbidden in CJS
37
+ // output. Mirror the pattern from `scripts/lib/session-runner/errors.ts`:
38
+ // anchor createRequire on the repo-root package.json discovered by
39
+ // walking up from `process.cwd()`.
40
+ function _findRepoRoot(): string {
41
+ let dir = process.cwd();
42
+ for (let i = 0; i < 8; i++) {
43
+ if (existsSync(join(dir, 'package.json'))) return dir;
44
+ const parent = dirname(dir);
45
+ if (parent === dir) break;
46
+ dir = parent;
47
+ }
48
+ return process.cwd();
49
+ }
50
+
51
+ // Soft load: if redact.cjs is unreachable from the runtime cwd (e.g. a
52
+ // hook subprocess running in a temp test dir three directories above
53
+ // the plugin root), fall through to the identity function. The writer
54
+ // keeps working — events just aren't scrubbed in that environment.
55
+ // Production callers always run from inside the plugin tree.
56
+ let _redact: (v: unknown) => unknown;
57
+ try {
58
+ const _root = _findRepoRoot();
59
+ const _candidate = resolve(_root, 'scripts/lib/redact.cjs');
60
+ if (existsSync(_candidate)) {
61
+ const _redactRequire = createRequire(join(_root, 'package.json'));
62
+ const _mod = _redactRequire(_candidate) as { redact: (v: unknown) => unknown };
63
+ _redact = _mod.redact;
64
+ } else {
65
+ // Fallback: also try walking up from this source file's logical
66
+ // position (3 dirs above writer.ts → repo root).
67
+ const _altRoot = resolve(_root, '..', '..');
68
+ const _altCandidate = resolve(_altRoot, 'scripts/lib/redact.cjs');
69
+ if (existsSync(_altCandidate)) {
70
+ const _altRequire = createRequire(join(_altRoot, 'package.json'));
71
+ const _altMod = _altRequire(_altCandidate) as { redact: (v: unknown) => unknown };
72
+ _redact = _altMod.redact;
73
+ } else {
74
+ _redact = (v) => v;
75
+ }
76
+ }
77
+ } catch {
78
+ _redact = (v) => v;
79
+ }
80
+ const redact = _redact;
81
+
29
82
  /** Default relative path for the persisted event stream. */
30
83
  export const DEFAULT_EVENTS_PATH = '.design/telemetry/events.jsonl';
31
84
 
@@ -113,22 +166,26 @@ export class EventWriter {
113
166
  * {@link append}.
114
167
  */
115
168
  serialize(ev: BaseEvent): string {
116
- const raw = JSON.stringify(ev) + '\n';
169
+ // Phase 22 Plan 22-02: scrub secrets from the entire event (envelope +
170
+ // payload) before serialization. Redaction is non-mutating and runs
171
+ // exactly once per event, here at the write boundary.
172
+ const scrubbed = redact(ev) as BaseEvent;
173
+ const raw = JSON.stringify(scrubbed) + '\n';
117
174
  if (Buffer.byteLength(raw, 'utf8') <= this.maxLineBytes) {
118
175
  return raw;
119
176
  }
120
177
 
121
178
  // Truncate: keep envelope fields, drop payload content.
122
179
  const truncated: BaseEvent = {
123
- type: ev.type,
124
- timestamp: ev.timestamp,
125
- sessionId: ev.sessionId,
180
+ type: scrubbed.type,
181
+ timestamp: scrubbed.timestamp,
182
+ sessionId: scrubbed.sessionId,
126
183
  payload: { _truncated_placeholder: true },
127
184
  _truncated: true,
128
185
  };
129
- if (ev.stage !== undefined) truncated.stage = ev.stage;
130
- if (ev.cycle !== undefined) truncated.cycle = ev.cycle;
131
- if (ev._meta !== undefined) truncated._meta = ev._meta;
186
+ if (scrubbed.stage !== undefined) truncated.stage = scrubbed.stage;
187
+ if (scrubbed.cycle !== undefined) truncated.cycle = scrubbed.cycle;
188
+ if (scrubbed._meta !== undefined) truncated._meta = scrubbed._meta;
132
189
  return JSON.stringify(truncated) + '\n';
133
190
  }
134
191