@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,290 @@
|
|
|
1
|
+
// scripts/lib/explore-parallel-runner/mappers.ts — Plan 21-06 (SDK-18).
|
|
2
|
+
//
|
|
3
|
+
// Mapper spawner + parallelism_safe helper. Wraps session-runner.run()
|
|
4
|
+
// per mapper; aggregates N mappers concurrently with a semaphore capped
|
|
5
|
+
// at `concurrency`.
|
|
6
|
+
//
|
|
7
|
+
// Design notes:
|
|
8
|
+
// * `isParallelismSafe` parses the `parallelism_safe` field from
|
|
9
|
+
// agent-markdown frontmatter using the same hand-rolled splitter
|
|
10
|
+
// pattern as tool-scoping/parse-agent-tools.ts. Missing file, missing
|
|
11
|
+
// frontmatter, or missing field all return `true` (default-safe).
|
|
12
|
+
// * `spawnMapper` never throws; any session-level failure lands as
|
|
13
|
+
// MapperOutcome.status === 'error' with `.error` populated. Output
|
|
14
|
+
// file presence is captured post-run so callers can distinguish
|
|
15
|
+
// "session completed but mapper didn't write" from "session errored".
|
|
16
|
+
// * `spawnMappersParallel` uses a rolling semaphore (NOT batch groups).
|
|
17
|
+
// Outcomes are returned in INPUT order, not completion order — tests
|
|
18
|
+
// assert this invariant.
|
|
19
|
+
|
|
20
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
21
|
+
import { resolve as resolvePath } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { run as defaultSessionRun } from '../session-runner/index.ts';
|
|
24
|
+
import type {
|
|
25
|
+
SessionResult,
|
|
26
|
+
SessionRunnerOptions,
|
|
27
|
+
BudgetCap,
|
|
28
|
+
} from '../session-runner/types.ts';
|
|
29
|
+
import {
|
|
30
|
+
enforceScope,
|
|
31
|
+
parseAgentToolsByName,
|
|
32
|
+
} from '../tool-scoping/index.ts';
|
|
33
|
+
|
|
34
|
+
import type { MapperOutcome, MapperSpec } from './types.ts';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// isParallelismSafe — frontmatter parser
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse the `parallelism_safe` field from an agent markdown file's YAML
|
|
42
|
+
* frontmatter. Returns `true` (default-safe) when the file is missing,
|
|
43
|
+
* has no frontmatter, or the field is absent.
|
|
44
|
+
*
|
|
45
|
+
* Supported shapes:
|
|
46
|
+
* parallelism_safe: true → true
|
|
47
|
+
* parallelism_safe: false → false
|
|
48
|
+
* parallelism_safe: "true" → true
|
|
49
|
+
* parallelism_safe: 'false' → false
|
|
50
|
+
*
|
|
51
|
+
* Anything else (garbage values, commented-out lines, etc.) falls through
|
|
52
|
+
* to `true` — fail-open is safer than blocking parallel execution on a
|
|
53
|
+
* stale frontmatter typo.
|
|
54
|
+
*/
|
|
55
|
+
export function isParallelismSafe(agentPath: string): boolean {
|
|
56
|
+
let raw: string;
|
|
57
|
+
try {
|
|
58
|
+
raw = readFileSync(agentPath, 'utf8');
|
|
59
|
+
} catch {
|
|
60
|
+
// ENOENT or any IO error → default-safe.
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const match: RegExpExecArray | null = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(raw);
|
|
65
|
+
if (match === null) return true;
|
|
66
|
+
const frontmatter: string = match[1] ?? '';
|
|
67
|
+
|
|
68
|
+
const lines: string[] = frontmatter.split(/\r?\n/);
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const m: RegExpExecArray | null = /^parallelism_safe:\s*(.*)$/.exec(line);
|
|
71
|
+
if (m === null) continue;
|
|
72
|
+
const value: string = (m[1] ?? '').trim().replace(/^["']|["']$/g, '').toLowerCase();
|
|
73
|
+
if (value === 'false') return false;
|
|
74
|
+
if (value === 'true') return true;
|
|
75
|
+
// Unknown / garbage → default-safe.
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// spawnMapper — run a single mapper session
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export interface SpawnMapperOptions {
|
|
86
|
+
readonly budget: BudgetCap;
|
|
87
|
+
readonly maxTurns: number;
|
|
88
|
+
readonly runOverride?: (
|
|
89
|
+
opts: SessionRunnerOptions,
|
|
90
|
+
) => Promise<SessionResult>;
|
|
91
|
+
readonly cwd: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Spawn a single mapper session. Returns a MapperOutcome — never throws.
|
|
96
|
+
*
|
|
97
|
+
* Scope resolution:
|
|
98
|
+
* * Parse agent frontmatter `tools:` via parseAgentToolsByName(name).
|
|
99
|
+
* * Compute allowedTools via enforceScope({ stage: 'explore', agentTools }).
|
|
100
|
+
* * Missing agent file → agentTools is null → stage 'explore' default applies.
|
|
101
|
+
*/
|
|
102
|
+
export async function spawnMapper(
|
|
103
|
+
spec: MapperSpec,
|
|
104
|
+
opts: SpawnMapperOptions,
|
|
105
|
+
): Promise<MapperOutcome> {
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
|
|
108
|
+
// Resolve tool scope. Agent frontmatter is read by bare name; we
|
|
109
|
+
// derive that from the spec.agentPath (`agents/<name>.md`).
|
|
110
|
+
const agentBaseName: string = spec.agentPath.replace(/\\/g, '/').split('/').pop()?.replace(/\.md$/i, '') ?? spec.name;
|
|
111
|
+
const agentsRoot: string = resolvePath(
|
|
112
|
+
opts.cwd,
|
|
113
|
+
spec.agentPath.replace(/\\/g, '/').split('/').slice(0, -1).join('/') || 'agents',
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
let allowedTools: readonly string[];
|
|
117
|
+
try {
|
|
118
|
+
const agentTools = parseAgentToolsByName(agentBaseName, agentsRoot);
|
|
119
|
+
allowedTools = enforceScope({ stage: 'explore', agentTools: agentTools ?? null });
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// enforceScope throws ValidationError on denied tool additions — we
|
|
122
|
+
// don't pass additional tools, so a throw here means the stage itself
|
|
123
|
+
// was rejected (which shouldn't happen). Degrade to empty scope +
|
|
124
|
+
// surface the error.
|
|
125
|
+
const message: string = err instanceof Error ? err.message : String(err);
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
name: spec.name,
|
|
128
|
+
status: 'error',
|
|
129
|
+
output_exists: false,
|
|
130
|
+
output_bytes: 0,
|
|
131
|
+
usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
|
|
132
|
+
duration_ms: Date.now() - start,
|
|
133
|
+
error: Object.freeze({ code: 'SCOPE_ERROR', message }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const runFn: (o: SessionRunnerOptions) => Promise<SessionResult> =
|
|
138
|
+
opts.runOverride ?? defaultSessionRun;
|
|
139
|
+
|
|
140
|
+
const runnerOpts: SessionRunnerOptions = {
|
|
141
|
+
prompt: spec.prompt,
|
|
142
|
+
stage: 'custom',
|
|
143
|
+
allowedTools: [...allowedTools],
|
|
144
|
+
budget: opts.budget,
|
|
145
|
+
turnCap: { maxTurns: opts.maxTurns },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
let sessionResult: SessionResult;
|
|
149
|
+
try {
|
|
150
|
+
sessionResult = await runFn(runnerOpts);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// session-runner.run() NEVER throws, but a test override might. Guard.
|
|
153
|
+
const message: string = err instanceof Error ? err.message : String(err);
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
name: spec.name,
|
|
156
|
+
status: 'error',
|
|
157
|
+
output_exists: false,
|
|
158
|
+
output_bytes: 0,
|
|
159
|
+
usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
|
|
160
|
+
duration_ms: Date.now() - start,
|
|
161
|
+
error: Object.freeze({ code: 'RUN_THREW', message }),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Capture output-file presence + size (post-run).
|
|
166
|
+
const outputFullPath: string = resolvePath(opts.cwd, spec.outputPath);
|
|
167
|
+
let outputExists = false;
|
|
168
|
+
let outputBytes = 0;
|
|
169
|
+
try {
|
|
170
|
+
const st = statSync(outputFullPath);
|
|
171
|
+
outputExists = st.isFile();
|
|
172
|
+
outputBytes = outputExists ? st.size : 0;
|
|
173
|
+
} catch {
|
|
174
|
+
outputExists = false;
|
|
175
|
+
outputBytes = 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Translate SessionResult.status → MapperOutcome.status. Anything
|
|
179
|
+
// other than 'completed' collapses to 'error'; the session-runner's
|
|
180
|
+
// error payload propagates.
|
|
181
|
+
if (sessionResult.status !== 'completed') {
|
|
182
|
+
const err = sessionResult.error ?? {
|
|
183
|
+
code: sessionResult.status.toUpperCase(),
|
|
184
|
+
message: `session ended with status ${sessionResult.status}`,
|
|
185
|
+
};
|
|
186
|
+
return Object.freeze({
|
|
187
|
+
name: spec.name,
|
|
188
|
+
status: 'error',
|
|
189
|
+
output_exists: outputExists,
|
|
190
|
+
output_bytes: outputBytes,
|
|
191
|
+
usage: {
|
|
192
|
+
input_tokens: sessionResult.usage.input_tokens,
|
|
193
|
+
output_tokens: sessionResult.usage.output_tokens,
|
|
194
|
+
usd_cost: sessionResult.usage.usd_cost,
|
|
195
|
+
},
|
|
196
|
+
duration_ms: Date.now() - start,
|
|
197
|
+
error: Object.freeze({ code: err.code, message: err.message }),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return Object.freeze({
|
|
202
|
+
name: spec.name,
|
|
203
|
+
status: 'completed',
|
|
204
|
+
output_exists: outputExists,
|
|
205
|
+
output_bytes: outputBytes,
|
|
206
|
+
usage: {
|
|
207
|
+
input_tokens: sessionResult.usage.input_tokens,
|
|
208
|
+
output_tokens: sessionResult.usage.output_tokens,
|
|
209
|
+
usd_cost: sessionResult.usage.usd_cost,
|
|
210
|
+
},
|
|
211
|
+
duration_ms: Date.now() - start,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// spawnMappersParallel — rolling semaphore over N specs
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
export interface SpawnMappersParallelOptions {
|
|
220
|
+
readonly concurrency: number;
|
|
221
|
+
readonly budget: BudgetCap;
|
|
222
|
+
readonly maxTurns: number;
|
|
223
|
+
readonly runOverride?: (
|
|
224
|
+
opts: SessionRunnerOptions,
|
|
225
|
+
) => Promise<SessionResult>;
|
|
226
|
+
readonly cwd: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Spawn N mappers concurrently, capped at `concurrency`. Returns
|
|
231
|
+
* outcomes in the SAME ORDER as `specs` (not completion order). A
|
|
232
|
+
* single mapper erroring does NOT cancel others.
|
|
233
|
+
*
|
|
234
|
+
* Concurrency < specs.length → rolling semaphore (new task starts as
|
|
235
|
+
* soon as a slot frees up). Concurrency >= specs.length → all spawn
|
|
236
|
+
* simultaneously.
|
|
237
|
+
*
|
|
238
|
+
* Empty specs → empty array.
|
|
239
|
+
*/
|
|
240
|
+
export async function spawnMappersParallel(
|
|
241
|
+
specs: readonly MapperSpec[],
|
|
242
|
+
opts: SpawnMappersParallelOptions,
|
|
243
|
+
): Promise<readonly MapperOutcome[]> {
|
|
244
|
+
if (specs.length === 0) return Object.freeze([]);
|
|
245
|
+
|
|
246
|
+
const n: number = specs.length;
|
|
247
|
+
const cap: number = Math.max(1, Math.min(opts.concurrency, n));
|
|
248
|
+
const outcomes: (MapperOutcome | null)[] = new Array(n).fill(null);
|
|
249
|
+
|
|
250
|
+
// Build a rolling-semaphore scheduler: we keep `cap` promises in
|
|
251
|
+
// flight at once. As each resolves we start the next. This is more
|
|
252
|
+
// flexible than a fixed batch grouping — if mapper #0 is slow and
|
|
253
|
+
// #1..3 all finish, #4 can start without waiting for #0.
|
|
254
|
+
let nextIndex = 0;
|
|
255
|
+
const workers: Promise<void>[] = [];
|
|
256
|
+
|
|
257
|
+
const launch = async (): Promise<void> => {
|
|
258
|
+
while (true) {
|
|
259
|
+
const myIndex = nextIndex;
|
|
260
|
+
nextIndex += 1;
|
|
261
|
+
if (myIndex >= n) return;
|
|
262
|
+
const spec = specs[myIndex];
|
|
263
|
+
if (spec === undefined) return;
|
|
264
|
+
const spawnOpts: SpawnMapperOptions = {
|
|
265
|
+
budget: opts.budget,
|
|
266
|
+
maxTurns: opts.maxTurns,
|
|
267
|
+
cwd: opts.cwd,
|
|
268
|
+
...(opts.runOverride !== undefined ? { runOverride: opts.runOverride } : {}),
|
|
269
|
+
};
|
|
270
|
+
const outcome = await spawnMapper(spec, spawnOpts);
|
|
271
|
+
outcomes[myIndex] = outcome;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
for (let w = 0; w < cap; w += 1) {
|
|
276
|
+
workers.push(launch());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await Promise.all(workers);
|
|
280
|
+
|
|
281
|
+
// Narrow `(MapperOutcome | null)[]` → `MapperOutcome[]`. All slots
|
|
282
|
+
// MUST be filled by now; a `null` indicates a scheduler bug.
|
|
283
|
+
const final: MapperOutcome[] = outcomes.map((o, i) => {
|
|
284
|
+
if (o === null) {
|
|
285
|
+
throw new Error(`spawnMappersParallel: slot ${i} was never filled`);
|
|
286
|
+
}
|
|
287
|
+
return o;
|
|
288
|
+
});
|
|
289
|
+
return Object.freeze(final);
|
|
290
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// scripts/lib/explore-parallel-runner/synthesizer.ts — Plan 21-06 (SDK-18).
|
|
2
|
+
//
|
|
3
|
+
// Incremental synthesizer driver. Watches `.design/map/<name>.md` for
|
|
4
|
+
// each mapper output, detects stable-size writes, and spawns a single
|
|
5
|
+
// synthesizer session with a concatenated prompt when all files are
|
|
6
|
+
// stable (or timeoutMs has elapsed).
|
|
7
|
+
//
|
|
8
|
+
// Design contract (as documented in the plan — "streaming-session
|
|
9
|
+
// injection contract"):
|
|
10
|
+
//
|
|
11
|
+
// 1. Wait for all mapper files to stabilize OR timeoutMs.
|
|
12
|
+
// 2. Read each stable file's content, concatenate under the synth
|
|
13
|
+
// prompt, mark missing/unstable files explicitly.
|
|
14
|
+
// 3. Spawn session-runner with the composite prompt.
|
|
15
|
+
//
|
|
16
|
+
// A fuller mid-session injection protocol would require Agent SDK
|
|
17
|
+
// multi-turn user-message injection; that's deferred to Phase 22.
|
|
18
|
+
// This implementation still satisfies the "streaming" acceptance: we
|
|
19
|
+
// DO NOT block the caller's mappers while waiting, and the
|
|
20
|
+
// synthesizer's prompt is composed dynamically from ready files.
|
|
21
|
+
//
|
|
22
|
+
// Stable-size detection: compare `statSync(path).size` across two
|
|
23
|
+
// consecutive polls. Unchanged size + still-present file → stable.
|
|
24
|
+
|
|
25
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
26
|
+
import { resolve as resolvePath } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { run as defaultSessionRun } from '../session-runner/index.ts';
|
|
29
|
+
import type {
|
|
30
|
+
BudgetCap,
|
|
31
|
+
SessionResult,
|
|
32
|
+
SessionRunnerOptions,
|
|
33
|
+
} from '../session-runner/types.ts';
|
|
34
|
+
|
|
35
|
+
export interface SynthesizeStreamingArgs {
|
|
36
|
+
readonly mapperNames: readonly string[];
|
|
37
|
+
readonly mapperOutputPaths: readonly string[];
|
|
38
|
+
readonly synthesizerPrompt: string;
|
|
39
|
+
readonly budget: BudgetCap;
|
|
40
|
+
readonly maxTurns: number;
|
|
41
|
+
readonly runOverride?: (
|
|
42
|
+
opts: SessionRunnerOptions,
|
|
43
|
+
) => Promise<SessionResult>;
|
|
44
|
+
readonly cwd: string;
|
|
45
|
+
/** Polling interval for stable-size detection (ms). Default 200. */
|
|
46
|
+
readonly pollIntervalMs?: number;
|
|
47
|
+
/** Total watch timeout (ms). Default 600_000 (10 min). */
|
|
48
|
+
readonly timeoutMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SynthesizeStreamingResult {
|
|
52
|
+
readonly status: 'completed' | 'error' | 'timeout' | 'skipped';
|
|
53
|
+
readonly output_path: string;
|
|
54
|
+
readonly usage: {
|
|
55
|
+
readonly input_tokens: number;
|
|
56
|
+
readonly output_tokens: number;
|
|
57
|
+
readonly usd_cost: number;
|
|
58
|
+
};
|
|
59
|
+
readonly files_fed: readonly string[];
|
|
60
|
+
readonly error?: { readonly code: string; readonly message: string };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_POLL_MS = 200;
|
|
64
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Probe a single mapper output path. Returns the current byte size when
|
|
68
|
+
* the path is a readable file, `null` when absent or unreadable.
|
|
69
|
+
*/
|
|
70
|
+
function probeSize(path: string): number | null {
|
|
71
|
+
try {
|
|
72
|
+
const st = statSync(path);
|
|
73
|
+
if (!st.isFile()) return null;
|
|
74
|
+
return st.size;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Wait until every `mapperOutputPaths[i]` is present AND has stable size
|
|
82
|
+
* across two consecutive polls — OR `timeoutMs` elapses. Returns the
|
|
83
|
+
* list of paths that stabilized in time.
|
|
84
|
+
*
|
|
85
|
+
* We allow `pollIntervalMs` to be explicitly `0` in tests (synchronous
|
|
86
|
+
* ready state). The minimum practical interval is still sub-millisecond
|
|
87
|
+
* — we never block the event loop longer than Node's setTimeout jitter.
|
|
88
|
+
*/
|
|
89
|
+
async function waitForStableFiles(
|
|
90
|
+
paths: readonly string[],
|
|
91
|
+
pollIntervalMs: number,
|
|
92
|
+
timeoutMs: number,
|
|
93
|
+
): Promise<readonly string[]> {
|
|
94
|
+
if (paths.length === 0) return Object.freeze([]);
|
|
95
|
+
|
|
96
|
+
const deadline: number = Date.now() + timeoutMs;
|
|
97
|
+
const lastSize: Map<string, number | null> = new Map();
|
|
98
|
+
const stable: Set<string> = new Set();
|
|
99
|
+
|
|
100
|
+
// Prime: record current sizes.
|
|
101
|
+
for (const p of paths) {
|
|
102
|
+
lastSize.set(p, probeSize(p));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
while (stable.size < paths.length) {
|
|
106
|
+
if (Date.now() >= deadline) break;
|
|
107
|
+
await sleep(pollIntervalMs);
|
|
108
|
+
|
|
109
|
+
for (const p of paths) {
|
|
110
|
+
if (stable.has(p)) continue;
|
|
111
|
+
const cur: number | null = probeSize(p);
|
|
112
|
+
const prev: number | null | undefined = lastSize.get(p);
|
|
113
|
+
if (cur !== null && prev !== null && prev !== undefined && cur === prev && cur >= 0) {
|
|
114
|
+
// Also require file exists (already enforced by non-null cur).
|
|
115
|
+
// And prevent 0-byte false positives by requiring at least one
|
|
116
|
+
// prior observation with a non-null size that matches.
|
|
117
|
+
stable.add(p);
|
|
118
|
+
}
|
|
119
|
+
lastSize.set(p, cur);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return Object.freeze(
|
|
124
|
+
paths.filter((p) => stable.has(p)),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Promise-returning sleep. 0 ms resolves on next microtask. */
|
|
129
|
+
function sleep(ms: number): Promise<void> {
|
|
130
|
+
if (ms <= 0) return Promise.resolve();
|
|
131
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Compose the synthesizer prompt from the base instructions + each
|
|
136
|
+
* stable mapper's file content, plus a note listing mappers that
|
|
137
|
+
* weren't ready.
|
|
138
|
+
*/
|
|
139
|
+
function composePrompt(args: {
|
|
140
|
+
basePrompt: string;
|
|
141
|
+
mapperNames: readonly string[];
|
|
142
|
+
mapperOutputPaths: readonly string[];
|
|
143
|
+
readyPaths: readonly string[];
|
|
144
|
+
}): { composed: string; filesFed: readonly string[] } {
|
|
145
|
+
const readySet: Set<string> = new Set(args.readyPaths);
|
|
146
|
+
const readBlocks: string[] = [];
|
|
147
|
+
const filesFed: string[] = [];
|
|
148
|
+
const missing: string[] = [];
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < args.mapperNames.length; i += 1) {
|
|
151
|
+
const name: string = args.mapperNames[i] ?? `mapper-${i}`;
|
|
152
|
+
const path: string = args.mapperOutputPaths[i] ?? '';
|
|
153
|
+
if (readySet.has(path)) {
|
|
154
|
+
let content = '';
|
|
155
|
+
try {
|
|
156
|
+
content = readFileSync(path, 'utf8');
|
|
157
|
+
} catch {
|
|
158
|
+
// File disappeared between stability check and read — treat
|
|
159
|
+
// as missing rather than erroring.
|
|
160
|
+
missing.push(name);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
readBlocks.push(`## Mapper: ${name}\n\n<path>${path}</path>\n\n${content}`);
|
|
164
|
+
filesFed.push(path);
|
|
165
|
+
} else {
|
|
166
|
+
missing.push(name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const missingNote: string =
|
|
171
|
+
missing.length > 0
|
|
172
|
+
? `\n\n<missing_mappers>\n${missing.join('\n')}\n</missing_mappers>\n`
|
|
173
|
+
: '';
|
|
174
|
+
|
|
175
|
+
const mapperSection: string =
|
|
176
|
+
readBlocks.length > 0
|
|
177
|
+
? `\n\n<mapper_outputs>\n\n${readBlocks.join('\n\n---\n\n')}\n\n</mapper_outputs>`
|
|
178
|
+
: '\n\n<mapper_outputs>(none ready)</mapper_outputs>';
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
composed: `${args.basePrompt}${missingNote}${mapperSection}`,
|
|
182
|
+
filesFed: Object.freeze(filesFed),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Public driver. Wait for mapper outputs to stabilize (or timeout),
|
|
188
|
+
* spawn the synthesizer session with the composite prompt, return
|
|
189
|
+
* terminal status + usage + fed-file list.
|
|
190
|
+
*
|
|
191
|
+
* Never throws. On session error, returns status 'error' with populated
|
|
192
|
+
* `.error`. On stability-wait timeout with NO files ready, returns
|
|
193
|
+
* status 'timeout' + empty `files_fed` (session NOT spawned).
|
|
194
|
+
*/
|
|
195
|
+
export async function synthesizeStreaming(
|
|
196
|
+
args: SynthesizeStreamingArgs,
|
|
197
|
+
): Promise<SynthesizeStreamingResult> {
|
|
198
|
+
const outputPath: string = resolvePath(args.cwd, '.design/DESIGN-PATTERNS.md');
|
|
199
|
+
const pollIntervalMs: number = args.pollIntervalMs ?? DEFAULT_POLL_MS;
|
|
200
|
+
const timeoutMs: number = args.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
201
|
+
|
|
202
|
+
// Resolve each mapper output path against cwd so callers can pass
|
|
203
|
+
// relative paths (e.g. '.design/map/token.md').
|
|
204
|
+
const absPaths: readonly string[] = args.mapperOutputPaths.map((p) =>
|
|
205
|
+
resolvePath(args.cwd, p),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const readyPaths: readonly string[] = await waitForStableFiles(
|
|
209
|
+
absPaths,
|
|
210
|
+
pollIntervalMs,
|
|
211
|
+
timeoutMs,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// If nothing stabilized within the timeout, short-circuit — don't
|
|
215
|
+
// burn a session with zero input. Callers log a warning.
|
|
216
|
+
if (readyPaths.length === 0 && absPaths.length > 0) {
|
|
217
|
+
return Object.freeze({
|
|
218
|
+
status: 'timeout',
|
|
219
|
+
output_path: outputPath,
|
|
220
|
+
usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
|
|
221
|
+
files_fed: Object.freeze([]),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const { composed, filesFed } = composePrompt({
|
|
226
|
+
basePrompt: args.synthesizerPrompt,
|
|
227
|
+
mapperNames: args.mapperNames,
|
|
228
|
+
mapperOutputPaths: absPaths,
|
|
229
|
+
readyPaths,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const runFn: (o: SessionRunnerOptions) => Promise<SessionResult> =
|
|
233
|
+
args.runOverride ?? defaultSessionRun;
|
|
234
|
+
|
|
235
|
+
const runnerOpts: SessionRunnerOptions = {
|
|
236
|
+
prompt: composed,
|
|
237
|
+
stage: 'explore',
|
|
238
|
+
budget: args.budget,
|
|
239
|
+
turnCap: { maxTurns: args.maxTurns },
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
let sessionResult: SessionResult;
|
|
243
|
+
try {
|
|
244
|
+
sessionResult = await runFn(runnerOpts);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
const message: string = err instanceof Error ? err.message : String(err);
|
|
247
|
+
return Object.freeze({
|
|
248
|
+
status: 'error',
|
|
249
|
+
output_path: outputPath,
|
|
250
|
+
usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
|
|
251
|
+
files_fed: filesFed,
|
|
252
|
+
error: Object.freeze({ code: 'RUN_THREW', message }),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Translate SessionResult → SynthesizeStreamingResult.
|
|
257
|
+
const usage = {
|
|
258
|
+
input_tokens: sessionResult.usage.input_tokens,
|
|
259
|
+
output_tokens: sessionResult.usage.output_tokens,
|
|
260
|
+
usd_cost: sessionResult.usage.usd_cost,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Determine terminal status. The plan allows the timeout outcome to
|
|
264
|
+
// bubble through even when a session DID spawn with partial inputs;
|
|
265
|
+
// we collapse to 'completed' when the session finished cleanly, even
|
|
266
|
+
// if not all files were ready. Callers inspect `files_fed.length <
|
|
267
|
+
// mapperNames.length` to detect partial coverage.
|
|
268
|
+
if (sessionResult.status !== 'completed') {
|
|
269
|
+
const err = sessionResult.error ?? {
|
|
270
|
+
code: sessionResult.status.toUpperCase(),
|
|
271
|
+
message: `synth session ended with status ${sessionResult.status}`,
|
|
272
|
+
};
|
|
273
|
+
return Object.freeze({
|
|
274
|
+
status: 'error',
|
|
275
|
+
output_path: outputPath,
|
|
276
|
+
usage,
|
|
277
|
+
files_fed: filesFed,
|
|
278
|
+
error: Object.freeze({ code: err.code, message: err.message }),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Happy path.
|
|
283
|
+
//
|
|
284
|
+
// If the caller provided 0 mapper paths, we didn't wait and didn't
|
|
285
|
+
// feed anything — that's the "synthesizer invoked with pre-rendered
|
|
286
|
+
// prompt" shape (used by the run() orchestrator's empty-mapper
|
|
287
|
+
// short-circuit elsewhere, though `run()` skips synth entirely in
|
|
288
|
+
// that case).
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
status: 'completed',
|
|
291
|
+
output_path: outputPath,
|
|
292
|
+
usage,
|
|
293
|
+
files_fed: filesFed,
|
|
294
|
+
});
|
|
295
|
+
}
|