@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.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +60 -0
- package/README.md +12 -0
- package/agents/design-reflector.md +13 -0
- package/connections/connections.md +3 -0
- package/connections/figma.md +2 -0
- package/connections/gdd-state.md +186 -0
- package/hooks/budget-enforcer.ts +716 -0
- package/hooks/context-exhaustion.ts +251 -0
- package/hooks/gdd-read-injection-scanner.ts +172 -0
- package/hooks/hooks.json +3 -3
- package/package.json +19 -6
- package/reference/config-schema.md +2 -2
- package/reference/error-recovery.md +58 -0
- package/reference/registry.json +7 -0
- package/reference/schemas/budget.schema.json +42 -0
- package/reference/schemas/events.schema.json +55 -0
- package/reference/schemas/generated.d.ts +419 -0
- package/reference/schemas/iteration-budget.schema.json +36 -0
- package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
- package/reference/schemas/rate-limits.schema.json +31 -0
- package/scripts/aggregate-agent-metrics.ts +282 -0
- package/scripts/codegen-schema-types.ts +149 -0
- package/scripts/lib/error-classifier.cjs +232 -0
- package/scripts/lib/error-classifier.d.cts +44 -0
- package/scripts/lib/event-stream/emitter.ts +88 -0
- package/scripts/lib/event-stream/index.ts +154 -0
- package/scripts/lib/event-stream/types.ts +127 -0
- package/scripts/lib/event-stream/writer.ts +154 -0
- package/scripts/lib/gdd-errors/classification.ts +124 -0
- package/scripts/lib/gdd-errors/index.ts +218 -0
- package/scripts/lib/gdd-state/gates.ts +216 -0
- package/scripts/lib/gdd-state/index.ts +167 -0
- package/scripts/lib/gdd-state/lockfile.ts +232 -0
- package/scripts/lib/gdd-state/mutator.ts +574 -0
- package/scripts/lib/gdd-state/parser.ts +523 -0
- package/scripts/lib/gdd-state/types.ts +179 -0
- package/scripts/lib/iteration-budget.cjs +205 -0
- package/scripts/lib/iteration-budget.d.cts +32 -0
- package/scripts/lib/jittered-backoff.cjs +112 -0
- package/scripts/lib/jittered-backoff.d.cts +38 -0
- package/scripts/lib/lockfile.cjs +177 -0
- package/scripts/lib/lockfile.d.cts +21 -0
- package/scripts/lib/prompt-sanitizer/index.ts +435 -0
- package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
- package/scripts/lib/rate-guard.cjs +365 -0
- package/scripts/lib/rate-guard.d.cts +38 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
- package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
- package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
- package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
- package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
- package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
- package/scripts/mcp-servers/gdd-state/server.ts +288 -0
- package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
- package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
- package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
- package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
- package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
- package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
- package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
- package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
- package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
- package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
- package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
- package/scripts/validate-frontmatter.ts +114 -0
- package/scripts/validate-schemas.ts +401 -0
- package/skills/brief/SKILL.md +15 -6
- package/skills/design/SKILL.md +31 -13
- package/skills/explore/SKILL.md +41 -17
- package/skills/health/SKILL.md +15 -4
- package/skills/optimize/SKILL.md +3 -3
- package/skills/pause/SKILL.md +16 -10
- package/skills/plan/SKILL.md +33 -17
- package/skills/progress/SKILL.md +15 -11
- package/skills/resume/SKILL.md +19 -10
- package/skills/settings/SKILL.md +11 -3
- package/skills/todo/SKILL.md +12 -3
- package/skills/verify/SKILL.md +65 -29
- package/hooks/budget-enforcer.js +0 -329
- package/hooks/context-exhaustion.js +0 -127
- package/hooks/gdd-read-injection-scanner.js +0 -39
- package/scripts/aggregate-agent-metrics.js +0 -173
- package/scripts/validate-frontmatter.cjs +0 -68
- 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
|
+
}
|