@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +184 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +7 -2
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/design-solidify.mjs +265 -0
- package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
- package/scripts/lib/design-tokens/css-vars.cjs +55 -0
- package/scripts/lib/design-tokens/figma.cjs +121 -0
- package/scripts/lib/design-tokens/index.cjs +100 -0
- package/scripts/lib/design-tokens/js-const.cjs +107 -0
- package/scripts/lib/design-tokens/tailwind.cjs +98 -0
- package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
- package/scripts/lib/domain-primitives/nng.cjs +136 -0
- package/scripts/lib/domain-primitives/wcag.cjs +166 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +20 -0
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/parse-contract.cjs +168 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/reference-resolver.cjs +184 -0
- package/scripts/lib/touches-analyzer/index.cjs +201 -0
- package/scripts/lib/touches-pattern-miner.cjs +195 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- package/scripts/lib/transports/ws.cjs +179 -0
- package/scripts/lib/visual-baseline/diff.cjs +137 -0
- 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;
|