@hegemonart/get-design-done 1.19.6 → 1.20.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +60 -0
  4. package/README.md +12 -0
  5. package/agents/design-reflector.md +13 -0
  6. package/connections/connections.md +3 -0
  7. package/connections/figma.md +2 -0
  8. package/connections/gdd-state.md +186 -0
  9. package/hooks/budget-enforcer.ts +716 -0
  10. package/hooks/context-exhaustion.ts +251 -0
  11. package/hooks/gdd-read-injection-scanner.ts +172 -0
  12. package/hooks/hooks.json +3 -3
  13. package/package.json +19 -6
  14. package/reference/config-schema.md +2 -2
  15. package/reference/error-recovery.md +58 -0
  16. package/reference/registry.json +7 -0
  17. package/reference/schemas/budget.schema.json +42 -0
  18. package/reference/schemas/events.schema.json +55 -0
  19. package/reference/schemas/generated.d.ts +419 -0
  20. package/reference/schemas/iteration-budget.schema.json +36 -0
  21. package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
  22. package/reference/schemas/rate-limits.schema.json +31 -0
  23. package/scripts/aggregate-agent-metrics.ts +282 -0
  24. package/scripts/codegen-schema-types.ts +149 -0
  25. package/scripts/lib/error-classifier.cjs +232 -0
  26. package/scripts/lib/error-classifier.d.cts +44 -0
  27. package/scripts/lib/event-stream/emitter.ts +88 -0
  28. package/scripts/lib/event-stream/index.ts +154 -0
  29. package/scripts/lib/event-stream/types.ts +127 -0
  30. package/scripts/lib/event-stream/writer.ts +154 -0
  31. package/scripts/lib/gdd-errors/classification.ts +124 -0
  32. package/scripts/lib/gdd-errors/index.ts +218 -0
  33. package/scripts/lib/gdd-state/gates.ts +216 -0
  34. package/scripts/lib/gdd-state/index.ts +167 -0
  35. package/scripts/lib/gdd-state/lockfile.ts +232 -0
  36. package/scripts/lib/gdd-state/mutator.ts +574 -0
  37. package/scripts/lib/gdd-state/parser.ts +523 -0
  38. package/scripts/lib/gdd-state/types.ts +179 -0
  39. package/scripts/lib/iteration-budget.cjs +205 -0
  40. package/scripts/lib/iteration-budget.d.cts +32 -0
  41. package/scripts/lib/jittered-backoff.cjs +112 -0
  42. package/scripts/lib/jittered-backoff.d.cts +38 -0
  43. package/scripts/lib/lockfile.cjs +177 -0
  44. package/scripts/lib/lockfile.d.cts +21 -0
  45. package/scripts/lib/prompt-sanitizer/index.ts +435 -0
  46. package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
  47. package/scripts/lib/rate-guard.cjs +365 -0
  48. package/scripts/lib/rate-guard.d.cts +38 -0
  49. package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
  50. package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
  51. package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
  52. package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
  53. package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
  54. package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
  55. package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
  56. package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
  57. package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
  58. package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
  59. package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
  60. package/scripts/mcp-servers/gdd-state/server.ts +288 -0
  61. package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
  62. package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
  63. package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
  64. package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
  65. package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
  66. package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
  67. package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
  68. package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
  69. package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
  70. package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
  71. package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
  72. package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
  73. package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
  74. package/scripts/validate-frontmatter.ts +114 -0
  75. package/scripts/validate-schemas.ts +401 -0
  76. package/skills/brief/SKILL.md +15 -6
  77. package/skills/design/SKILL.md +31 -13
  78. package/skills/explore/SKILL.md +41 -17
  79. package/skills/health/SKILL.md +15 -4
  80. package/skills/optimize/SKILL.md +3 -3
  81. package/skills/pause/SKILL.md +16 -10
  82. package/skills/plan/SKILL.md +33 -17
  83. package/skills/progress/SKILL.md +15 -11
  84. package/skills/resume/SKILL.md +19 -10
  85. package/skills/settings/SKILL.md +11 -3
  86. package/skills/todo/SKILL.md +12 -3
  87. package/skills/verify/SKILL.md +65 -29
  88. package/hooks/budget-enforcer.js +0 -329
  89. package/hooks/context-exhaustion.js +0 -127
  90. package/hooks/gdd-read-injection-scanner.js +0 -39
  91. package/scripts/aggregate-agent-metrics.js +0 -173
  92. package/scripts/validate-frontmatter.cjs +0 -68
  93. package/scripts/validate-schemas.cjs +0 -242
