@hegemonart/get-design-done 1.21.0 → 1.23.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 (39) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +184 -0
  4. package/hooks/_hook-emit.js +81 -0
  5. package/hooks/gdd-bash-guard.js +8 -0
  6. package/hooks/gdd-decision-injector.js +2 -0
  7. package/hooks/gdd-protected-paths.js +8 -0
  8. package/hooks/gdd-trajectory-capture.js +64 -0
  9. package/hooks/hooks.json +9 -0
  10. package/package.json +7 -2
  11. package/reference/output-contracts/planner-decision.schema.json +94 -0
  12. package/reference/output-contracts/verifier-decision.schema.json +66 -0
  13. package/scripts/cli/gdd-events.mjs +283 -0
  14. package/scripts/lib/audit-aggregator/index.cjs +219 -0
  15. package/scripts/lib/connection-probe/index.cjs +263 -0
  16. package/scripts/lib/design-solidify.mjs +265 -0
  17. package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
  18. package/scripts/lib/design-tokens/css-vars.cjs +55 -0
  19. package/scripts/lib/design-tokens/figma.cjs +121 -0
  20. package/scripts/lib/design-tokens/index.cjs +100 -0
  21. package/scripts/lib/design-tokens/js-const.cjs +107 -0
  22. package/scripts/lib/design-tokens/tailwind.cjs +98 -0
  23. package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
  24. package/scripts/lib/domain-primitives/nng.cjs +136 -0
  25. package/scripts/lib/domain-primitives/wcag.cjs +166 -0
  26. package/scripts/lib/event-chain.cjs +177 -0
  27. package/scripts/lib/event-stream/index.ts +20 -0
  28. package/scripts/lib/event-stream/reader.ts +139 -0
  29. package/scripts/lib/event-stream/types.ts +155 -1
  30. package/scripts/lib/event-stream/writer.ts +65 -8
  31. package/scripts/lib/parse-contract.cjs +168 -0
  32. package/scripts/lib/redact.cjs +122 -0
  33. package/scripts/lib/reference-resolver.cjs +184 -0
  34. package/scripts/lib/touches-analyzer/index.cjs +201 -0
  35. package/scripts/lib/touches-pattern-miner.cjs +195 -0
  36. package/scripts/lib/trajectory/index.cjs +126 -0
  37. package/scripts/lib/transports/ws.cjs +179 -0
  38. package/scripts/lib/visual-baseline/diff.cjs +137 -0
  39. package/scripts/lib/visual-baseline/index.cjs +139 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * domain-primitives/wcag.cjs — minimal WCAG validator (Plan 23-09).
