@hegemonart/get-design-done 1.20.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +9 -12
- package/.claude-plugin/plugin.json +8 -31
- package/CHANGELOG.md +200 -0
- package/README.md +48 -7
- package/bin/gdd-sdk +55 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +19 -47
- package/reference/codex-tools.md +53 -0
- package/reference/gemini-tools.md +53 -0
- package/reference/registry.json +14 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/e2e/run-headless.ts +514 -0
- package/scripts/lib/cli/commands/audit.ts +382 -0
- package/scripts/lib/cli/commands/init.ts +217 -0
- package/scripts/lib/cli/commands/query.ts +329 -0
- package/scripts/lib/cli/commands/run.ts +656 -0
- package/scripts/lib/cli/commands/stage.ts +468 -0
- package/scripts/lib/cli/index.ts +167 -0
- package/scripts/lib/cli/parse-args.ts +336 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/context-engine/index.ts +116 -0
- package/scripts/lib/context-engine/manifest.ts +69 -0
- package/scripts/lib/context-engine/truncate.ts +282 -0
- package/scripts/lib/context-engine/types.ts +59 -0
- package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
- package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
- package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +31 -1
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/explore-parallel-runner/index.ts +294 -0
- package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
- package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
- package/scripts/lib/explore-parallel-runner/types.ts +139 -0
- package/scripts/lib/harness/detect.ts +90 -0
- package/scripts/lib/harness/index.ts +64 -0
- package/scripts/lib/harness/tool-map.ts +142 -0
- package/scripts/lib/init-runner/index.ts +396 -0
- package/scripts/lib/init-runner/researchers.ts +245 -0
- package/scripts/lib/init-runner/scaffold.ts +224 -0
- package/scripts/lib/init-runner/synthesizer.ts +224 -0
- package/scripts/lib/init-runner/types.ts +143 -0
- package/scripts/lib/logger/index.ts +251 -0
- package/scripts/lib/logger/sinks.ts +269 -0
- package/scripts/lib/logger/types.ts +110 -0
- package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
- package/scripts/lib/pipeline-runner/index.ts +527 -0
- package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
- package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
- package/scripts/lib/pipeline-runner/types.ts +183 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/session-runner/errors.ts +406 -0
- package/scripts/lib/session-runner/index.ts +715 -0
- package/scripts/lib/session-runner/transcript.ts +189 -0
- package/scripts/lib/session-runner/types.ts +144 -0
- package/scripts/lib/tool-scoping/index.ts +219 -0
- package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
- package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
- package/scripts/lib/tool-scoping/types.ts +77 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- package/scripts/lib/transports/ws.cjs +179 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// scripts/lib/discuss-parallel-runner/types.ts — Plan 21-07 (SDK-19).
|
|
2
|
+
//
|
|
3
|
+
// Public type surface for the parallel discussion runner. The runner
|
|
4
|
+
// spawns N design-discussant variants (each a different persona / angle
|
|
5
|
+
// — user-journey, technical-constraint, brand-fit, accessibility)
|
|
6
|
+
// concurrently via `session-runner`, collects each discussant's open
|
|
7
|
+
// questions and concerns as a structured `DiscussionContribution`, and
|
|
8
|
+
// runs an aggregator pass that deduplicates, clusters by theme, and
|
|
9
|
+
// surfaces a single ranked question list for the user.
|
|
10
|
+
//
|
|
11
|
+
// Consumers: the `discuss` skill (standalone leaf) and the `gdd-sdk
|
|
12
|
+
// discuss` CLI subcommand (Plan 21-09).
|
|
13
|
+
//
|
|
14
|
+
// Design invariants (see PLAN.md Context):
|
|
15
|
+
// * Discussant sessions are independent — none write to shared files;
|
|
16
|
+
// parallelism is always safe.
|
|
17
|
+
// * The aggregator runs AFTER all discussants complete.
|
|
18
|
+
// * Error isolation: one discussant's failure never cascades into
|
|
19
|
+
// other discussant sessions.
|
|
20
|
+
// * `AggregatedQuestion.key` is SHA-256-based (stable across runs)
|
|
21
|
+
// per the aggregator prompt contract.
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
BudgetCap,
|
|
25
|
+
SessionResult,
|
|
26
|
+
SessionRunnerOptions,
|
|
27
|
+
} from '../session-runner/types.ts';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Named discussant variants. The default roster is the four values in
|
|
31
|
+
* the union below, but callers can pass any string (arbitrary custom
|
|
32
|
+
* discussant).
|
|
33
|
+
*/
|
|
34
|
+
export type DiscussantName =
|
|
35
|
+
| 'user-journey'
|
|
36
|
+
| 'technical-constraint'
|
|
37
|
+
| 'brand-fit'
|
|
38
|
+
| 'accessibility'
|
|
39
|
+
| string;
|
|
40
|
+
|
|
41
|
+
/** Severity ordering is blocker > major > minor > nice-to-have. */
|
|
42
|
+
export type Severity = 'blocker' | 'major' | 'minor' | 'nice-to-have';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One discussant specification. The runner passes `prompt` verbatim
|
|
46
|
+
* through `session-runner.run()` as the prompt body.
|
|
47
|
+
*
|
|
48
|
+
* `agentPath` is optional; when absent, the runner defaults to the
|
|
49
|
+
* stage scope from tool-scoping (`discuss` maps to `custom` — see
|
|
50
|
+
* Plan 21-03). When present, the runner reads the agent markdown's
|
|
51
|
+
* `tools:` frontmatter via `parseAgentToolsByName` and passes the
|
|
52
|
+
* resolved list as `allowedTools`.
|
|
53
|
+
*/
|
|
54
|
+
export interface DiscussantSpec {
|
|
55
|
+
name: DiscussantName;
|
|
56
|
+
/** Optional agent frontmatter path; missing → stage scope from tool-scoping. */
|
|
57
|
+
agentPath?: string;
|
|
58
|
+
/** Per-discussant prompt body. */
|
|
59
|
+
prompt: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One parsed item from a discussant's DISCUSSION COMPLETE block.
|
|
64
|
+
*
|
|
65
|
+
* `kind` discriminates questions (things the discussant wants
|
|
66
|
+
* answered) from concerns (things they want to flag).
|
|
67
|
+
*
|
|
68
|
+
* `tag` captures the per-item annotation from the discussant output
|
|
69
|
+
* (`Concern: <stakeholder>` for questions, `Area: <scope>` for
|
|
70
|
+
* concerns). Optional because lenient parse allows missing.
|
|
71
|
+
*/
|
|
72
|
+
export interface DiscussionItem {
|
|
73
|
+
kind: 'question' | 'concern';
|
|
74
|
+
text: string;
|
|
75
|
+
/** Area / angle / concern tag per discussant output. */
|
|
76
|
+
tag?: string;
|
|
77
|
+
severity: Severity;
|
|
78
|
+
rationale?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One discussant's complete contribution. `items` is empty when the
|
|
83
|
+
* session errored or the block was missing/malformed.
|
|
84
|
+
*
|
|
85
|
+
* `status`:
|
|
86
|
+
* * `completed` — session ended cleanly AND a DISCUSSION COMPLETE
|
|
87
|
+
* block was parsed successfully.
|
|
88
|
+
* * `parse-error` — session ended cleanly but the block was absent
|
|
89
|
+
* or malformed (items: []).
|
|
90
|
+
* * `error` — session failed (budget, turn cap, aborted, error);
|
|
91
|
+
* `error` populated; items: [].
|
|
92
|
+
*/
|
|
93
|
+
export interface DiscussionContribution {
|
|
94
|
+
discussant: DiscussantName;
|
|
95
|
+
items: readonly DiscussionItem[];
|
|
96
|
+
/** Raw final_text captured for audit / aggregator input. */
|
|
97
|
+
raw: string;
|
|
98
|
+
usage: { input_tokens: number; output_tokens: number; usd_cost: number };
|
|
99
|
+
status: 'completed' | 'error' | 'parse-error';
|
|
100
|
+
error?: { code: string; message: string };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One aggregated (post-dedup, post-cluster) question.
|
|
105
|
+
*
|
|
106
|
+
* * `key` — SHA-256 of the normalized question text (lowercase,
|
|
107
|
+
* whitespace-collapsed) truncated to 8 hex chars.
|
|
108
|
+
* Stable across runs.
|
|
109
|
+
* * `raised_by` — discussants that raised (a semantic variant of)
|
|
110
|
+
* this question.
|
|
111
|
+
* * `theme` — cluster/theme name from aggregator.
|
|
112
|
+
* * `rank` — 0-indexed priority (0 = highest). Ranking combines
|
|
113
|
+
* severity + frequency per the aggregator prompt.
|
|
114
|
+
*/
|
|
115
|
+
export interface AggregatedQuestion {
|
|
116
|
+
/** Stable key across runs (hash of normalized question text). */
|
|
117
|
+
key: string;
|
|
118
|
+
text: string;
|
|
119
|
+
severity: Severity;
|
|
120
|
+
/** Discussants that raised this question. */
|
|
121
|
+
raised_by: readonly DiscussantName[];
|
|
122
|
+
/** Cluster/theme assignment from aggregator. */
|
|
123
|
+
theme: string;
|
|
124
|
+
/** Aggregator-assigned rank (0 = highest priority). */
|
|
125
|
+
rank: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The aggregator's final output. `output_path` is the Markdown file
|
|
130
|
+
* written to disk (`.design/DISCUSSION.md` by default). `usage` is
|
|
131
|
+
* the aggregator session's token/cost spend — separate from the
|
|
132
|
+
* per-discussant usage aggregated in `DiscussRunnerResult.total_usage`.
|
|
133
|
+
*/
|
|
134
|
+
export interface AggregatedDiscussion {
|
|
135
|
+
themes: readonly { name: string; summary: string }[];
|
|
136
|
+
questions: readonly AggregatedQuestion[];
|
|
137
|
+
/** Output path. */
|
|
138
|
+
output_path: string;
|
|
139
|
+
/** Aggregator session usage. */
|
|
140
|
+
usage: { input_tokens: number; output_tokens: number; usd_cost: number };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Options for `run()` — the top-level orchestrator entry point.
|
|
145
|
+
*
|
|
146
|
+
* * `discussants` — omit to use `DEFAULT_DISCUSSANTS` (4 variants).
|
|
147
|
+
* * `budget` + `maxTurnsPerDiscussant` — applied per-discussant.
|
|
148
|
+
* * `aggregatorBudget` + `aggregatorMaxTurns` — applied to the
|
|
149
|
+
* aggregator session specifically.
|
|
150
|
+
* * `concurrency` — defaults to 4 (matches the default roster size).
|
|
151
|
+
* * `runOverride` — test injection for `session-runner.run()`. All
|
|
152
|
+
* discussants AND the aggregator receive the SAME override so one
|
|
153
|
+
* mock controls the entire run.
|
|
154
|
+
* * `aggregatorPrompt` — replace the default aggregator prompt
|
|
155
|
+
* (advanced / debug use).
|
|
156
|
+
*/
|
|
157
|
+
export interface DiscussRunnerOptions {
|
|
158
|
+
/** Discussants to run. Default: the 4-variant roster. */
|
|
159
|
+
discussants?: readonly DiscussantSpec[];
|
|
160
|
+
budget: BudgetCap;
|
|
161
|
+
maxTurnsPerDiscussant: number;
|
|
162
|
+
aggregatorBudget: BudgetCap;
|
|
163
|
+
aggregatorMaxTurns: number;
|
|
164
|
+
concurrency?: number;
|
|
165
|
+
runOverride?: (opts: SessionRunnerOptions) => Promise<SessionResult>;
|
|
166
|
+
cwd?: string;
|
|
167
|
+
/** Custom aggregator prompt override. */
|
|
168
|
+
aggregatorPrompt?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The final return value from `run()`.
|
|
173
|
+
*
|
|
174
|
+
* * `contributions` — one per discussant, in input spec order (NOT
|
|
175
|
+
* completion order). Failed discussants are included with
|
|
176
|
+
* `status !== 'completed'`.
|
|
177
|
+
* * `aggregated` — the aggregator's parsed output.
|
|
178
|
+
* * `total_usage` — sum of per-discussant + aggregator usage.
|
|
179
|
+
*/
|
|
180
|
+
export interface DiscussRunnerResult {
|
|
181
|
+
contributions: readonly DiscussionContribution[];
|
|
182
|
+
aggregated: AggregatedDiscussion;
|
|
183
|
+
total_usage: { input_tokens: number; output_tokens: number; usd_cost: number };
|
|
184
|
+
}
|
|
@@ -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
|
|
@@ -61,7 +81,17 @@ let cachedHost: string | null = null;
|
|
|
61
81
|
*/
|
|
62
82
|
export function getWriter(opts?: WriterOptions): EventWriter {
|
|
63
83
|
if (defaultWriter === null) {
|
|
64
|
-
|
|
84
|
+
// Honor GDD_EVENTS_PATH env var as the first-choice default path
|
|
85
|
+
// when the caller doesn't pass an explicit `opts.path`. Lets test
|
|
86
|
+
// harnesses and Plan 21-11's E2E subprocess steer the on-disk
|
|
87
|
+
// stream into a fixture-specific directory without chdir'ing the
|
|
88
|
+
// entire process. Explicit `opts.path` always wins.
|
|
89
|
+
const envPath: string | undefined = process.env['GDD_EVENTS_PATH'];
|
|
90
|
+
const finalOpts: WriterOptions =
|
|
91
|
+
opts?.path === undefined && envPath !== undefined && envPath.length > 0
|
|
92
|
+
? { ...(opts ?? {}), path: envPath }
|
|
93
|
+
: (opts ?? {});
|
|
94
|
+
defaultWriter = new EventWriter(finalOpts);
|
|
65
95
|
}
|
|
66
96
|
return defaultWriter;
|
|
67
97
|
}
|
|
@@ -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
|
+
}
|