@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.
- package/.claude-plugin/marketplace.json +9 -12
- package/.claude-plugin/plugin.json +8 -31
- package/CHANGELOG.md +200 -0
- package/README.md +48 -7
- package/bin/gdd-sdk +55 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +19 -47
- package/reference/codex-tools.md +53 -0
- package/reference/gemini-tools.md +53 -0
- package/reference/registry.json +14 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/e2e/run-headless.ts +514 -0
- package/scripts/lib/cli/commands/audit.ts +382 -0
- package/scripts/lib/cli/commands/init.ts +217 -0
- package/scripts/lib/cli/commands/query.ts +329 -0
- package/scripts/lib/cli/commands/run.ts +656 -0
- package/scripts/lib/cli/commands/stage.ts +468 -0
- package/scripts/lib/cli/index.ts +167 -0
- package/scripts/lib/cli/parse-args.ts +336 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/context-engine/index.ts +116 -0
- package/scripts/lib/context-engine/manifest.ts +69 -0
- package/scripts/lib/context-engine/truncate.ts +282 -0
- package/scripts/lib/context-engine/types.ts +59 -0
- package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
- package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
- package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +31 -1
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/explore-parallel-runner/index.ts +294 -0
- package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
- package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
- package/scripts/lib/explore-parallel-runner/types.ts +139 -0
- package/scripts/lib/harness/detect.ts +90 -0
- package/scripts/lib/harness/index.ts +64 -0
- package/scripts/lib/harness/tool-map.ts +142 -0
- package/scripts/lib/init-runner/index.ts +396 -0
- package/scripts/lib/init-runner/researchers.ts +245 -0
- package/scripts/lib/init-runner/scaffold.ts +224 -0
- package/scripts/lib/init-runner/synthesizer.ts +224 -0
- package/scripts/lib/init-runner/types.ts +143 -0
- package/scripts/lib/logger/index.ts +251 -0
- package/scripts/lib/logger/sinks.ts +269 -0
- package/scripts/lib/logger/types.ts +110 -0
- package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
- package/scripts/lib/pipeline-runner/index.ts +527 -0
- package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
- package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
- package/scripts/lib/pipeline-runner/types.ts +183 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/session-runner/errors.ts +406 -0
- package/scripts/lib/session-runner/index.ts +715 -0
- package/scripts/lib/session-runner/transcript.ts +189 -0
- package/scripts/lib/session-runner/types.ts +144 -0
- package/scripts/lib/tool-scoping/index.ts +219 -0
- package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
- package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
- package/scripts/lib/tool-scoping/types.ts +77 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- 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
|
+
}
|