3
+ *
4
+ * Three checks (no axe-core dep):
5
+ * 1. Contrast ratio (WCAG 1.4.3 / 1.4.6) — given fg + bg colors,
6
+ * compute the ratio. Pass: ≥4.5 (AA normal text), fail: <4.5.
7
+ * 2. Tap target size (WCAG 2.5.5 AAA / 2.5.8 AA) — given width+height
8
+ * in CSS pixels, fail if either dimension <24 (AA) or <44 (AAA).
9
+ * 3. ARIA label presence — given a snippet of HTML, look for
10
+ * <button>, <a>, <input> elements without an accessible name
11
+ * (no text content AND no aria-label/aria-labelledby).
12
+ *
13
+ * All inputs are passed by the caller — this module does not parse
14
+ * arbitrary CSS or run a browser. Each check returns an Array<HeuristicHit>.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ /**
20
+ * @typedef {Object} HeuristicHit
21
+ * @property {string} rule_id
22
+ * @property {'P0'|'P1'|'P2'|'P3'} severity
23
+ * @property {string} summary
24
+ * @property {string} [evidence]
25
+ * @property {number} [line]
26
+ * @property {string} file
27
+ */
28
+
29
+ const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
30
+
31
+ function hexToRgb(hex) {
32
+ if (typeof hex !== 'string') return null;
33
+ const m = hex.match(HEX_RE);
34
+ if (!m) return null;
35
+ let h = m[1];
36
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
37
+ return {
38
+ r: parseInt(h.slice(0, 2), 16),
39
+ g: parseInt(h.slice(2, 4), 16),
40
+ b: parseInt(h.slice(4, 6), 16),
41
+ };
42
+ }
43
+
44
+ function relLuminance({ r, g, b }) {
45
+ const channel = (v) => {
46
+ const s = v / 255;
47
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
48
+ };
49
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
50
+ }
51
+
52
+ /**
53
+ * WCAG 1.4.3 contrast ratio between two colors.
54
+ * @param {string} fgHex
55
+ * @param {string} bgHex
56
+ * @returns {number} 1.0 to 21.0
57
+ */
58
+ function contrastRatio(fgHex, bgHex) {
59
+ const fg = hexToRgb(fgHex);
60
+ const bg = hexToRgb(bgHex);
61
+ if (!fg || !bg) return NaN;
62
+ const lf = relLuminance(fg);
63
+ const lb = relLuminance(bg);
64
+ const [hi, lo] = lf > lb ? [lf, lb] : [lb, lf];
65
+ return (hi + 0.05) / (lo + 0.05);
66
+ }
67
+
68
+ /**
69
+ * Check contrast against AA (4.5:1) or AAA (7:1) threshold.
70
+ *
71
+ * @param {{file: string, fg: string, bg: string, level?: 'AA'|'AAA', context?: string}} input
72
+ * @returns {HeuristicHit[]}
73
+ */
74
+ function checkContrast(input) {
75
+ if (!input || typeof input.fg !== 'string' || typeof input.bg !== 'string') return [];
76
+ const ratio = contrastRatio(input.fg, input.bg);
77
+ const level = input.level ?? 'AA';
78
+ const minimum = level === 'AAA' ? 7 : 4.5;
79
+ if (Number.isNaN(ratio)) {
80
+ return [{
81
+ rule_id: 'wcag/1.4.3',
82
+ severity: 'P2',
83
+ summary: `unparseable color: fg=${input.fg} bg=${input.bg}`,
84
+ file: input.file,
85
+ }];
86
+ }
87
+ if (ratio >= minimum) return [];
88
+ return [{
89
+ rule_id: level === 'AAA' ? 'wcag/1.4.6' : 'wcag/1.4.3',
90
+ severity: ratio < 3 ? 'P0' : 'P1',
91
+ summary: `Contrast ratio ${ratio.toFixed(2)} below ${level} minimum ${minimum}:1`,
92
+ evidence: `fg=${input.fg}, bg=${input.bg}, ratio=${ratio.toFixed(2)}`,
93
+ file: input.file,
94
+ }];
95
+ }
96
+
97
+ /**
98
+ * Check tap target size (WCAG 2.5.5 / 2.5.8).
99
+ *
100
+ * @param {{file: string, width: number, height: number, level?: 'AA'|'AAA', name?: string}} input
101
+ * @returns {HeuristicHit[]}
102
+ */
103
+ function checkTapTarget(input) {
104
+ if (!input || typeof input.width !== 'number' || typeof input.height !== 'number') return [];
105
+ const level = input.level ?? 'AA';
106
+ const min = level === 'AAA' ? 44 : 24;
107
+ if (input.width >= min && input.height >= min) return [];
108
+ return [{
109
+ rule_id: level === 'AAA' ? 'wcag/2.5.5' : 'wcag/2.5.8',
110
+ severity: 'P1',
111
+ summary: `Tap target ${input.width}×${input.height}px below ${level} minimum ${min}×${min}px${input.name ? ` (${input.name})` : ''}`,
112
+ evidence: `${input.width}×${input.height}`,
113
+ file: input.file,
114
+ }];
115
+ }
116
+
117
+ const INTERACTIVE_RE = /<(button|a|input)\b([^>]*)>([\s\S]*?)<\/\1>|<input\b([^>]*)\/?>/gi;
118
+
119
+ /**
120
+ * Check that interactive elements have an accessible name.
121
+ *
122
+ * @param {{file: string, content: string}} input
123
+ * @returns {HeuristicHit[]}
124
+ */
125
+ function checkAriaLabels(input) {
126
+ if (!input || typeof input.content !== 'string') return [];
127
+ const hits = [];
128
+ // Walk lines so we can attach line numbers.
129
+ const lines = input.content.split(/\r?\n/);
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const ln = lines[i];
132
+ const elementMatches = [...ln.matchAll(/<(button|a|input)\b([^>]*?)(?:\s*\/)?>([^<]*)?/gi)];
133
+ for (const em of elementMatches) {
134
+ const tag = em[1].toLowerCase();
135
+ const attrs = em[2] || '';
136
+ const inner = (em[3] || '').trim();
137
+ const hasAriaLabel = /\baria-labell?e?d?b?y?\s*=/.test(attrs);
138
+ const hasTitle = /\btitle\s*=\s*["'][^"']+["']/.test(attrs);
139
+ const hasAlt = /\balt\s*=\s*["'][^"']+["']/.test(attrs);
140
+ if (tag === 'input') {
141
+ // Inputs with type=submit/button can rely on `value` attr.
142
+ const hasValue = /\bvalue\s*=\s*["'][^"']+["']/.test(attrs);
143
+ if (hasAriaLabel || hasTitle || hasValue) continue;
144
+ } else if (inner.length > 0 || hasAriaLabel || hasTitle || hasAlt) {
145
+ continue;
146
+ }
147
+ hits.push({
148
+ rule_id: 'wcag/4.1.2',
149
+ severity: 'P1',
150
+ summary: `<${tag}> has no accessible name (no text content + no aria-label/title)`,
151
+ evidence: em[0].slice(0, 200),
152
+ line: i + 1,
153
+ file: input.file,
154
+ });
155
+ }
156
+ }
157
+ return hits;
158
+ }
159
+
160
+ module.exports = {
161
+ contrastRatio,
162
+ hexToRgb,
163
+ checkContrast,
164
+ checkTapTarget,
165
+ checkAriaLabels,
166
+ };
@@ -0,0 +1,177 @@
1
+ /**
2
+ * event-chain.cjs — append-only causal event chain (Plan 22-04).
3
+ *
4
+ * Lives at `.design/gep/events.jsonl` (gep = "GDD Event Provenance").
5
+ * One JSONL line per event with parent-id linkage so consumers can
6
+ * walk decision → agent-spawn → outcome chains for retroactive audit.
7
+ *
8
+ * Schema:
9
+ * {
10
+ * event_id: UUIDv4 (random)
11
+ * parent_event_id: string | null
12
+ * ts: ISO-8601
13
+ * agent: string
14
+ * decision_refs: string[] // STATE.md decision IDs (D-NN, etc.)
15
+ * outcome: string // free-form: 'pass' / 'fail' / 'rolled-back' / …
16
+ * rollback_reason: string? // present iff outcome = 'rolled-back'
17
+ * ...rest: opaque caller-supplied fields preserved verbatim
18
+ * }
19
+ *
20
+ * Why a separate file from .design/telemetry/events.jsonl:
21
+ * * the chain is a CAUSAL overlay — rows have semantic meaning
22
+ * * the general event-stream is a high-volume firehose
23
+ * * /gdd:audit --retroactive walks the chain; it should not have to
24
+ * scan a 100k-line firehose for causal rows
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const { appendFileSync, mkdirSync, readFileSync, existsSync } = require('node:fs');
30
+ const { dirname, isAbsolute, join, resolve } = require('node:path');
31
+ const { randomUUID } = require('node:crypto');
32
+
33
+ const DEFAULT_CHAIN_PATH = '.design/gep/events.jsonl';
34
+
35
+ /**
36
+ * Resolve the on-disk chain file path, honouring an absolute override.
37
+ *
38
+ * @param {{baseDir?: string, path?: string}} [opts]
39
+ * @returns {string}
40
+ */
41
+ function chainPathFor(opts = {}) {
42
+ if (opts.path) {
43
+ return isAbsolute(opts.path) ? opts.path : resolve(opts.baseDir ?? process.cwd(), opts.path);
44
+ }
45
+ const base = opts.baseDir ?? process.cwd();
46
+ return resolve(base, DEFAULT_CHAIN_PATH);
47
+ }
48
+
49
+ /**
50
+ * Append one chain event. Returns the event_id (caller may not have
51
+ * supplied one).
52
+ *
53
+ * Required fields: agent, outcome.
54
+ * Optional: parent_event_id, decision_refs, rollback_reason, plus any
55
+ * opaque extra fields which are preserved verbatim.
56
+ *
57
+ * @param {{
58
+ * event_id?: string,
59
+ * parent_event_id?: string | null,
60
+ * agent: string,
61
+ * decision_refs?: string[],
62
+ * outcome: string,
63
+ * rollback_reason?: string,
64
+ * ts?: string,
65
+ * path?: string,
66
+ * baseDir?: string,
67
+ * [k: string]: unknown,
68
+ * }} input
69
+ * @returns {string} event_id
70
+ */
71
+ function appendChainEvent(input) {
72
+ if (!input || typeof input.agent !== 'string' || input.agent.length === 0) {
73
+ throw new TypeError('appendChainEvent: agent is required');
74
+ }
75
+ if (typeof input.outcome !== 'string' || input.outcome.length === 0) {
76
+ throw new TypeError('appendChainEvent: outcome is required');
77
+ }
78
+ const event_id = input.event_id || randomUUID();
79
+ const record = {
80
+ event_id,
81
+ parent_event_id: input.parent_event_id ?? null,
82
+ ts: input.ts || new Date().toISOString(),
83
+ agent: input.agent,
84
+ decision_refs: Array.isArray(input.decision_refs) ? input.decision_refs : [],
85
+ outcome: input.outcome,
86
+ };
87
+ if (input.rollback_reason !== undefined) {
88
+ record.rollback_reason = input.rollback_reason;
89
+ }
90
+ // Preserve opaque extras (any keys not already on `record`).
91
+ for (const key of Object.keys(input)) {
92
+ if (key === 'path' || key === 'baseDir') continue;
93
+ if (!(key in record)) {
94
+ record[key] = input[key];
95
+ }
96
+ }
97
+ const path = chainPathFor({ baseDir: input.baseDir, path: input.path });
98
+ try {
99
+ mkdirSync(dirname(path), { recursive: true });
100
+ appendFileSync(path, JSON.stringify(record) + '\n', { flag: 'a' });
101
+ } catch (err) {
102
+ try {
103
+ process.stderr.write(
104
+ `[event-chain] write failed: ${err && err.message ? err.message : String(err)}\n`,
105
+ );
106
+ } catch {
107
+ /* swallow */
108
+ }
109
+ }
110
+ return event_id;
111
+ }
112
+
113
+ /**
114
+ * Read the chain file and yield each parsed record. Invalid JSON lines
115
+ * are skipped with a stderr warning.
116
+ *
117
+ * @param {{path?: string, baseDir?: string}} [opts]
118
+ * @returns {Generator<Record<string, unknown>>}
119
+ */
120
+ function* readChain(opts = {}) {
121
+ const path = chainPathFor(opts);
122
+ if (!existsSync(path)) return;
123
+ const raw = readFileSync(path, 'utf8');
124
+ let lineNum = 0;
125
+ for (const line of raw.split('\n')) {
126
+ lineNum += 1;
127
+ if (line.trim() === '') continue;
128
+ try {
129
+ yield JSON.parse(line);
130
+ } catch (err) {
131
+ try {
132
+ process.stderr.write(
133
+ `[event-chain] skipping invalid line ${lineNum} at ${path}\n`,
134
+ );
135
+ } catch {
136
+ /* swallow */
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Walk parents of `event_id` until reaching a row with no parent.
144
+ * Returns the chain in caller-→-root order, i.e. `[event, parent, …]`.
145
+ *
146
+ * Returns an empty array if the event_id is not found.
147
+ *
148
+ * @param {string} event_id
149
+ * @param {{path?: string, baseDir?: string}} [opts]
150
+ * @returns {Array<Record<string, unknown>>}
151
+ */
152
+ function walkParents(event_id, opts = {}) {
153
+ /** @type {Map<string, Record<string, unknown>>} */
154
+ const byId = new Map();
155
+ for (const ev of readChain(opts)) {
156
+ byId.set(/** @type {string} */ (ev.event_id), ev);
157
+ }
158
+ const chain = [];
159
+ /** @type {string | null | undefined} */
160
+ let id = event_id;
161
+ const visited = new Set();
162
+ while (id && byId.has(id) && !visited.has(id)) {
163
+ visited.add(id);
164
+ const ev = byId.get(id);
165
+ chain.push(ev);
166
+ id = /** @type {string | null | undefined} */ (ev.parent_event_id);
167
+ }
168
+ return chain;
169
+ }
170
+
171
+ module.exports = {
172
+ appendChainEvent,
173
+ readChain,
174
+ walkParents,
175
+ chainPathFor,
176
+ DEFAULT_CHAIN_PATH,
177
+ };
@@ -33,11 +33,31 @@ export type {
33
33
  StageExitedEvent,
34
34
  HookFiredEvent,
35
35
  ErrorEvent,
36
+ WaveStartedEvent,
37
+ WaveCompletedEvent,
38
+ BlockerAddedEvent,
39
+ DecisionAddedEvent,
40
+ MustHaveAddedEvent,
41
+ ParallelismVerdictEvent,
42
+ CostUpdateEvent,
43
+ RateLimitEvent,
44
+ ApiRetryEvent,
45
+ CompactBoundaryEvent,
46
+ McpProbeEvent,
47
+ ReflectionProposedEvent,
48
+ ConnectionStatusChangeEvent,
49
+ ToolCallStartedEvent,
50
+ ToolCallCompletedEvent,
51
+ AgentSpawnEvent,
52
+ AgentOutcomeEvent,
36
53
  } from './types.ts';
54
+ export { KNOWN_EVENT_TYPES } from './types.ts';
37
55
  export { EventBus } from './emitter.ts';
38
56
  export type { EventHandler, Unsubscribe } from './emitter.ts';
39
57
  export { EventWriter, DEFAULT_EVENTS_PATH, DEFAULT_MAX_LINE_BYTES } from './writer.ts';
40
58
  export type { WriterOptions } from './writer.ts';
59
+ export { readEvents, aggregate } from './reader.ts';
60
+ export type { ReadEventsOptions, AggregateResult } from './reader.ts';
41
61
 
42
62
  /**
43
63
  * Lazily-constructed module-level singletons. `getWriter()` honors the
@@ -0,0 +1,139 @@
1
+ // scripts/lib/event-stream/reader.ts — typed JSONL reader + aggregator
2
+ // (Plan 22-05).
3
+ //
4
+ // `readEvents()` is a streaming async iterator over the persisted event
5
+ // log. It uses `readline` over a `fs.createReadStream`, so the entire
6
+ // file is never held in memory — events.jsonl can grow to gigabytes
7
+ // without OOM-ing a tail consumer.
8
+ //
9
+ // `aggregate()` collects an event iterable into a structured rollup
10
+ // (counts by type / stage / cycle / agent + totals). Aggregation
11
+ // always materialises the iterator, so callers that already have very
12
+ // large logs should pre-filter via `readEvents({filter: …})` before
13
+ // aggregating.
14
+
15
+ import { createReadStream, existsSync, type ReadStream } from 'node:fs';
16
+ import { isAbsolute, resolve } from 'node:path';
17
+ import { createInterface } from 'node:readline';
18
+
19
+ import type { BaseEvent } from './types.ts';
20
+ import { DEFAULT_EVENTS_PATH } from './writer.ts';
21
+
22
+ /** Options for {@link readEvents}. */
23
+ export interface ReadEventsOptions {
24
+ /** Source file path. Default: `.design/telemetry/events.jsonl` (resolved against cwd). */
25
+ path?: string;
26
+ /** Resolution base for relative `path`. Default: `process.cwd()`. */
27
+ baseDir?: string;
28
+ /** Match by event type — string is exact-equal, RegExp is `.test(type)`. */
29
+ type?: string | RegExp;
30
+ /** Custom predicate; runs after `type` filter if both are supplied. */
31
+ predicate?: (ev: BaseEvent) => boolean;
32
+ /** Inclusive lower bound on `timestamp` (ISO-8601 string). */
33
+ since?: string;
34
+ /** Inclusive upper bound on `timestamp` (ISO-8601 string). */
35
+ until?: string;
36
+ }
37
+
38
+ /** Result shape from {@link aggregate}. */
39
+ export interface AggregateResult {
40
+ byType: Record<string, number>;
41
+ byStage: Record<string, number>;
42
+ byCycle: Record<string, number>;
43
+ byAgent: Record<string, number>;
44
+ totals: {
45
+ count: number;
46
+ error_count: number;
47
+ truncated_count: number;
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Resolve the read path the same way the writer does: absolute paths
53
+ * win; relative paths resolve against `baseDir` (defaults to cwd).
54
+ */
55
+ function resolveReadPath(opts: ReadEventsOptions): string {
56
+ const raw = opts.path ?? DEFAULT_EVENTS_PATH;
57
+ if (isAbsolute(raw)) return raw;
58
+ return resolve(opts.baseDir ?? process.cwd(), raw);
59
+ }
60
+
61
+ /**
62
+ * Stream events from the JSONL log line-by-line. Invalid JSON lines
63
+ * are skipped silently — the writer guarantees well-formed output, so
64
+ * a malformed line is a data-corruption signal that should not crash
65
+ * a tail consumer.
66
+ *
67
+ * Filters apply in this order: type → predicate → since/until. The
68
+ * type filter is the cheapest, so it short-circuits first.
69
+ */
70
+ export async function* readEvents(
71
+ opts: ReadEventsOptions = {},
72
+ ): AsyncIterable<BaseEvent> {
73
+ const path = resolveReadPath(opts);
74
+ if (!existsSync(path)) return;
75
+
76
+ const stream: ReadStream = createReadStream(path, { encoding: 'utf8' });
77
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
78
+
79
+ const typeRe = opts.type instanceof RegExp ? opts.type : null;
80
+ const typeStr = typeof opts.type === 'string' ? opts.type : null;
81
+
82
+ try {
83
+ for await (const line of rl) {
84
+ if (line.trim() === '') continue;
85
+ let ev: BaseEvent;
86
+ try {
87
+ ev = JSON.parse(line) as BaseEvent;
88
+ } catch {
89
+ continue;
90
+ }
91
+ if (typeStr !== null && ev.type !== typeStr) continue;
92
+ if (typeRe !== null && !typeRe.test(ev.type)) continue;
93
+ if (opts.predicate && !opts.predicate(ev)) continue;
94
+ if (opts.since !== undefined && ev.timestamp < opts.since) continue;
95
+ if (opts.until !== undefined && ev.timestamp > opts.until) continue;
96
+ yield ev;
97
+ }
98
+ } finally {
99
+ rl.close();
100
+ stream.close();
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Synchronous aggregator that drains an iterable of events and rolls
106
+ * them up by type / stage / cycle / agent. `agent` is read from
107
+ * `payload.agent` (the trajectory + cost-update + agent.spawn shapes
108
+ * all expose it there).
109
+ */
110
+ export async function aggregate(
111
+ events: AsyncIterable<BaseEvent> | Iterable<BaseEvent>,
112
+ ): Promise<AggregateResult> {
113
+ const result: AggregateResult = {
114
+ byType: {},
115
+ byStage: {},
116
+ byCycle: {},
117
+ byAgent: {},
118
+ totals: { count: 0, error_count: 0, truncated_count: 0 },
119
+ };
120
+
121
+ /** @param {Record<string, number>} bucket @param {string} key */
122
+ const inc = (bucket: Record<string, number>, key: string) => {
123
+ bucket[key] = (bucket[key] ?? 0) + 1;
124
+ };
125
+
126
+ for await (const ev of events as AsyncIterable<BaseEvent>) {
127
+ result.totals.count += 1;
128
+ inc(result.byType, ev.type);
129
+ if (ev.type === 'error') result.totals.error_count += 1;
130
+ if (ev._truncated === true) result.totals.truncated_count += 1;
131
+ if (ev.stage) inc(result.byStage, String(ev.stage));
132
+ if (ev.cycle) inc(result.byCycle, ev.cycle);
133
+ const payload = ev.payload as Record<string, unknown> | undefined;
134
+ if (payload && typeof payload['agent'] === 'string') {
135
+ inc(result.byAgent, payload['agent'] as string);
136
+ }
137
+ }
138
+ return result;
139
+ }
@@ -112,6 +112,112 @@ export type ErrorEvent = BaseEvent & {
112
112
  payload: { code: string; message: string; kind: string };
113
113
  };
114
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // Phase 22 — pre-registered subtypes expansion (Plan 22-01)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Wave orchestration — Plan 21 parallel-mapper / wave execution. */
120
+ export type WaveStartedEvent = BaseEvent & {
121
+ type: 'wave.started';
122
+ payload: { wave: string; plan_count: number };
123
+ };
124
+ export type WaveCompletedEvent = BaseEvent & {
125
+ type: 'wave.completed';
126
+ payload: { wave: string; duration_ms: number; outcome: 'pass' | 'fail' };
127
+ };
128
+
129
+ /** STATE.md mutation lifecycle (Plan 20-03). */
130
+ export type BlockerAddedEvent = BaseEvent & {
131
+ type: 'blocker.added';
132
+ payload: { id: string; summary: string; source: string };
133
+ };
134
+ export type DecisionAddedEvent = BaseEvent & {
135
+ type: 'decision.added';
136
+ payload: { id: string; summary: string; source: string };
137
+ };
138
+ export type MustHaveAddedEvent = BaseEvent & {
139
+ type: 'must_have.added';
140
+ payload: { id: string; summary: string; source: string };
141
+ };
142
+
143
+ /** Parallelism decision engine output — Plan 21 explore-parallel-runner. */
144
+ export type ParallelismVerdictEvent = BaseEvent & {
145
+ type: 'parallelism.verdict';
146
+ payload: { task_ids: string[]; verdict: 'parallel' | 'sequential'; reason: string };
147
+ };
148
+
149
+ /** Phase 10.1 cost-telemetry event-stream sink. */
150
+ export type CostUpdateEvent = BaseEvent & {
151
+ type: 'cost.update';
152
+ payload: { agent: string; tier: string; usd: number; tokens_in: number; tokens_out: number };
153
+ };
154
+
155
+ /** Rate-guard / backoff stream (Plan 20-10, 20-11). */
156
+ export type RateLimitEvent = BaseEvent & {
157
+ type: 'rate_limit';
158
+ payload: { provider: string; reset_at: string; remaining: number };
159
+ };
160
+ export type ApiRetryEvent = BaseEvent & {
161
+ type: 'api.retry';
162
+ payload: { provider: string; attempt: number; delay_ms: number; reason: string };
163
+ };
164
+
165
+ /** Context-window churn; emitted by `hooks/context-exhaustion.ts`. */
166
+ export type CompactBoundaryEvent = BaseEvent & {
167
+ type: 'compact.boundary';
168
+ payload: { tokens_before: number; tokens_after: number };
169
+ };
170
+
171
+ /** MCP liveness probe from connection-probe primitive (Plan 22-08). */
172
+ export type McpProbeEvent = BaseEvent & {
173
+ type: 'mcp.probe';
174
+ payload: { name: string; status: 'ok' | 'degraded' | 'down'; latency_ms?: number };
175
+ };
176
+
177
+ /** Reflector proposal (Phase 11 post-cycle reflector → event stream). */
178
+ export type ReflectionProposedEvent = BaseEvent & {
179
+ type: 'reflection.proposed';
180
+ payload: { kind: string; target_file: string; summary: string };
181
+ };
182
+
183
+ /** Connection state transitions emitted by `connection-probe` (Plan 22-08). */
184
+ export type ConnectionStatusChangeEvent = BaseEvent & {
185
+ type: 'connection.status_change';
186
+ payload: { name: string; from: string; to: string };
187
+ };
188
+
189
+ /** Per-tool-call trajectory (Plan 22-03). */
190
+ export type ToolCallStartedEvent = BaseEvent & {
191
+ type: 'tool_call.started';
192
+ payload: { tool: string; args_hash: string };
193
+ };
194
+ export type ToolCallCompletedEvent = BaseEvent & {
195
+ type: 'tool_call.completed';
196
+ payload: {
197
+ tool: string;
198
+ args_hash: string;
199
+ result_hash: string;
200
+ latency_ms: number;
201
+ status: 'ok' | 'error';
202
+ };
203
+ };
204
+
205
+ /** Agent-level lifecycle (Plan 21 pipeline-runner / subagent spawn). */
206
+ export type AgentSpawnEvent = BaseEvent & {
207
+ type: 'agent.spawn';
208
+ payload: { agent: string; task_id?: string; tier?: string };
209
+ };
210
+ export type AgentOutcomeEvent = BaseEvent & {
211
+ type: 'agent.outcome';
212
+ payload: {
213
+ agent: string;
214
+ task_id?: string;
215
+ outcome: 'pass' | 'fail' | 'halted';
216
+ duration_ms: number;
217
+ cost_usd?: number;
218
+ };
219
+ };
220
+
115
221
  /**
116
222
  * Union of all pre-registered event types. Not a closed enum at the
117
223
  * envelope level — callers can emit unknown types — but downstream
@@ -124,4 +230,52 @@ export type KnownEvent =
124
230
  | StageEnteredEvent
125
231
  | StageExitedEvent
126
232
  | HookFiredEvent
127
- | ErrorEvent;
233
+ | ErrorEvent
234
+ | WaveStartedEvent
235
+ | WaveCompletedEvent
236
+ | BlockerAddedEvent
237
+ | DecisionAddedEvent
238
+ | MustHaveAddedEvent
239
+ | ParallelismVerdictEvent
240
+ | CostUpdateEvent
241
+ | RateLimitEvent
242
+ | ApiRetryEvent
243
+ | CompactBoundaryEvent
244
+ | McpProbeEvent
245
+ | ReflectionProposedEvent
246
+ | ConnectionStatusChangeEvent
247
+ | ToolCallStartedEvent
248
+ | ToolCallCompletedEvent
249
+ | AgentSpawnEvent
250
+ | AgentOutcomeEvent;
251
+
252
+ /**
253
+ * Runtime list of all pre-registered event `type` strings. Used by the
254
+ * Phase 22 baseline test and the CLI transport's `--list-types`
255
+ * subcommand.
256
+ */
257
+ export const KNOWN_EVENT_TYPES: readonly string[] = [
258
+ 'state.mutation',
259
+ 'state.transition',
260
+ 'stage.entered',
261
+ 'stage.exited',
262
+ 'hook.fired',
263
+ 'error',
264
+ 'wave.started',
265
+ 'wave.completed',
266
+ 'blocker.added',
267
+ 'decision.added',
268
+ 'must_have.added',
269
+ 'parallelism.verdict',
270
+ 'cost.update',
271
+ 'rate_limit',
272
+ 'api.retry',
273
+ 'compact.boundary',
274
+ 'mcp.probe',
275
+ 'reflection.proposed',
276
+ 'connection.status_change',
277
+ 'tool_call.started',
278
+ 'tool_call.completed',
279
+ 'agent.spawn',
280
+ 'agent.outcome',
281
+ ] as const;