@@ -0,0 +1,44 @@
1
+ // scripts/lib/error-classifier.d.cts — types for error-classifier.cjs.
2
+
3
+ /** Recovery-action categories. Values are stable strings — safe to log. */
4
+ export type FailoverReasonValue =
5
+ | 'rate_limited'
6
+ | 'context_overflow'
7
+ | 'auth_error'
8
+ | 'network_transient'
9
+ | 'network_permanent'
10
+ | 'tool_not_found'
11
+ | 'validation'
12
+ | 'unknown';
13
+
14
+ /** String-constant map of FailoverReason names to values. */
15
+ export const FailoverReason: {
16
+ readonly RATE_LIMITED: 'rate_limited';
17
+ readonly CONTEXT_OVERFLOW: 'context_overflow';
18
+ readonly AUTH_ERROR: 'auth_error';
19
+ readonly NETWORK_TRANSIENT: 'network_transient';
20
+ readonly NETWORK_PERMANENT: 'network_permanent';
21
+ readonly TOOL_NOT_FOUND: 'tool_not_found';
22
+ readonly VALIDATION: 'validation';
23
+ readonly UNKNOWN: 'unknown';
24
+ };
25
+
26
+ /** Result of {@link classify}. */
27
+ export interface ClassifiedError {
28
+ reason: FailoverReasonValue;
29
+ retryable: boolean;
30
+ suggestedAction: string;
31
+ raw: unknown;
32
+ }
33
+
34
+ /**
35
+ * Map a raw error value onto a stable recovery-action category.
36
+ * Tolerant of null/undefined/non-Error inputs.
37
+ */
38
+ export function classify(err: unknown): ClassifiedError;
39
+
40
+ /** Keyed suggested-action strings (read-only). */
41
+ export const SUGGESTED_ACTIONS: Readonly<Record<FailoverReasonValue, string>>;
42
+
43
+ /** Which reasons are considered safe to retry by policy. */
44
+ export const RETRYABLE: Readonly<Record<FailoverReasonValue, boolean>>;
@@ -0,0 +1,88 @@
1
+ // scripts/lib/event-stream/emitter.ts — in-process pub-sub for Phase 20+
2
+ // telemetry events (Plan 20-06, SDK-08).
3
+ //
4
+ // Thin wrapper around Node's built-in `EventEmitter`. Two additions over
5
+ // the raw primitive:
6
+ //
7
+ // 1. `subscribe(type, handler)` returns an unsubscribe closure — the
8
+ // standard `on()` / `off()` dance is easy to forget, and our
9
+ // subscribers (hook consumers, Phase 22 transports) need tidy
10
+ // lifecycle management.
11
+ //
12
+ // 2. `subscribeAll(handler)` lets observability infra (dashboard,
13
+ // log transport) see every event without enumerating known types.
14
+ // We re-emit on a dedicated `'*'` channel so listeners on that
15
+ // channel observe every `emit()`.
16
+ //
17
+ // Replay semantics:
18
+ // The bus is live-only. Subscribing does NOT deliver historical
19
+ // events from `events.jsonl` — that's a Phase 22 transport concern.
20
+
21
+ import { EventEmitter } from 'node:events';
22
+
23
+ import type { BaseEvent } from './types.ts';
24
+
25
+ /**
26
+ * Default max listeners raised above Node's 10-listener default. Mapper
27
+ * parallelism + multiple hook consumers + a dashboard transport can
28
+ * easily stack above 10; 50 is conservative headroom before Node warns.
29
+ */
30
+ export const DEFAULT_MAX_LISTENERS = 50;
31
+
32
+ /**
33
+ * Typed handler for a specific event subtype. `T extends BaseEvent`
34
+ * means callers can narrow via `subscribe<StateMutationEvent>(…)` and
35
+ * the handler sees the narrowed shape.
36
+ */
37
+ export type EventHandler<T extends BaseEvent = BaseEvent> = (ev: T) => void;
38
+
39
+ /** Unsubscribe closure returned from `subscribe` / `subscribeAll`. */
40
+ export type Unsubscribe = () => void;
41
+
42
+ /**
43
+ * In-process event bus. Extends `EventEmitter` so raw consumers can
44
+ * still call `on()` / `off()` if they need Node-native semantics, but
45
+ * prefer `subscribe` / `subscribeAll` for ergonomic cleanup.
46
+ */
47
+ export class EventBus extends EventEmitter {
48
+ constructor() {
49
+ super();
50
+ this.setMaxListeners(DEFAULT_MAX_LISTENERS);
51
+ }
52
+
53
+ /**
54
+ * Subscribe to one specific event type. The handler fires for every
55
+ * subsequent `emit(type, ev)` call where `ev.type === type`. Returns
56
+ * a closure that detaches the listener on invocation.
57
+ *
58
+ * @example
59
+ * const off = bus.subscribe<StateMutationEvent>('state.mutation', (ev) => {
60
+ * console.log(ev.payload.tool);
61
+ * });
62
+ * // …later
63
+ * off();
64
+ */
65
+ subscribe<T extends BaseEvent = BaseEvent>(
66
+ type: T['type'],
67
+ handler: EventHandler<T>,
68
+ ): Unsubscribe {
69
+ const listener = handler as unknown as (...args: unknown[]) => void;
70
+ this.on(type, listener);
71
+ return () => {
72
+ this.off(type, listener);
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Subscribe to *every* event regardless of type. Listeners registered
78
+ * here fire on the special `'*'` channel, which `appendEvent()`
79
+ * re-emits to on every event. Returns an unsubscribe closure.
80
+ */
81
+ subscribeAll(handler: EventHandler<BaseEvent>): Unsubscribe {
82
+ const listener = handler as unknown as (...args: unknown[]) => void;
83
+ this.on('*', listener);
84
+ return () => {
85
+ this.off('*', listener);
86
+ };
87
+ }
88
+ }
@@ -0,0 +1,154 @@
1
+ // scripts/lib/event-stream/index.ts — public API for the Phase 20+
2
+ // telemetry stream (Plan 20-06, SDK-08).
3
+ //
4
+ // Consumers import ONLY from this file. The internal parts
5
+ // (`./types.ts`, `./writer.ts`, `./emitter.ts`) are implementation
6
+ // detail; changing them without updating this export surface is a
7
+ // breaking change for downstream plans (20-05 MCP handlers, 20-13 hooks).
8
+ //
9
+ // Surface:
10
+ // * appendEvent(ev) — persist + broadcast one event
11
+ // * getWriter(opts?) — lazy singleton EventWriter
12
+ // * getBus() — lazy singleton EventBus
13
+ // * reset() — clear module-level singletons (tests)
14
+ // * types — BaseEvent, KnownEvent, and every pre-
15
+ // registered subtype (StateMutationEvent,
16
+ // StateTransitionEvent, …).
17
+
18
+ import { hostname } from 'node:os';
19
+
20
+ import { EventBus } from './emitter.ts';
21
+ import type { Unsubscribe, EventHandler } from './emitter.ts';
22
+ import { EventWriter } from './writer.ts';
23
+ import type { WriterOptions } from './writer.ts';
24
+ import type { BaseEvent, EventMeta } from './types.ts';
25
+
26
+ export type {
27
+ BaseEvent,
28
+ EventMeta,
29
+ KnownEvent,
30
+ StateMutationEvent,
31
+ StateTransitionEvent,
32
+ StageEnteredEvent,
33
+ StageExitedEvent,
34
+ HookFiredEvent,
35
+ ErrorEvent,
36
+ } from './types.ts';
37
+ export { EventBus } from './emitter.ts';
38
+ export type { EventHandler, Unsubscribe } from './emitter.ts';
39
+ export { EventWriter, DEFAULT_EVENTS_PATH, DEFAULT_MAX_LINE_BYTES } from './writer.ts';
40
+ export type { WriterOptions } from './writer.ts';
41
+
42
+ /**
43
+ * Lazily-constructed module-level singletons. `getWriter()` honors the
44
+ * first `opts` it receives; subsequent calls with different options are
45
+ * ignored. Tests that need to vary options across runs should call
46
+ * {@link reset} between runs.
47
+ */
48
+ let defaultWriter: EventWriter | null = null;
49
+ let defaultBus: EventBus | null = null;
50
+ /**
51
+ * Cached host name. `os.hostname()` is cheap but not free (syscall on
52
+ * some platforms) and we stamp it onto every event; compute once.
53
+ */
54
+ let cachedHost: string | null = null;
55
+
56
+ /**
57
+ * Return the module-level default writer, constructing it on first
58
+ * call. Passing `opts` on subsequent calls is a no-op (the first
59
+ * caller wins); that matches the "single shared file per process"
60
+ * intent.
61
+ */
62
+ export function getWriter(opts?: WriterOptions): EventWriter {
63
+ if (defaultWriter === null) {
64
+ defaultWriter = new EventWriter(opts ?? {});
65
+ }
66
+ return defaultWriter;
67
+ }
68
+
69
+ /** Return the module-level default bus, constructing it on first call. */
70
+ export function getBus(): EventBus {
71
+ if (defaultBus === null) {
72
+ defaultBus = new EventBus();
73
+ }
74
+ return defaultBus;
75
+ }
76
+
77
+ /**
78
+ * Persist `ev` to the on-disk JSONL stream AND broadcast it to the
79
+ * in-process bus. This is the normal emission path for every Phase 20+
80
+ * event producer.
81
+ *
82
+ * Ordering:
83
+ * 1. Stamp `_meta` (pid/host/source) if the caller didn't supply it.
84
+ * 2. Persist via `getWriter().append(ev)` — sync, never throws.
85
+ * 3. Broadcast via `getBus().emit(ev.type, ev)` AND `emit('*', ev)`
86
+ * so typed subscribers and `subscribeAll` observers both see it.
87
+ *
88
+ * Bus emission can still throw if a subscriber handler throws; we
89
+ * intentionally surface that rather than silently swallowing, since a
90
+ * failing handler is a programming bug, not an expected runtime
91
+ * condition. Plan 20-13's hooks wrap their handler bodies defensively
92
+ * for this reason.
93
+ */
94
+ export function appendEvent(ev: BaseEvent): void {
95
+ // Stamp writer-injected metadata if absent. We don't clone the full
96
+ // event — callers typically build it fresh per emission — but we do
97
+ // need to ensure `_meta` is present by the time we persist.
98
+ if (ev._meta === undefined) {
99
+ if (cachedHost === null) {
100
+ try {
101
+ cachedHost = hostname();
102
+ } catch {
103
+ cachedHost = 'unknown';
104
+ }
105
+ }
106
+ const meta: EventMeta = {
107
+ pid: process.pid,
108
+ host: cachedHost,
109
+ source: 'event-stream',
110
+ };
111
+ ev._meta = meta;
112
+ }
113
+
114
+ // Persist first. Bus emission is synchronous; if a subscriber throws
115
+ // after we've persisted, the durable record is already safe.
116
+ getWriter().append(ev);
117
+
118
+ const bus = getBus();
119
+ bus.emit(ev.type, ev);
120
+ bus.emit('*', ev);
121
+ }
122
+
123
+ /**
124
+ * Reset module-level singletons. Intended for tests that want a fresh
125
+ * writer (e.g. pointed at a new temp directory) or a fresh bus (e.g.
126
+ * to assert isolation between test cases).
127
+ *
128
+ * Safe to call from production code but the intended caller is a test.
129
+ * `appendEvent()` will lazily reconstruct both singletons on the next
130
+ * emission.
131
+ */
132
+ export function reset(): void {
133
+ if (defaultBus !== null) {
134
+ defaultBus.removeAllListeners();
135
+ }
136
+ defaultWriter = null;
137
+ defaultBus = null;
138
+ }
139
+
140
+ // Re-export `subscribe`/`subscribeAll` convenience: some callers only
141
+ // need to subscribe, not emit, and `getBus().subscribe(…)` reads fine
142
+ // but the shorter form keeps consumer code terse.
143
+ /** Convenience: subscribe to one event type on the default bus. */
144
+ export function subscribe<T extends BaseEvent = BaseEvent>(
145
+ type: T['type'],
146
+ handler: EventHandler<T>,
147
+ ): Unsubscribe {
148
+ return getBus().subscribe<T>(type, handler);
149
+ }
150
+
151
+ /** Convenience: subscribe to every event on the default bus. */
152
+ export function subscribeAll(handler: EventHandler<BaseEvent>): Unsubscribe {
153
+ return getBus().subscribeAll(handler);
154
+ }
@@ -0,0 +1,127 @@
1
+ // scripts/lib/event-stream/types.ts — typed event envelope + pre-registered
2
+ // event shapes, per Plan 20-06 (SDK-08).
3
+ //
4
+ // The event stream is the Phase 20+ observability primitive that every
5
+ // downstream consumer (Plan 20-05 MCP tool handlers, Plan 20-13 hooks)
6
+ // builds on. A single append-only JSONL file at
7
+ // `.design/telemetry/events.jsonl` holds the persisted form; an in-process
8
+ // `EventEmitter` bus (see `./emitter.ts`) broadcasts the same events
9
+ // live to subscribers within the same Node process.
10
+ //
11
+ // Envelope invariants (also encoded in `reference/schemas/events.schema.json`):
12
+ // * `type` — required, string, free-form. Pre-registered subtypes
13
+ // below are merely the seeded set; unknown types are
14
+ // allowed (validation is structural, not a closed enum).
15
+ // * `timestamp` — required, ISO-8601 (`date-time` format).
16
+ // * `sessionId` — required, stable per GDD pipeline run.
17
+ // * `stage` — optional, narrow `Stage` union.
18
+ // * `cycle` — optional, free-form string identifier.
19
+ // * `payload` — required, opaque object bag.
20
+ // * `_meta` — optional, writer-injected `{ pid, host, source }`.
21
+ // * `_truncated` — optional, writer-set when a payload exceeds
22
+ // `maxLineBytes` and has been replaced by a placeholder.
23
+ //
24
+ // Plan 20-04 owns the error taxonomy that feeds `ErrorEvent.payload`:
25
+ // `{ code, message, kind }` mirrors `toToolError(err)` output.
26
+
27
+ import type { Stage } from '../gdd-state/types.ts';
28
+
29
+ /** Writer-injected metadata. Never populated by callers. */
30
+ export interface EventMeta {
31
+ pid: number;
32
+ host: string;
33
+ /**
34
+ * Free-form identifier for the module that produced the event.
35
+ * Defaults to `"event-stream"` when `appendEvent()` fills the field
36
+ * itself; callers that wrap `appendEvent()` in a module-specific helper
37
+ * should overwrite this before calling.
38
+ */
39
+ source: string;
40
+ }
41
+
42
+ /**
43
+ * Canonical event envelope. All persisted and in-process events share
44
+ * this shape. Concrete subtypes narrow `type` + `payload` but add no
45
+ * additional top-level fields.
46
+ */
47
+ export interface BaseEvent {
48
+ type: string;
49
+ timestamp: string;
50
+ sessionId: string;
51
+ stage?: Stage;
52
+ cycle?: string;
53
+ payload: Record<string, unknown>;
54
+ _meta?: EventMeta;
55
+ /**
56
+ * Set to `true` by the writer when the serialized event exceeded
57
+ * `maxLineBytes` and the payload has been replaced with a placeholder.
58
+ * Never set by callers.
59
+ */
60
+ _truncated?: boolean;
61
+ }
62
+
63
+ /**
64
+ * Emitted by Plan 20-05's MCP tool handlers after a successful
65
+ * `mutate()` / `transition()` call. `diff` is an opaque structural
66
+ * description of the change; consumers (Phase 22 dashboard) render it.
67
+ */
68
+ export type StateMutationEvent = BaseEvent & {
69
+ type: 'state.mutation';
70
+ payload: { tool: string; diff: unknown };
71
+ };
72
+
73
+ /**
74
+ * Emitted by Plan 20-05 wrapping `transition()`. `pass=false` means
75
+ * the gate blocked the advance; `blockers` carries the same list the
76
+ * transition's `TransitionGateFailed` would expose.
77
+ */
78
+ export type StateTransitionEvent = BaseEvent & {
79
+ type: 'state.transition';
80
+ payload: { from: Stage; to: Stage; blockers: string[]; pass: boolean };
81
+ };
82
+
83
+ /** Lifecycle hook emitted when a pipeline stage begins execution. */
84
+ export type StageEnteredEvent = BaseEvent & {
85
+ type: 'stage.entered';
86
+ payload: { stage: Stage };
87
+ };
88
+
89
+ /**
90
+ * Lifecycle hook emitted when a pipeline stage finishes. `duration_ms`
91
+ * measures wall-clock time from `stage.entered`. `outcome` mirrors the
92
+ * stage's terminal state.
93
+ */
94
+ export type StageExitedEvent = BaseEvent & {
95
+ type: 'stage.exited';
96
+ payload: { stage: Stage; duration_ms: number; outcome: 'pass' | 'fail' | 'halted' };
97
+ };
98
+
99
+ /** Emitted by Plan 20-13 hook consumers when a hook dispatches a decision. */
100
+ export type HookFiredEvent = BaseEvent & {
101
+ type: 'hook.fired';
102
+ payload: { hook: string; decision: string };
103
+ };
104
+
105
+ /**
106
+ * Emitted whenever a `GDDError` is surfaced to the user or returned from
107
+ * a tool handler. `kind` mirrors `classify(err).kind`; `code` +
108
+ * `message` mirror the error's `code` + `message`.
109
+ */
110
+ export type ErrorEvent = BaseEvent & {
111
+ type: 'error';
112
+ payload: { code: string; message: string; kind: string };
113
+ };
114
+
115
+ /**
116
+ * Union of all pre-registered event types. Not a closed enum at the
117
+ * envelope level — callers can emit unknown types — but downstream
118
+ * consumers use this to drive typed `switch` statements with exhaustive
119
+ * checks for the subset they care about.
120
+ */
121
+ export type KnownEvent =
122
+ | StateMutationEvent
123
+ | StateTransitionEvent
124
+ | StageEnteredEvent
125
+ | StageExitedEvent
126
+ | HookFiredEvent
127
+ | ErrorEvent;
@@ -0,0 +1,154 @@
1
+ // scripts/lib/event-stream/writer.ts — append-only JSONL writer for the
2
+ // Phase 20+ telemetry stream (Plan 20-06, SDK-08).
3
+ //
4
+ // Design:
5
+ // * One file per .design/: `.design/telemetry/events.jsonl`, sibling to
6
+ // the existing `costs.jsonl` (which this plan does NOT modify).
7
+ // * Each `append()` is one call to `fs.appendFileSync(…, { flag: 'a' })`.
8
+ // On POSIX the O_APPEND semantic guarantees that a single write()
9
+ // under `PIPE_BUF` (4096 bytes) is atomic with respect to other
10
+ // appenders — multiple processes can append concurrently without
11
+ // interleaving or corruption. On Windows, `FILE_APPEND_DATA` via the
12
+ // Node runtime supplies the same guarantee. Typical event lines are
13
+ // well under 1KB; oversized events are truncated (see below) so we
14
+ // never approach the 4KB atomicity ceiling even on stricter POSIX
15
+ // implementations.
16
+ // * `append()` NEVER throws to the caller. On I/O failure we record
17
+ // the error (so observability tooling can surface it) and write a
18
+ // single diagnostic line to stderr.
19
+ // * Oversized payloads (> `maxLineBytes`, default 64KB) are truncated
20
+ // rather than dropped: we keep envelope metadata and replace
21
+ // `payload` with `{ _truncated_placeholder: true }`, then re-serialize
22
+ // and stamp `_truncated: true` on the line.
23
+
24
+ import { appendFileSync, mkdirSync } from 'node:fs';
25
+ import { dirname, resolve, isAbsolute, join } from 'node:path';
26
+
27
+ import type { BaseEvent } from './types.ts';
28
+
29
+ /** Default relative path for the persisted event stream. */
30
+ export const DEFAULT_EVENTS_PATH = '.design/telemetry/events.jsonl';
31
+
32
+ /** Default max line size in bytes. JSONL lines that exceed this are truncated. */
33
+ export const DEFAULT_MAX_LINE_BYTES = 64 * 1024; // 64 KiB
34
+
35
+ /** Constructor options for {@link EventWriter}. */
36
+ export interface WriterOptions {
37
+ /**
38
+ * Target file path for persisted events. Resolved to absolute at
39
+ * construction. Relative paths are resolved against `process.cwd()`.
40
+ *
41
+ * Default: `.design/telemetry/events.jsonl`.
42
+ */
43
+ path?: string;
44
+ /**
45
+ * Maximum serialized line size in bytes (JSON.stringify length + `\n`).
46
+ * Events exceeding this cap are truncated — the envelope is preserved
47
+ * and the payload is replaced with a `_truncated_placeholder` marker.
48
+ *
49
+ * Default: 65536 (64 KiB).
50
+ */
51
+ maxLineBytes?: number;
52
+ }
53
+
54
+ /**
55
+ * Append-only JSONL writer. One instance per file path is sufficient;
56
+ * the module-level cache in `./index.ts` shares a single default writer
57
+ * across the process so directory creation only happens once.
58
+ */
59
+ export class EventWriter {
60
+ /** Resolved absolute target path. */
61
+ readonly path: string;
62
+ /** Maximum line size in bytes (see {@link WriterOptions.maxLineBytes}). */
63
+ readonly maxLineBytes: number;
64
+ /** Number of failed append attempts since construction. */
65
+ writeErrors: number = 0;
66
+ /** The most recent write error, or `null` if none has occurred. */
67
+ lastError: Error | null = null;
68
+
69
+ /** `true` once we've ensured the target directory exists. */
70
+ private directoryEnsured: boolean = false;
71
+
72
+ constructor(opts: WriterOptions = {}) {
73
+ const rawPath = opts.path ?? DEFAULT_EVENTS_PATH;
74
+ this.path = isAbsolute(rawPath) ? rawPath : resolve(process.cwd(), rawPath);
75
+ this.maxLineBytes = opts.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
76
+ }
77
+
78
+ /**
79
+ * Append one event to the target file.
80
+ *
81
+ * Contract:
82
+ * * SYNC — returns when the write has been accepted by the kernel.
83
+ * * NEVER throws — I/O errors increment {@link writeErrors} and update
84
+ * {@link lastError}; a diagnostic is written to stderr and
85
+ * execution continues.
86
+ * * Truncates oversized payloads rather than dropping the event.
87
+ */
88
+ append(ev: BaseEvent): void {
89
+ try {
90
+ const line = this.serialize(ev);
91
+ this.ensureDirectory();
92
+ appendFileSync(this.path, line, { flag: 'a' });
93
+ } catch (err) {
94
+ this.writeErrors += 1;
95
+ this.lastError = err instanceof Error ? err : new Error(String(err));
96
+ // One-line diagnostic; intentionally minimal so callers aren't
97
+ // spammed under sustained failure.
98
+ try {
99
+ process.stderr.write(
100
+ `[event-stream] write failed: ${this.lastError.message}\n`,
101
+ );
102
+ } catch {
103
+ // If stderr itself is broken we have no recourse; swallow.
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Produce the on-disk JSONL representation of an event, truncating
110
+ * oversized payloads so the line fits within {@link maxLineBytes}.
111
+ *
112
+ * Exposed on the instance for unit-testability; callers should use
113
+ * {@link append}.
114
+ */
115
+ serialize(ev: BaseEvent): string {
116
+ const raw = JSON.stringify(ev) + '\n';
117
+ if (Buffer.byteLength(raw, 'utf8') <= this.maxLineBytes) {
118
+ return raw;
119
+ }
120
+
121
+ // Truncate: keep envelope fields, drop payload content.
122
+ const truncated: BaseEvent = {
123
+ type: ev.type,
124
+ timestamp: ev.timestamp,
125
+ sessionId: ev.sessionId,
126
+ payload: { _truncated_placeholder: true },
127
+ _truncated: true,
128
+ };
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;
132
+ return JSON.stringify(truncated) + '\n';
133
+ }
134
+
135
+ /**
136
+ * Ensure the target directory exists. Memoized so we only pay the
137
+ * filesystem stat cost once per writer lifetime.
138
+ */
139
+ private ensureDirectory(): void {
140
+ if (this.directoryEnsured) return;
141
+ mkdirSync(dirname(this.path), { recursive: true });
142
+ this.directoryEnsured = true;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Convenience helper: resolve a relative events-path against a supplied
148
+ * base directory (typically the project root) rather than `process.cwd()`.
149
+ * Intended for tests that scaffold a temp workspace and don't want to
150
+ * chdir.
151
+ */
152
+ export function eventsPathFor(baseDir: string): string {
153
+ return join(baseDir, DEFAULT_EVENTS_PATH);
154
+ }
@@ -0,0 +1,124 @@
1
+ // scripts/lib/gdd-errors/classification.ts — error classification + MCP helpers.
2
+ //
3
+ // Two exports:
4
+ // * classify(err) — normalize any thrown value into an
5
+ // ErrorClassification descriptor
6
+ // * toToolError(err) — wrap into the shape MCP tool handlers return
7
+ // inside data.error
8
+ //
9
+ // These helpers are the bridge between the taxonomy (index.ts) and the
10
+ // MCP tool layer (plan 20-05 wires them into all 11 tool handlers).
11
+
12
+ import {
13
+ GDDError,
14
+ ValidationError,
15
+ StateConflictError,
16
+ OperationFailedError,
17
+ } from './index.ts';
18
+
19
+ /**
20
+ * The four-valued classification of any error value. `kind === 'unknown'`
21
+ * means the thrown value was not a GDDError — either a plain Error or a
22
+ * non-Error value (string, number, etc.).
23
+ *
24
+ * Flag semantics:
25
+ * shouldThrow=true — caller should propagate (or rethrow) this error;
26
+ * it represents an invariant violation or bad input
27
+ * shouldThrow=false — caller should embed this in data.error and
28
+ * return normally (expected branching failure)
29
+ * retryable=true — upstream may retry after a backoff (only
30
+ * StateConflictError; e.g. lockfile contention)
31
+ */
32
+ export interface ErrorClassification {
33
+ kind: 'validation' | 'state_conflict' | 'operation_failed' | 'unknown';
34
+ shouldThrow: boolean;
35
+ retryable: boolean;
36
+ code: string;
37
+ message: string;
38
+ }
39
+
40
+ /**
41
+ * Classify any value thrown at us. Robust to non-Error throws (strings,
42
+ * numbers, plain objects) — returns `kind: 'unknown'` with a sensible
43
+ * message rather than blowing up.
44
+ */
45
+ export function classify(err: unknown): ErrorClassification {
46
+ if (err instanceof ValidationError) {
47
+ return {
48
+ kind: 'validation',
49
+ shouldThrow: true,
50
+ retryable: false,
51
+ code: err.code,
52
+ message: err.message,
53
+ };
54
+ }
55
+ if (err instanceof StateConflictError) {
56
+ return {
57
+ kind: 'state_conflict',
58
+ shouldThrow: true,
59
+ retryable: true,
60
+ code: err.code,
61
+ message: err.message,
62
+ };
63
+ }
64
+ if (err instanceof OperationFailedError) {
65
+ return {
66
+ kind: 'operation_failed',
67
+ shouldThrow: false,
68
+ retryable: false,
69
+ code: err.code,
70
+ message: err.message,
71
+ };
72
+ }
73
+ if (err instanceof Error) {
74
+ return {
75
+ kind: 'unknown',
76
+ shouldThrow: true,
77
+ retryable: false,
78
+ code: 'UNKNOWN',
79
+ message: err.message,
80
+ };
81
+ }
82
+ return {
83
+ kind: 'unknown',
84
+ shouldThrow: true,
85
+ retryable: false,
86
+ code: 'UNKNOWN',
87
+ message: String(err),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Shape MCP tool handlers return inside `data.error`. The `context`
93
+ * key is only present when the error is a GDDError instance — plain
94
+ * errors don't carry structured context, and we intentionally don't
95
+ * fabricate one (the `code` + `message` pair is sufficient).
96
+ */
97
+ export interface ToolErrorPayload {
98
+ error: {
99
+ code: string;
100
+ message: string;
101
+ kind: ErrorClassification['kind'];
102
+ context?: Readonly<Record<string, unknown>>;
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Convert any error into the shape MCP tool handlers return in
108
+ * data.error. Plan 20-05 wires this into the 11 tool handlers; Plan
109
+ * 20-06 emits it as an `error` event on the telemetry stream.
110
+ */
111
+ export function toToolError(err: unknown): ToolErrorPayload {
112
+ const c = classify(err);
113
+ const out: ToolErrorPayload = {
114
+ error: {
115
+ code: c.code,
116
+ message: c.message,
117
+ kind: c.kind,
118
+ },
119
+ };
120
+ if (err instanceof GDDError) {
121
+ out.error.context = err.context;
122
+ }
123
+ return out;
124
+ }