@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.
Files changed (140) hide show
  1. package/.claude-plugin/marketplace.json +11 -14
  2. package/.claude-plugin/plugin.json +9 -32
  3. package/CHANGELOG.md +138 -0
  4. package/README.md +54 -1
  5. package/agents/design-reflector.md +13 -0
  6. package/bin/gdd-sdk +55 -0
  7. package/connections/connections.md +3 -0
  8. package/connections/figma.md +2 -0
  9. package/connections/gdd-state.md +186 -0
  10. package/hooks/budget-enforcer.ts +716 -0
  11. package/hooks/context-exhaustion.ts +251 -0
  12. package/hooks/gdd-read-injection-scanner.ts +172 -0
  13. package/hooks/hooks.json +3 -3
  14. package/package.json +32 -51
  15. package/reference/codex-tools.md +53 -0
  16. package/reference/config-schema.md +2 -2
  17. package/reference/error-recovery.md +58 -0
  18. package/reference/gemini-tools.md +53 -0
  19. package/reference/registry.json +21 -0
  20. package/reference/schemas/budget.schema.json +42 -0
  21. package/reference/schemas/events.schema.json +55 -0
  22. package/reference/schemas/generated.d.ts +419 -0
  23. package/reference/schemas/iteration-budget.schema.json +36 -0
  24. package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
  25. package/reference/schemas/rate-limits.schema.json +31 -0
  26. package/scripts/aggregate-agent-metrics.ts +282 -0
  27. package/scripts/codegen-schema-types.ts +149 -0
  28. package/scripts/e2e/run-headless.ts +514 -0
  29. package/scripts/lib/cli/commands/audit.ts +382 -0
  30. package/scripts/lib/cli/commands/init.ts +217 -0
  31. package/scripts/lib/cli/commands/query.ts +329 -0
  32. package/scripts/lib/cli/commands/run.ts +656 -0
  33. package/scripts/lib/cli/commands/stage.ts +468 -0
  34. package/scripts/lib/cli/index.ts +167 -0
  35. package/scripts/lib/cli/parse-args.ts +336 -0
  36. package/scripts/lib/context-engine/index.ts +116 -0
  37. package/scripts/lib/context-engine/manifest.ts +69 -0
  38. package/scripts/lib/context-engine/truncate.ts +282 -0
  39. package/scripts/lib/context-engine/types.ts +59 -0
  40. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  41. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  42. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  43. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  44. package/scripts/lib/error-classifier.cjs +232 -0
  45. package/scripts/lib/error-classifier.d.cts +44 -0
  46. package/scripts/lib/event-stream/emitter.ts +88 -0
  47. package/scripts/lib/event-stream/index.ts +164 -0
  48. package/scripts/lib/event-stream/types.ts +127 -0
  49. package/scripts/lib/event-stream/writer.ts +154 -0
  50. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  51. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  52. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  53. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  54. package/scripts/lib/gdd-errors/classification.ts +124 -0
  55. package/scripts/lib/gdd-errors/index.ts +218 -0
  56. package/scripts/lib/gdd-state/gates.ts +216 -0
  57. package/scripts/lib/gdd-state/index.ts +167 -0
  58. package/scripts/lib/gdd-state/lockfile.ts +232 -0
  59. package/scripts/lib/gdd-state/mutator.ts +574 -0
  60. package/scripts/lib/gdd-state/parser.ts +523 -0
  61. package/scripts/lib/gdd-state/types.ts +179 -0
  62. package/scripts/lib/harness/detect.ts +90 -0
  63. package/scripts/lib/harness/index.ts +64 -0
  64. package/scripts/lib/harness/tool-map.ts +142 -0
  65. package/scripts/lib/init-runner/index.ts +396 -0
  66. package/scripts/lib/init-runner/researchers.ts +245 -0
  67. package/scripts/lib/init-runner/scaffold.ts +224 -0
  68. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  69. package/scripts/lib/init-runner/types.ts +143 -0
  70. package/scripts/lib/iteration-budget.cjs +205 -0
  71. package/scripts/lib/iteration-budget.d.cts +32 -0
  72. package/scripts/lib/jittered-backoff.cjs +112 -0
  73. package/scripts/lib/jittered-backoff.d.cts +38 -0
  74. package/scripts/lib/lockfile.cjs +177 -0
  75. package/scripts/lib/lockfile.d.cts +21 -0
  76. package/scripts/lib/logger/index.ts +251 -0
  77. package/scripts/lib/logger/sinks.ts +269 -0
  78. package/scripts/lib/logger/types.ts +110 -0
  79. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  80. package/scripts/lib/pipeline-runner/index.ts +527 -0
  81. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  82. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  83. package/scripts/lib/pipeline-runner/types.ts +183 -0
  84. package/scripts/lib/prompt-sanitizer/index.ts +435 -0
  85. package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
  86. package/scripts/lib/rate-guard.cjs +365 -0
  87. package/scripts/lib/rate-guard.d.cts +38 -0
  88. package/scripts/lib/session-runner/errors.ts +406 -0
  89. package/scripts/lib/session-runner/index.ts +715 -0
  90. package/scripts/lib/session-runner/transcript.ts +189 -0
  91. package/scripts/lib/session-runner/types.ts +144 -0
  92. package/scripts/lib/tool-scoping/index.ts +219 -0
  93. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  94. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  95. package/scripts/lib/tool-scoping/types.ts +77 -0
  96. package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
  97. package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
  98. package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
  99. package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
  100. package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
  101. package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
  102. package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
  103. package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
  104. package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
  105. package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
  106. package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
  107. package/scripts/mcp-servers/gdd-state/server.ts +288 -0
  108. package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
  109. package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
  110. package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
  111. package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
  112. package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
  113. package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
  114. package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
  115. package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
  116. package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
  117. package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
  118. package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
  119. package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
  120. package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
  121. package/scripts/validate-frontmatter.ts +114 -0
  122. package/scripts/validate-schemas.ts +401 -0
  123. package/skills/brief/SKILL.md +15 -6
  124. package/skills/design/SKILL.md +31 -13
  125. package/skills/explore/SKILL.md +41 -17
  126. package/skills/health/SKILL.md +15 -4
  127. package/skills/optimize/SKILL.md +3 -3
  128. package/skills/pause/SKILL.md +16 -10
  129. package/skills/plan/SKILL.md +33 -17
  130. package/skills/progress/SKILL.md +15 -11
  131. package/skills/resume/SKILL.md +19 -10
  132. package/skills/settings/SKILL.md +11 -3
  133. package/skills/todo/SKILL.md +12 -3
  134. package/skills/verify/SKILL.md +65 -29
  135. package/hooks/budget-enforcer.js +0 -329
  136. package/hooks/context-exhaustion.js +0 -127
  137. package/hooks/gdd-read-injection-scanner.js +0 -39
  138. package/scripts/aggregate-agent-metrics.js +0 -173
  139. package/scripts/validate-frontmatter.cjs +0 -68
  140. 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
+ }