@hegemonart/get-design-done 1.19.6 → 1.21.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 +11 -14
- package/.claude-plugin/plugin.json +9 -32
- package/CHANGELOG.md +138 -0
- package/README.md +54 -1
- package/agents/design-reflector.md +13 -0
- package/bin/gdd-sdk +55 -0
- package/connections/connections.md +3 -0
- package/connections/figma.md +2 -0
- package/connections/gdd-state.md +186 -0
- package/hooks/budget-enforcer.ts +716 -0
- package/hooks/context-exhaustion.ts +251 -0
- package/hooks/gdd-read-injection-scanner.ts +172 -0
- package/hooks/hooks.json +3 -3
- package/package.json +32 -51
- package/reference/codex-tools.md +53 -0
- package/reference/config-schema.md +2 -2
- package/reference/error-recovery.md +58 -0
- package/reference/gemini-tools.md +53 -0
- package/reference/registry.json +21 -0
- package/reference/schemas/budget.schema.json +42 -0
- package/reference/schemas/events.schema.json +55 -0
- package/reference/schemas/generated.d.ts +419 -0
- package/reference/schemas/iteration-budget.schema.json +36 -0
- package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
- package/reference/schemas/rate-limits.schema.json +31 -0
- package/scripts/aggregate-agent-metrics.ts +282 -0
- package/scripts/codegen-schema-types.ts +149 -0
- package/scripts/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/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/error-classifier.cjs +232 -0
- package/scripts/lib/error-classifier.d.cts +44 -0
- package/scripts/lib/event-stream/emitter.ts +88 -0
- package/scripts/lib/event-stream/index.ts +164 -0
- package/scripts/lib/event-stream/types.ts +127 -0
- package/scripts/lib/event-stream/writer.ts +154 -0
- 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/gdd-errors/classification.ts +124 -0
- package/scripts/lib/gdd-errors/index.ts +218 -0
- package/scripts/lib/gdd-state/gates.ts +216 -0
- package/scripts/lib/gdd-state/index.ts +167 -0
- package/scripts/lib/gdd-state/lockfile.ts +232 -0
- package/scripts/lib/gdd-state/mutator.ts +574 -0
- package/scripts/lib/gdd-state/parser.ts +523 -0
- package/scripts/lib/gdd-state/types.ts +179 -0
- package/scripts/lib/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/iteration-budget.cjs +205 -0
- package/scripts/lib/iteration-budget.d.cts +32 -0
- package/scripts/lib/jittered-backoff.cjs +112 -0
- package/scripts/lib/jittered-backoff.d.cts +38 -0
- package/scripts/lib/lockfile.cjs +177 -0
- package/scripts/lib/lockfile.d.cts +21 -0
- package/scripts/lib/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/prompt-sanitizer/index.ts +435 -0
- package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
- package/scripts/lib/rate-guard.cjs +365 -0
- package/scripts/lib/rate-guard.d.cts +38 -0
- package/scripts/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/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
- package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
- package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
- package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
- package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
- package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
- package/scripts/mcp-servers/gdd-state/server.ts +288 -0
- package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
- package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
- package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
- package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
- package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
- package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
- package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
- package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
- package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
- package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
- package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
- package/scripts/validate-frontmatter.ts +114 -0
- package/scripts/validate-schemas.ts +401 -0
- package/skills/brief/SKILL.md +15 -6
- package/skills/design/SKILL.md +31 -13
- package/skills/explore/SKILL.md +41 -17
- package/skills/health/SKILL.md +15 -4
- package/skills/optimize/SKILL.md +3 -3
- package/skills/pause/SKILL.md +16 -10
- package/skills/plan/SKILL.md +33 -17
- package/skills/progress/SKILL.md +15 -11
- package/skills/resume/SKILL.md +19 -10
- package/skills/settings/SKILL.md +11 -3
- package/skills/todo/SKILL.md +12 -3
- package/skills/verify/SKILL.md +65 -29
- package/hooks/budget-enforcer.js +0 -329
- package/hooks/context-exhaustion.js +0 -127
- package/hooks/gdd-read-injection-scanner.js +0 -39
- package/scripts/aggregate-agent-metrics.js +0 -173
- package/scripts/validate-frontmatter.cjs +0 -68
- package/scripts/validate-schemas.cjs +0 -242
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// scripts/lib/explore-parallel-runner/index.ts — Plan 21-06 (SDK-18).
|
|
2
|
+
//
|
|
3
|
+
// Public surface:
|
|
4
|
+
//
|
|
5
|
+
// run(opts: ExploreRunnerOptions): Promise<ExploreRunnerResult>
|
|
6
|
+
// DEFAULT_MAPPERS — the locked Phase-21 4-mapper roster (frozen).
|
|
7
|
+
// isParallelismSafe, spawnMapper, spawnMappersParallel (from mappers.ts)
|
|
8
|
+
// synthesizeStreaming (from synthesizer.ts)
|
|
9
|
+
// Types (from types.ts) — MapperName, MapperSpec, MapperOutcome,
|
|
10
|
+
// ExploreRunnerOptions, ExploreRunnerResult.
|
|
11
|
+
//
|
|
12
|
+
// Algorithm:
|
|
13
|
+
// 1. specs = opts.mappers ?? DEFAULT_MAPPERS.
|
|
14
|
+
// 2. Partition by isParallelismSafe(spec.agentPath).
|
|
15
|
+
// 3. Run safe mappers via spawnMappersParallel(concurrency).
|
|
16
|
+
// 4. Run unsafe mappers sequentially (tail phase).
|
|
17
|
+
// 5. Run synthesizer via synthesizeStreaming.
|
|
18
|
+
// 6. Aggregate total_usage = sum mapper + synthesizer.
|
|
19
|
+
// 7. Emit logger + explore.runner.* lifecycle events.
|
|
20
|
+
// 8. Return ExploreRunnerResult.
|
|
21
|
+
//
|
|
22
|
+
// Empty specs short-circuits: no mappers spawned, synthesizer skipped,
|
|
23
|
+
// returns an all-zero result.
|
|
24
|
+
|
|
25
|
+
import { resolve as resolvePath } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { getLogger } from '../logger/index.ts';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
isParallelismSafe,
|
|
31
|
+
spawnMapper,
|
|
32
|
+
spawnMappersParallel,
|
|
33
|
+
} from './mappers.ts';
|
|
34
|
+
import { synthesizeStreaming } from './synthesizer.ts';
|
|
35
|
+
import type {
|
|
36
|
+
ExploreRunnerOptions,
|
|
37
|
+
ExploreRunnerResult,
|
|
38
|
+
MapperOutcome,
|
|
39
|
+
MapperSpec,
|
|
40
|
+
} from './types.ts';
|
|
41
|
+
|
|
42
|
+
// Re-exports.
|
|
43
|
+
export type {
|
|
44
|
+
MapperName,
|
|
45
|
+
MapperSpec,
|
|
46
|
+
MapperOutcome,
|
|
47
|
+
ExploreRunnerOptions,
|
|
48
|
+
ExploreRunnerResult,
|
|
49
|
+
} from './types.ts';
|
|
50
|
+
export {
|
|
51
|
+
isParallelismSafe,
|
|
52
|
+
spawnMapper,
|
|
53
|
+
spawnMappersParallel,
|
|
54
|
+
} from './mappers.ts';
|
|
55
|
+
export { synthesizeStreaming } from './synthesizer.ts';
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// DEFAULT_MAPPERS — locked Phase-21 roster
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Locked 4-mapper roster for the explore stage. Frozen end-to-end so
|
|
63
|
+
* consumers can't mutate entries; override via ExploreRunnerOptions.mappers.
|
|
64
|
+
*
|
|
65
|
+
* Agent paths use the exact filenames from `agents/` (as of Phase 21):
|
|
66
|
+
* token-mapper.md, component-taxonomy-mapper.md, a11y-mapper.md,
|
|
67
|
+
* visual-hierarchy-mapper.md.
|
|
68
|
+
*
|
|
69
|
+
* When an agent file is missing, session-runner scope computation
|
|
70
|
+
* gracefully falls through to the stage default (see mappers.ts).
|
|
71
|
+
*/
|
|
72
|
+
export const DEFAULT_MAPPERS: readonly MapperSpec[] = Object.freeze([
|
|
73
|
+
Object.freeze({
|
|
74
|
+
name: 'token' as const,
|
|
75
|
+
agentPath: 'agents/token-mapper.md',
|
|
76
|
+
outputPath: '.design/map/token.md',
|
|
77
|
+
prompt:
|
|
78
|
+
'Enumerate every design token found in the UI source: colors, typography, spacing, radii, shadows, motion durations. Output to .design/map/token.md as a canonical token inventory.',
|
|
79
|
+
}),
|
|
80
|
+
Object.freeze({
|
|
81
|
+
name: 'component-taxonomy' as const,
|
|
82
|
+
agentPath: 'agents/component-taxonomy-mapper.md',
|
|
83
|
+
outputPath: '.design/map/component-taxonomy.md',
|
|
84
|
+
prompt:
|
|
85
|
+
'Enumerate component archetypes and their variants. Output to .design/map/component-taxonomy.md — one entry per archetype with variant list, slot inventory, and usage count.',
|
|
86
|
+
}),
|
|
87
|
+
Object.freeze({
|
|
88
|
+
name: 'a11y' as const,
|
|
89
|
+
agentPath: 'agents/a11y-mapper.md',
|
|
90
|
+
outputPath: '.design/map/a11y.md',
|
|
91
|
+
prompt:
|
|
92
|
+
'WCAG-axis scan: contrast ratios, keyboard navigation, ARIA semantics, focus management, reduced-motion respect. Output to .design/map/a11y.md — one section per axis with findings.',
|
|
93
|
+
}),
|
|
94
|
+
Object.freeze({
|
|
95
|
+
name: 'visual-hierarchy' as const,
|
|
96
|
+
agentPath: 'agents/visual-hierarchy-mapper.md',
|
|
97
|
+
outputPath: '.design/map/visual-hierarchy.md',
|
|
98
|
+
prompt:
|
|
99
|
+
'Describe z-order, focal points, and attention grammar. Output to .design/map/visual-hierarchy.md — one section per surface describing layering, emphasis, and scan path.',
|
|
100
|
+
}),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// run — main orchestrator
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Spawn the 4 mapper sessions (parallel) + the synthesizer (sequential
|
|
109
|
+
* after mappers become stable), aggregate usage, emit lifecycle events,
|
|
110
|
+
* return terminal ExploreRunnerResult.
|
|
111
|
+
*
|
|
112
|
+
* Contract:
|
|
113
|
+
* * Never throws. All failure modes land as outcomes / synth status.
|
|
114
|
+
* * Individual mapper errors do NOT abort other mappers.
|
|
115
|
+
* * parallelism_safe: false mappers run serially AFTER the safe batch.
|
|
116
|
+
* * total_usage aggregates mappers + synthesizer.
|
|
117
|
+
*/
|
|
118
|
+
export async function run(
|
|
119
|
+
opts: ExploreRunnerOptions,
|
|
120
|
+
): Promise<ExploreRunnerResult> {
|
|
121
|
+
const specs: readonly MapperSpec[] = opts.mappers ?? DEFAULT_MAPPERS;
|
|
122
|
+
const cwd: string = opts.cwd ?? process.cwd();
|
|
123
|
+
const concurrency: number = opts.concurrency ?? 4;
|
|
124
|
+
|
|
125
|
+
const logger = getLogger().child('explore.runner');
|
|
126
|
+
|
|
127
|
+
const outputPath: string = resolvePath(cwd, '.design/DESIGN-PATTERNS.md');
|
|
128
|
+
|
|
129
|
+
logger.info('explore.runner.started', {
|
|
130
|
+
mapper_count: specs.length,
|
|
131
|
+
concurrency,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Empty-spec short-circuit — no mappers, no synthesizer, zero usage.
|
|
135
|
+
if (specs.length === 0) {
|
|
136
|
+
logger.info('explore.runner.completed', {
|
|
137
|
+
parallel_count: 0,
|
|
138
|
+
serial_count: 0,
|
|
139
|
+
synthesizer_status: 'skipped',
|
|
140
|
+
total_usd_cost: 0,
|
|
141
|
+
});
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
mappers: Object.freeze([]),
|
|
144
|
+
synthesizer: Object.freeze({
|
|
145
|
+
status: 'skipped' as const,
|
|
146
|
+
output_path: outputPath,
|
|
147
|
+
usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
|
|
148
|
+
files_fed: Object.freeze([]),
|
|
149
|
+
}),
|
|
150
|
+
parallel_count: 0,
|
|
151
|
+
serial_count: 0,
|
|
152
|
+
total_usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Partition specs by parallelism_safe frontmatter ---------------------
|
|
157
|
+
const safeSpecs: MapperSpec[] = [];
|
|
158
|
+
const serialSpecs: MapperSpec[] = [];
|
|
159
|
+
for (const spec of specs) {
|
|
160
|
+
const resolvedAgentPath: string = resolvePath(cwd, spec.agentPath);
|
|
161
|
+
if (isParallelismSafe(resolvedAgentPath)) {
|
|
162
|
+
safeSpecs.push(spec);
|
|
163
|
+
} else {
|
|
164
|
+
serialSpecs.push(spec);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- Parallel batch ------------------------------------------------------
|
|
169
|
+
const parallelOutcomes: readonly MapperOutcome[] =
|
|
170
|
+
safeSpecs.length > 0
|
|
171
|
+
? await spawnMappersParallel(safeSpecs, {
|
|
172
|
+
concurrency,
|
|
173
|
+
budget: opts.budget,
|
|
174
|
+
maxTurns: opts.maxTurnsPerMapper,
|
|
175
|
+
cwd,
|
|
176
|
+
...(opts.runOverride !== undefined ? { runOverride: opts.runOverride } : {}),
|
|
177
|
+
})
|
|
178
|
+
: Object.freeze([]);
|
|
179
|
+
|
|
180
|
+
for (const o of parallelOutcomes) {
|
|
181
|
+
logger.info('explore.runner.mapper_done', {
|
|
182
|
+
mapper: o.name,
|
|
183
|
+
status: o.status,
|
|
184
|
+
duration_ms: o.duration_ms,
|
|
185
|
+
output_exists: o.output_exists,
|
|
186
|
+
output_bytes: o.output_bytes,
|
|
187
|
+
mode: 'parallel',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- Serial tail --------------------------------------------------------
|
|
192
|
+
const serialOutcomes: MapperOutcome[] = [];
|
|
193
|
+
for (const spec of serialSpecs) {
|
|
194
|
+
const spawnOpts: Parameters<typeof spawnMapper>[1] = {
|
|
195
|
+
budget: opts.budget,
|
|
196
|
+
maxTurns: opts.maxTurnsPerMapper,
|
|
197
|
+
cwd,
|
|
198
|
+
...(opts.runOverride !== undefined ? { runOverride: opts.runOverride } : {}),
|
|
199
|
+
};
|
|
200
|
+
const outcome = await spawnMapper(spec, spawnOpts);
|
|
201
|
+
serialOutcomes.push(outcome);
|
|
202
|
+
logger.info('explore.runner.mapper_done', {
|
|
203
|
+
mapper: outcome.name,
|
|
204
|
+
status: outcome.status,
|
|
205
|
+
duration_ms: outcome.duration_ms,
|
|
206
|
+
output_exists: outcome.output_exists,
|
|
207
|
+
output_bytes: outcome.output_bytes,
|
|
208
|
+
mode: 'serial',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Merge outcomes in ORIGINAL spec order -------------------------------
|
|
213
|
+
//
|
|
214
|
+
// Callers rely on `.mappers[i]` pairing with `opts.mappers[i]` (or
|
|
215
|
+
// DEFAULT_MAPPERS[i]). We rebuild by indexing the name→outcome map.
|
|
216
|
+
const byName: Map<string, MapperOutcome> = new Map();
|
|
217
|
+
for (const o of parallelOutcomes) byName.set(o.name, o);
|
|
218
|
+
for (const o of serialOutcomes) byName.set(o.name, o);
|
|
219
|
+
const mergedOutcomes: MapperOutcome[] = specs.map((s) => {
|
|
220
|
+
const o = byName.get(s.name);
|
|
221
|
+
if (o === undefined) {
|
|
222
|
+
// Shouldn't happen unless partitioning dropped a spec. Surface
|
|
223
|
+
// as a synthetic error outcome rather than throwing.
|
|
224
|
+
return Object.freeze({
|
|
225
|
+
name: s.name,
|
|
226
|
+
status: 'error',
|
|
227
|
+
output_exists: false,
|
|
228
|
+
output_bytes: 0,
|
|
229
|
+
usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
|
|
230
|
+
duration_ms: 0,
|
|
231
|
+
error: Object.freeze({
|
|
232
|
+
code: 'PARTITION_LOST',
|
|
233
|
+
message: `mapper ${s.name} was not executed by either batch`,
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return o;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// --- Synthesizer --------------------------------------------------------
|
|
241
|
+
logger.info('explore.runner.synthesizer_started', {
|
|
242
|
+
mappers_ready: mergedOutcomes.filter((m) => m.output_exists).length,
|
|
243
|
+
mappers_total: mergedOutcomes.length,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const synthResult = await synthesizeStreaming({
|
|
247
|
+
mapperNames: specs.map((s) => s.name),
|
|
248
|
+
mapperOutputPaths: specs.map((s) => s.outputPath),
|
|
249
|
+
synthesizerPrompt: opts.synthesizerPrompt,
|
|
250
|
+
budget: opts.synthesizerBudget,
|
|
251
|
+
maxTurns: opts.synthesizerMaxTurns,
|
|
252
|
+
cwd,
|
|
253
|
+
...(opts.runOverride !== undefined ? { runOverride: opts.runOverride } : {}),
|
|
254
|
+
...(opts.pollIntervalMs !== undefined ? { pollIntervalMs: opts.pollIntervalMs } : {}),
|
|
255
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// --- Aggregate usage ----------------------------------------------------
|
|
259
|
+
let totalInput = synthResult.usage.input_tokens;
|
|
260
|
+
let totalOutput = synthResult.usage.output_tokens;
|
|
261
|
+
let totalCost = synthResult.usage.usd_cost;
|
|
262
|
+
for (const m of mergedOutcomes) {
|
|
263
|
+
totalInput += m.usage.input_tokens;
|
|
264
|
+
totalOutput += m.usage.output_tokens;
|
|
265
|
+
totalCost += m.usage.usd_cost;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
logger.info('explore.runner.completed', {
|
|
269
|
+
parallel_count: safeSpecs.length,
|
|
270
|
+
serial_count: serialSpecs.length,
|
|
271
|
+
synthesizer_status: synthResult.status,
|
|
272
|
+
total_usd_cost: totalCost,
|
|
273
|
+
total_input_tokens: totalInput,
|
|
274
|
+
total_output_tokens: totalOutput,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return Object.freeze({
|
|
278
|
+
mappers: Object.freeze(mergedOutcomes),
|
|
279
|
+
synthesizer: Object.freeze({
|
|
280
|
+
status: synthResult.status,
|
|
281
|
+
output_path: synthResult.output_path,
|
|
282
|
+
usage: synthResult.usage,
|
|
283
|
+
files_fed: synthResult.files_fed,
|
|
284
|
+
...(synthResult.error !== undefined ? { error: synthResult.error } : {}),
|
|
285
|
+
}),
|
|
286
|
+
parallel_count: safeSpecs.length,
|
|
287
|
+
serial_count: serialSpecs.length,
|
|
288
|
+
total_usage: {
|
|
289
|
+
input_tokens: totalInput,
|
|
290
|
+
output_tokens: totalOutput,
|
|
291
|
+
usd_cost: totalCost,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|
|
@@ -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
|
+
}
|