@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,251 @@
1
+ // scripts/lib/logger/index.ts — Plan 21-04 (SDK-16).
2
+ //
3
+ // Public API for the Phase-21 structured logger. Consumers import ONLY
4
+ // from this file; `./types.ts` and `./sinks.ts` are implementation
5
+ // detail but their public types/classes are re-exported here for
6
+ // advanced callers (e.g., a test that wants to build a custom sink
7
+ // stack or a debug harness that wants to introspect levels).
8
+ //
9
+ // Surface:
10
+ // * createLogger(opts?) — build a Logger with auto mode detection
11
+ // * getLogger() — module-level singleton accessor
12
+ // * setLogger(l) — replace the singleton (tests)
13
+ // * resetLogger() — clear the singleton (tests)
14
+ // * Types + sinks re-exports — for advanced consumers / tests
15
+ //
16
+ // Mode detection (in priority order):
17
+ // 1. `opts.headless === true` → headless (JsonlSink)
18
+ // 2. `opts.headless === false` → interactive (ConsoleSink)
19
+ // 3. `process.env.GDD_HEADLESS === '1'` → headless
20
+ // 4. `process.env.GDD_HEADLESS === '0'` → interactive (explicit off wins)
21
+ // 5. `!process.stdout.isTTY` → headless
22
+ // 6. otherwise → interactive
23
+ //
24
+ // Event-stream integration (warn + error only):
25
+ // * `warn(msg, fields)` → `appendEvent({ type: 'error', payload: { level: 'warn', msg, fields } })`
26
+ // * `error(msg, fields)` → same, with level: 'error'.
27
+ // * `debug` and `info` never emit events.
28
+ // * Failures inside appendEvent are swallowed (logged once via
29
+ // process.stderr); the logger contract never propagates event-stream
30
+ // failures back to the caller.
31
+
32
+ import { appendEvent } from '../event-stream/index.ts';
33
+ import { ConsoleSink, JsonlSink, MultiSink } from './sinks.ts';
34
+ import {
35
+ LEVEL_ORDER,
36
+ type LogEntry,
37
+ type LogLevel,
38
+ type Logger,
39
+ type LoggerOptions,
40
+ type Sink,
41
+ } from './types.ts';
42
+
43
+ export type { LogLevel, LoggerOptions, LogEntry, Logger, Sink } from './types.ts';
44
+ export { LEVEL_ORDER } from './types.ts';
45
+ export { ConsoleSink, JsonlSink, MultiSink } from './sinks.ts';
46
+ export type {
47
+ ConsoleSinkOptions,
48
+ JsonlSinkOptions,
49
+ } from './sinks.ts';
50
+ export { DEFAULT_LOG_DIR, safeStringify } from './sinks.ts';
51
+
52
+ /**
53
+ * Decide whether a logger with `opts` should run in headless mode.
54
+ * Explicit `opts.headless` wins; otherwise env; otherwise TTY check.
55
+ */
56
+ function detectHeadless(opts: LoggerOptions): boolean {
57
+ if (opts.headless === true) return true;
58
+ if (opts.headless === false) return false;
59
+ const envFlag = process.env['GDD_HEADLESS'];
60
+ if (envFlag === '1') return true;
61
+ if (envFlag === '0') return false;
62
+ return !process.stdout.isTTY;
63
+ }
64
+
65
+ /** Flag so we only emit the `appendEvent failed` tripwire once per process. */
66
+ let eventStreamFailureLogged = false;
67
+
68
+ /**
69
+ * LoggerImpl is not exported; callers build via `createLogger()` and
70
+ * hold a `Logger` interface reference. Keeping the implementation private
71
+ * lets us add fields (metrics, per-level counters) without breaking
72
+ * consumers.
73
+ */
74
+ class LoggerImpl implements Logger {
75
+ private readonly sink: Sink;
76
+ private readonly minLevel: LogLevel;
77
+ private readonly scope: string | undefined;
78
+ private readonly baseFields: Record<string, unknown>;
79
+ private readonly now: () => string;
80
+ private readonly emitEvents: boolean;
81
+
82
+ constructor(
83
+ sink: Sink,
84
+ minLevel: LogLevel,
85
+ scope: string | undefined,
86
+ baseFields: Record<string, unknown>,
87
+ now: () => string,
88
+ emitEvents: boolean,
89
+ ) {
90
+ this.sink = sink;
91
+ this.minLevel = minLevel;
92
+ this.scope = scope;
93
+ this.baseFields = baseFields;
94
+ this.now = now;
95
+ this.emitEvents = emitEvents;
96
+ }
97
+
98
+ private emit(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
99
+ // Level filter first — short-circuit below the minimum for perf.
100
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel]) return;
101
+
102
+ // Build the entry. Caller fields are merged shallow, then reserved
103
+ // keys are written last so they always override caller input.
104
+ const merged: Record<string, unknown> = { ...this.baseFields, ...(fields ?? {}) };
105
+ const entry: LogEntry = {
106
+ ...merged,
107
+ ts: this.now(),
108
+ level,
109
+ msg,
110
+ pid: process.pid,
111
+ };
112
+ if (this.scope !== undefined) entry.scope = this.scope;
113
+
114
+ // Persist to the sink. Sinks are required not to throw, but we
115
+ // wrap defensively so a buggy custom sink cannot break callers.
116
+ try {
117
+ this.sink.write(entry);
118
+ } catch {
119
+ // Swallow.
120
+ }
121
+
122
+ // Event-stream integration: warn + error emit an ErrorEvent.
123
+ if (this.emitEvents && (level === 'warn' || level === 'error')) {
124
+ try {
125
+ const payloadFields: Record<string, unknown> = { ...(fields ?? {}) };
126
+ appendEvent({
127
+ type: 'error',
128
+ timestamp: entry.ts,
129
+ sessionId: this.scope ?? 'anonymous',
130
+ payload: {
131
+ level,
132
+ msg,
133
+ fields: payloadFields,
134
+ },
135
+ });
136
+ } catch {
137
+ // Event-stream failures must not surface. Print one tripwire
138
+ // so operators notice, then stay silent.
139
+ if (!eventStreamFailureLogged) {
140
+ eventStreamFailureLogged = true;
141
+ try {
142
+ process.stderr.write(
143
+ '[logger] appendEvent failed; subsequent failures will be silent\n',
144
+ );
145
+ } catch {
146
+ // If stderr is also dead, we've done all we can.
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ debug(msg: string, fields?: Record<string, unknown>): void {
154
+ this.emit('debug', msg, fields);
155
+ }
156
+ info(msg: string, fields?: Record<string, unknown>): void {
157
+ this.emit('info', msg, fields);
158
+ }
159
+ warn(msg: string, fields?: Record<string, unknown>): void {
160
+ this.emit('warn', msg, fields);
161
+ }
162
+ error(msg: string, fields?: Record<string, unknown>): void {
163
+ this.emit('error', msg, fields);
164
+ }
165
+
166
+ child(scope: string, fields?: Record<string, unknown>): Logger {
167
+ const childScope = this.scope !== undefined ? `${this.scope}.${scope}` : scope;
168
+ const mergedFields: Record<string, unknown> = {
169
+ ...this.baseFields,
170
+ ...(fields ?? {}),
171
+ };
172
+ return new LoggerImpl(
173
+ this.sink,
174
+ this.minLevel,
175
+ childScope,
176
+ mergedFields,
177
+ this.now,
178
+ this.emitEvents,
179
+ );
180
+ }
181
+
182
+ flush(): void {
183
+ // v1 sinks are synchronous; reserved for future async implementations.
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Build a Logger with auto mode detection. See the module-level
189
+ * mode-detection table.
190
+ *
191
+ * @throws RangeError when `opts.level` is not a member of LogLevel.
192
+ */
193
+ export function createLogger(opts: LoggerOptions = {}): Logger {
194
+ const level = opts.level ?? 'info';
195
+ if (!(level in LEVEL_ORDER)) {
196
+ // Defensive: narrow with a runtime check — TypeScript cannot catch
197
+ // a caller passing `'verbose' as any`. Fail loud at construction
198
+ // rather than silently mis-filter downstream.
199
+ throw new RangeError(
200
+ `Invalid LogLevel: ${String(level)}. Expected one of: debug, info, warn, error.`,
201
+ );
202
+ }
203
+
204
+ const now = opts.nowOverride ?? ((): string => new Date().toISOString());
205
+ const emitEvents = opts.emitEventsOverride === false ? false : true;
206
+ const scope = opts.scope;
207
+
208
+ const headless = detectHeadless(opts);
209
+ const sink: Sink = headless
210
+ ? new JsonlSink({
211
+ ...(opts.logDir !== undefined ? { dir: opts.logDir } : {}),
212
+ ...(opts.nowOverride !== undefined ? { nowOverride: opts.nowOverride } : {}),
213
+ })
214
+ : new ConsoleSink();
215
+
216
+ return new LoggerImpl(sink, level, scope, {}, now, emitEvents);
217
+ }
218
+
219
+ /** Module-level singleton. Built lazily on first `getLogger()` call. */
220
+ let defaultLogger: Logger | null = null;
221
+
222
+ /**
223
+ * Return the module-level default logger, constructing it on first
224
+ * call with default options. Used by modules that don't own a logger
225
+ * explicitly (session-runner, pipeline-runner, parallel-runners in
226
+ * later Phase-21 waves).
227
+ */
228
+ export function getLogger(): Logger {
229
+ if (defaultLogger === null) {
230
+ defaultLogger = createLogger();
231
+ }
232
+ return defaultLogger;
233
+ }
234
+
235
+ /**
236
+ * Replace the module-level default logger. Intended for tests that
237
+ * want to inject a logger with a captured sink; also used by the
238
+ * session-runner boot path to install a logger with its pinned
239
+ * `logDir`/`scope` before any child import calls `getLogger()`.
240
+ */
241
+ export function setLogger(l: Logger): void {
242
+ defaultLogger = l;
243
+ }
244
+
245
+ /**
246
+ * Clear the module-level default logger. Next `getLogger()` rebuilds
247
+ * with current env/TTY state. Primarily a test hook.
248
+ */
249
+ export function resetLogger(): void {
250
+ defaultLogger = null;
251
+ }
@@ -0,0 +1,269 @@
1
+ // scripts/lib/logger/sinks.ts — Plan 21-04 (SDK-16).
2
+ //
3
+ // Sink implementations for the Phase-21 structured logger:
4
+ // * ConsoleSink — pretty stderr output with ANSI colors when TTY.
5
+ // * JsonlSink — crash-safe append-only JSONL to .design/logs/<file>.
6
+ // * MultiSink — fan-out to N sinks (e.g., console + JSONL simultaneously
7
+ // under a test harness).
8
+ //
9
+ // Design rules:
10
+ // * Sinks MUST NOT throw from `.write()`. IO failures are swallowed
11
+ // (they'd otherwise break the caller's happy path). A one-shot
12
+ // `process.stderr.write` of the error is acceptable as a tripwire.
13
+ // * `safeStringify()` walks the entry with a WeakSet for circular
14
+ // detection and replaces non-JSON-serializable values with
15
+ // `"<unserializable: <reason>>"` so a bad caller payload never
16
+ // crashes the process.
17
+ // * File paths encode the ISO timestamp with `:` → `-` (Windows won't
18
+ // allow colons in filenames).
19
+
20
+ import { appendFileSync, mkdirSync } from 'node:fs';
21
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
22
+
23
+ import type { LogEntry, Sink } from './types.ts';
24
+
25
+ /**
26
+ * Default directory for JSONL logs when neither `opts.dir` nor
27
+ * `GDD_LOG_DIR` is set. Resolved relative to `process.cwd()`.
28
+ */
29
+ export const DEFAULT_LOG_DIR = '.design/logs';
30
+
31
+ /**
32
+ * ANSI color codes keyed by log level. Empty string when color disabled.
33
+ */
34
+ const ANSI_RESET = '\u001b[0m';
35
+ const ANSI_BY_LEVEL: Record<LogEntry['level'], string> = {
36
+ debug: '\u001b[90m', // gray
37
+ info: '\u001b[36m', // cyan
38
+ warn: '\u001b[33m', // yellow
39
+ error: '\u001b[31m', // red
40
+ };
41
+
42
+ /**
43
+ * Reserved keys that the logger controls directly. The sinks rely on
44
+ * these being present; extraction helpers skip them when rendering
45
+ * "extra fields".
46
+ */
47
+ const RESERVED_KEYS = new Set(['ts', 'level', 'msg', 'pid', 'scope']);
48
+
49
+ /**
50
+ * Replacer that handles non-JSON-serializable values. Uses a single
51
+ * WeakSet across the whole walk to catch circular refs. Returns
52
+ * `"<unserializable: <reason>>"` for:
53
+ * * circular references (object already seen on the ancestor path)
54
+ * * BigInt values
55
+ * * Function values
56
+ * * anything else that `JSON.stringify` would otherwise throw on.
57
+ */
58
+ function buildSafeReplacer(): (key: string, value: unknown) => unknown {
59
+ const seen = new WeakSet<object>();
60
+ return (_key: string, value: unknown): unknown => {
61
+ if (typeof value === 'bigint') return '<unserializable: bigint>';
62
+ if (typeof value === 'function') return '<unserializable: function>';
63
+ if (typeof value === 'symbol') return '<unserializable: symbol>';
64
+ if (value !== null && typeof value === 'object') {
65
+ if (seen.has(value)) return '<unserializable: circular>';
66
+ seen.add(value);
67
+ }
68
+ return value;
69
+ };
70
+ }
71
+
72
+ /**
73
+ * JSON.stringify that never throws. All unserializable leaves become
74
+ * `<unserializable: ...>` placeholders.
75
+ */
76
+ export function safeStringify(value: unknown): string {
77
+ try {
78
+ return JSON.stringify(value, buildSafeReplacer()) ?? 'null';
79
+ } catch {
80
+ // Extremely pathological cases (e.g., `toJSON` throws after our
81
+ // replacer) still fall through here. Return a tagged sentinel
82
+ // rather than propagate.
83
+ return '"<unserializable: stringify-failed>"';
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Split a LogEntry into (reserved header, caller fields) for rendering.
89
+ * The reserved header controls output ordering; caller fields are
90
+ * rendered as an inline JSON object suffix.
91
+ */
92
+ function splitEntry(entry: LogEntry): {
93
+ header: { ts: string; level: string; msg: string; pid: number; scope?: string };
94
+ extras: Record<string, unknown>;
95
+ } {
96
+ const extras: Record<string, unknown> = {};
97
+ for (const [k, v] of Object.entries(entry)) {
98
+ if (!RESERVED_KEYS.has(k)) extras[k] = v;
99
+ }
100
+ const header: { ts: string; level: string; msg: string; pid: number; scope?: string } = {
101
+ ts: entry.ts,
102
+ level: entry.level,
103
+ msg: entry.msg,
104
+ pid: entry.pid,
105
+ };
106
+ if (entry.scope !== undefined) header.scope = entry.scope;
107
+ return { header, extras };
108
+ }
109
+
110
+ export interface ConsoleSinkOptions {
111
+ /**
112
+ * Enable ANSI color output. When `undefined` (default), autodetected
113
+ * from `process.stderr.isTTY`. Explicit `false` disables coloring;
114
+ * explicit `true` forces it even for non-TTY streams (useful for
115
+ * tests that assert the colorized form).
116
+ */
117
+ color?: boolean;
118
+ /**
119
+ * Write target. Defaults to `process.stderr.write`. Tests inject a
120
+ * capturing function here.
121
+ */
122
+ write?: (chunk: string) => void;
123
+ }
124
+
125
+ /**
126
+ * Pretty-printed stderr sink used in interactive mode. Format:
127
+ * `<ts> [<LEVEL>] <scope?> <msg> <json-fields>`
128
+ * Fields JSON is omitted when the entry has no caller fields.
129
+ */
130
+ export class ConsoleSink implements Sink {
131
+ readonly colorEnabled: boolean;
132
+ private readonly writer: (chunk: string) => void;
133
+
134
+ constructor(opts: ConsoleSinkOptions = {}) {
135
+ this.colorEnabled =
136
+ opts.color !== undefined ? opts.color : Boolean(process.stderr.isTTY);
137
+ this.writer = opts.write ?? ((chunk: string) => {
138
+ process.stderr.write(chunk);
139
+ });
140
+ }
141
+
142
+ write(entry: LogEntry): void {
143
+ const { header, extras } = splitEntry(entry);
144
+ const levelToken = header.level.toUpperCase();
145
+ const coloredLevel = this.colorEnabled
146
+ ? `${ANSI_BY_LEVEL[entry.level]}${levelToken}${ANSI_RESET}`
147
+ : levelToken;
148
+ const scopePart = header.scope !== undefined ? ` ${header.scope}` : '';
149
+ const extrasKeys = Object.keys(extras);
150
+ const fieldsPart = extrasKeys.length > 0 ? ` ${safeStringify(extras)}` : '';
151
+ const line = `${header.ts} [${coloredLevel}]${scopePart} ${header.msg}${fieldsPart}\n`;
152
+ try {
153
+ this.writer(line);
154
+ } catch {
155
+ // Swallow: writing to stderr failed (very rare — detached stdio,
156
+ // pipe closed). We intentionally do nothing to honor the "sinks
157
+ // never throw" contract.
158
+ }
159
+ }
160
+
161
+ close(): void {
162
+ // No fd to release; stderr is process-owned.
163
+ }
164
+ }
165
+
166
+ export interface JsonlSinkOptions {
167
+ /**
168
+ * Output directory. Resolved relative to `process.cwd()` when relative.
169
+ * Priority: `opts.dir` → `process.env.GDD_LOG_DIR` → `DEFAULT_LOG_DIR`.
170
+ */
171
+ dir?: string;
172
+ /**
173
+ * Override the ISO timestamp used when composing the filename. Only
174
+ * affects the filename itself — entry timestamps come from the Logger.
175
+ * Allows tests to produce a deterministic file path.
176
+ */
177
+ nowOverride?: () => string;
178
+ /**
179
+ * Override pid used in the filename suffix. Tests use this to pin
180
+ * the filename when `process.pid` would otherwise vary.
181
+ */
182
+ pidOverride?: number;
183
+ }
184
+
185
+ /**
186
+ * Append-only JSONL sink. One entry per line, UTF-8, newline-terminated.
187
+ * File path: `<dir>/<ISO-with-dashes>-<pid>.jsonl`.
188
+ *
189
+ * Uses `appendFileSync` on every write — crash-safe (kernel-level atomic
190
+ * append for small writes), no in-memory buffering. Writes are sync so
191
+ * the logger caller can return immediately; async buffering would
192
+ * complicate ordering guarantees under concurrent pipeline stages.
193
+ */
194
+ export class JsonlSink implements Sink {
195
+ readonly path: string;
196
+ private dirCreated = false;
197
+
198
+ constructor(opts: JsonlSinkOptions = {}) {
199
+ const dirOpt =
200
+ opts.dir ?? process.env['GDD_LOG_DIR'] ?? DEFAULT_LOG_DIR;
201
+ const dir = isAbsolute(dirOpt) ? dirOpt : resolve(process.cwd(), dirOpt);
202
+ const iso = opts.nowOverride ? opts.nowOverride() : new Date().toISOString();
203
+ const pid = opts.pidOverride ?? process.pid;
204
+ // Replace colons (invalid on Windows filesystems) with dashes.
205
+ const safeIso = iso.replace(/:/g, '-');
206
+ this.path = join(dir, `${safeIso}-${pid}.jsonl`);
207
+ }
208
+
209
+ private ensureDir(): void {
210
+ if (this.dirCreated) return;
211
+ try {
212
+ mkdirSync(dirname(this.path), { recursive: true });
213
+ this.dirCreated = true;
214
+ } catch {
215
+ // If mkdir fails (e.g., permission denied), subsequent appendFileSync
216
+ // will also fail and we'll swallow there. Keep dirCreated=false so
217
+ // a later write attempts mkdir again (the condition may clear).
218
+ }
219
+ }
220
+
221
+ write(entry: LogEntry): void {
222
+ this.ensureDir();
223
+ const line = `${safeStringify(entry)}\n`;
224
+ try {
225
+ appendFileSync(this.path, line, { encoding: 'utf8' });
226
+ } catch {
227
+ // Swallow. The logger contract forbids throwing from sinks.
228
+ // A sustained IO failure is visible via missing log file.
229
+ }
230
+ }
231
+
232
+ close(): void {
233
+ // `appendFileSync` opens+closes per call; nothing to release.
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Fan-out sink: every `.write()` is forwarded to every child sink.
239
+ * Construction validates that the input is a non-null array; null/undefined
240
+ * entries are rejected defensively (they'd NPE on write otherwise).
241
+ */
242
+ export class MultiSink implements Sink {
243
+ private readonly sinks: readonly Sink[];
244
+
245
+ constructor(sinks: readonly Sink[]) {
246
+ this.sinks = sinks.filter((s): s is Sink => s !== null && s !== undefined);
247
+ }
248
+
249
+ write(entry: LogEntry): void {
250
+ for (const s of this.sinks) {
251
+ try {
252
+ s.write(entry);
253
+ } catch {
254
+ // Defense in depth: child sinks shouldn't throw, but if one does,
255
+ // it must not block the others. Swallow per-sink.
256
+ }
257
+ }
258
+ }
259
+
260
+ close(): void {
261
+ for (const s of this.sinks) {
262
+ try {
263
+ s.close();
264
+ } catch {
265
+ // Same defense — close of one sink must not block another.
266
+ }
267
+ }
268
+ }
269
+ }
@@ -0,0 +1,110 @@
1
+ // scripts/lib/logger/types.ts — Plan 21-04 (SDK-16).
2
+ //
3
+ // Public type surface for the structured logger. Consumers import from
4
+ // `./index.ts` (the module barrel); this file exists to keep the type
5
+ // graph clean for `./sinks.ts` and `./index.ts` which both depend on the
6
+ // same `LogEntry`/`Sink`/`Logger` contracts.
7
+ //
8
+ // Contracts:
9
+ // * LogLevel — closed union of the four levels supported in v1.
10
+ // * LEVEL_ORDER — numeric map used for min-level filtering. Higher
11
+ // numbers are more severe. Frozen so callers cannot mutate.
12
+ // * LoggerOptions — construction-time options. All fields optional so
13
+ // `createLogger()` can be called with zero args.
14
+ // * LogEntry — the on-wire shape (both JSONL and console). `ts`,
15
+ // `level`, `msg`, `pid` are reserved keys: the logger overwrites
16
+ // caller fields of the same name to preserve the guarantee that a
17
+ // JSONL line always has a valid timestamp/level/msg/pid.
18
+ // * Logger — the public API: 4 level methods, `child()`, `flush()`.
19
+ // * Sink — the sink interface used by ConsoleSink/JsonlSink/MultiSink
20
+ // in `./sinks.ts`.
21
+
22
+ /** Closed set of log levels. */
23
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
24
+
25
+ /**
26
+ * Numeric ordering for level filtering. Higher = more severe.
27
+ * `LEVEL_ORDER[entry.level] >= LEVEL_ORDER[opts.level]` → emit; otherwise drop.
28
+ * Frozen to prevent accidental mutation by consumers.
29
+ */
30
+ export const LEVEL_ORDER: Readonly<Record<LogLevel, number>> = Object.freeze({
31
+ debug: 10,
32
+ info: 20,
33
+ warn: 30,
34
+ error: 40,
35
+ });
36
+
37
+ export interface LoggerOptions {
38
+ /** Minimum level to emit. Levels below this are dropped. Default: `'info'`. */
39
+ level?: LogLevel;
40
+ /**
41
+ * Force headless mode regardless of env / TTY detection. When `undefined`
42
+ * (the default), mode is auto-detected: `GDD_HEADLESS=1` OR
43
+ * `!process.stdout.isTTY` → headless. `GDD_HEADLESS=0` pins interactive.
44
+ */
45
+ headless?: boolean;
46
+ /**
47
+ * JSONL output directory (headless mode only). Resolved at construction
48
+ * time; falls back to `process.env.GDD_LOG_DIR` then `'.design/logs'`.
49
+ */
50
+ logDir?: string;
51
+ /**
52
+ * Module / subsystem tag merged into every entry (e.g., `'session-runner'`).
53
+ * `child(scope)` concatenates onto this dot-joined.
54
+ */
55
+ scope?: string;
56
+ /**
57
+ * Test-only override: replaces the `new Date().toISOString()` source so
58
+ * test fixtures can be byte-identical across runs.
59
+ */
60
+ nowOverride?: () => string;
61
+ /**
62
+ * Test-only override: when explicitly `false`, `warn`/`error` do NOT
63
+ * emit an `ErrorEvent` via event-stream. Any other value behaves as
64
+ * "emit normally" (the default).
65
+ */
66
+ emitEventsOverride?: false;
67
+ }
68
+
69
+ /**
70
+ * Shape written to JSONL sinks and passed to ConsoleSink.write().
71
+ * Reserved keys (`ts`, `level`, `msg`, `pid`) are always present; caller
72
+ * fields are merged shallow but never override reserved keys.
73
+ */
74
+ export interface LogEntry {
75
+ ts: string;
76
+ level: LogLevel;
77
+ msg: string;
78
+ pid: number;
79
+ scope?: string;
80
+ [field: string]: unknown;
81
+ }
82
+
83
+ export interface Logger {
84
+ debug(msg: string, fields?: Record<string, unknown>): void;
85
+ info(msg: string, fields?: Record<string, unknown>): void;
86
+ warn(msg: string, fields?: Record<string, unknown>): void;
87
+ error(msg: string, fields?: Record<string, unknown>): void;
88
+ /**
89
+ * Returns a child logger inheriting options, with additional scope
90
+ * appended (dot-joined if a parent scope exists) and additional fields
91
+ * merged into every entry. Child loggers share the parent's sink(s).
92
+ */
93
+ child(scope: string, fields?: Record<string, unknown>): Logger;
94
+ /**
95
+ * Flush buffered writes. JSONL and Console sinks in v1 are synchronous,
96
+ * so this is a no-op — reserved for async sinks in a future wave.
97
+ */
98
+ flush(): void;
99
+ }
100
+
101
+ /**
102
+ * Sink interface implemented by ConsoleSink/JsonlSink/MultiSink in
103
+ * `./sinks.ts`. Logger.write() is expected to never throw; sinks SHOULD
104
+ * swallow IO errors and either retry or drop the line rather than
105
+ * surface failures to the caller.
106
+ */
107
+ export interface Sink {
108
+ write(entry: LogEntry): void;
109
+ close(): void;
110
+ }