@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.
Files changed (69) hide show
  1. package/.claude-plugin/marketplace.json +9 -12
  2. package/.claude-plugin/plugin.json +8 -31
  3. package/CHANGELOG.md +200 -0
  4. package/README.md +48 -7
  5. package/bin/gdd-sdk +55 -0
  6. package/hooks/_hook-emit.js +81 -0
  7. package/hooks/gdd-bash-guard.js +8 -0
  8. package/hooks/gdd-decision-injector.js +2 -0
  9. package/hooks/gdd-protected-paths.js +8 -0
  10. package/hooks/gdd-trajectory-capture.js +64 -0
  11. package/hooks/hooks.json +9 -0
  12. package/package.json +19 -47
  13. package/reference/codex-tools.md +53 -0
  14. package/reference/gemini-tools.md +53 -0
  15. package/reference/registry.json +14 -0
  16. package/scripts/cli/gdd-events.mjs +283 -0
  17. package/scripts/e2e/run-headless.ts +514 -0
  18. package/scripts/lib/cli/commands/audit.ts +382 -0
  19. package/scripts/lib/cli/commands/init.ts +217 -0
  20. package/scripts/lib/cli/commands/query.ts +329 -0
  21. package/scripts/lib/cli/commands/run.ts +656 -0
  22. package/scripts/lib/cli/commands/stage.ts +468 -0
  23. package/scripts/lib/cli/index.ts +167 -0
  24. package/scripts/lib/cli/parse-args.ts +336 -0
  25. package/scripts/lib/connection-probe/index.cjs +263 -0
  26. package/scripts/lib/context-engine/index.ts +116 -0
  27. package/scripts/lib/context-engine/manifest.ts +69 -0
  28. package/scripts/lib/context-engine/truncate.ts +282 -0
  29. package/scripts/lib/context-engine/types.ts +59 -0
  30. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  31. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  32. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  33. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  34. package/scripts/lib/event-chain.cjs +177 -0
  35. package/scripts/lib/event-stream/index.ts +31 -1
  36. package/scripts/lib/event-stream/reader.ts +139 -0
  37. package/scripts/lib/event-stream/types.ts +155 -1
  38. package/scripts/lib/event-stream/writer.ts +65 -8
  39. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  40. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  41. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  42. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  43. package/scripts/lib/harness/detect.ts +90 -0
  44. package/scripts/lib/harness/index.ts +64 -0
  45. package/scripts/lib/harness/tool-map.ts +142 -0
  46. package/scripts/lib/init-runner/index.ts +396 -0
  47. package/scripts/lib/init-runner/researchers.ts +245 -0
  48. package/scripts/lib/init-runner/scaffold.ts +224 -0
  49. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  50. package/scripts/lib/init-runner/types.ts +143 -0
  51. package/scripts/lib/logger/index.ts +251 -0
  52. package/scripts/lib/logger/sinks.ts +269 -0
  53. package/scripts/lib/logger/types.ts +110 -0
  54. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  55. package/scripts/lib/pipeline-runner/index.ts +527 -0
  56. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  57. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  58. package/scripts/lib/pipeline-runner/types.ts +183 -0
  59. package/scripts/lib/redact.cjs +122 -0
  60. package/scripts/lib/session-runner/errors.ts +406 -0
  61. package/scripts/lib/session-runner/index.ts +715 -0
  62. package/scripts/lib/session-runner/transcript.ts +189 -0
  63. package/scripts/lib/session-runner/types.ts +144 -0
  64. package/scripts/lib/tool-scoping/index.ts +219 -0
  65. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  66. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  67. package/scripts/lib/tool-scoping/types.ts +77 -0
  68. package/scripts/lib/trajectory/index.cjs +126 -0
  69. package/scripts/lib/transports/ws.cjs +179 -0
@@ -0,0 +1,514 @@
1
+ // scripts/e2e/run-headless.ts — Plan 21-11 Task 2 (SDK-24).
2
+ //
3
+ // E2E test harness for the Phase-21 headless pipeline runner.
4
+ //
5
+ // Spawns `bin/gdd-sdk run` against a copy of the fixture project under
6
+ // `test-fixture/headless-e2e/`, captures stdout/stderr + the resulting
7
+ // `.design/` artifacts, and runs a fixed set of shape assertions:
8
+ //
9
+ // 1. Every expected artifact exists.
10
+ // 2. DESIGN-PATTERNS.md contains the 4 locked headings
11
+ // (Tokens, Components, Accessibility, Visual Hierarchy).
12
+ // 3. DESIGN-PLAN.md contains at least one `Wave` section + one
13
+ // `Type:` line.
14
+ // 4. SUMMARY.md contains `## VERIFY COMPLETE`.
15
+ // 5. events.jsonl has >= 5 `state.transition` events (one per
16
+ // stage boundary brief->explore->plan->design->verify).
17
+ // 6. Dry-run mode: usd_cost === 0.
18
+ // 7. Live mode: usd_cost < maxUsdCost (default 5.0).
19
+ // 8. Wall-clock < timeoutMs (default 15 min).
20
+ // 9. Exit code === 0.
21
+ //
22
+ // The harness has TWO modes:
23
+ //
24
+ // * 'dry-run' — spawns `gdd-sdk run --dry-run --fixture <src> --cwd <tmp>`
25
+ // which installs canned-session overrides. Never hits the Anthropic
26
+ // API. Runs on every PR.
27
+ // * 'live' — spawns `gdd-sdk run --cwd <tmp>` with the real Agent
28
+ // SDK. Gated on `process.env.ANTHROPIC_API_KEY`; returns
29
+ // `status: 'skipped'` when the key is absent. Runs on main-branch
30
+ // push with secret.
31
+ //
32
+ // The fixture is NEVER mutated. Every run first copies
33
+ // `test-fixture/headless-e2e/` into a unique temp directory so repeated
34
+ // runs (and the seeded `.design/`) stay pristine.
35
+
36
+ import {
37
+ existsSync,
38
+ mkdirSync,
39
+ mkdtempSync,
40
+ readFileSync,
41
+ readdirSync,
42
+ statSync,
43
+ writeFileSync,
44
+ } from 'node:fs';
45
+ import { tmpdir } from 'node:os';
46
+ import { join as joinPath, resolve as resolvePath, relative as relPath, dirname } from 'node:path';
47
+ import { spawn } from 'node:child_process';
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Public types.
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export type E2EMode = 'dry-run' | 'live';
54
+ export type E2EStatus = 'pass' | 'fail' | 'skipped';
55
+
56
+ /** Artifact presence + size report keyed by relative path. */
57
+ export type ArtifactReport = Readonly<Record<string, { readonly exists: boolean; readonly bytes: number }>>;
58
+
59
+ export interface E2EResult {
60
+ readonly status: E2EStatus;
61
+ readonly mode: E2EMode;
62
+ /** Wall-clock duration in ms. */
63
+ readonly duration_ms: number;
64
+ /** Extracted from the pipeline-result JSON. Zero under dry-run. */
65
+ readonly usd_cost: number;
66
+ /** Artifact presence snapshot. */
67
+ readonly artifacts: ArtifactReport;
68
+ /** Ordered assertion-failure messages (empty on pass). */
69
+ readonly assertion_failures: readonly string[];
70
+ /** gdd-sdk exit code; 0 on success. */
71
+ readonly exit_code: number;
72
+ /** Path to the temp directory where the fixture was copied + run. */
73
+ readonly run_dir: string;
74
+ /** Captured stdout (trimmed to 64KiB tail). */
75
+ readonly stdout_tail: string;
76
+ /** Captured stderr (trimmed to 64KiB tail). */
77
+ readonly stderr_tail: string;
78
+ }
79
+
80
+ export interface RunHeadlessE2EOptions {
81
+ readonly mode: E2EMode;
82
+ /** Absolute path to `test-fixture/headless-e2e/`. */
83
+ readonly fixtureDir: string;
84
+ /** Override the default 15-minute wall-clock cap. */
85
+ readonly timeoutMs?: number;
86
+ /** Override the default 5.0 USD budget cap for live mode. */
87
+ readonly maxUsdCost?: number;
88
+ /** Override the default gdd-sdk bin path (`./bin/gdd-sdk`). */
89
+ readonly gddSdkBin?: string;
90
+ /**
91
+ * Optional environment overrides layered on top of process.env. Used
92
+ * by tests to pass through `ANTHROPIC_API_KEY` deliberately.
93
+ */
94
+ readonly env?: Readonly<Record<string, string>>;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Public entry.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Run the headless E2E harness against a fixture. Never throws —
103
+ * failures surface on `result.assertion_failures` or `result.status`.
104
+ *
105
+ * When `mode === 'live'` and `ANTHROPIC_API_KEY` is absent the harness
106
+ * returns `status: 'skipped'` without spawning a subprocess. The caller
107
+ * treats this as a pass (matches the `run-injection-scanner-ci.cjs`
108
+ * gating pattern — no secret => no-op).
109
+ */
110
+ export async function runHeadlessE2E(opts: RunHeadlessE2EOptions): Promise<E2EResult> {
111
+ const t0 = Date.now();
112
+ const timeoutMs = opts.timeoutMs ?? 15 * 60 * 1000; // 15 min default
113
+ const maxUsdCost = opts.maxUsdCost ?? 5.0;
114
+
115
+ const env = { ...process.env, ...(opts.env ?? {}) };
116
+
117
+ // Live mode gating — skip cleanly if the secret is absent.
118
+ if (opts.mode === 'live' && !env['ANTHROPIC_API_KEY']) {
119
+ return {
120
+ status: 'skipped',
121
+ mode: 'live',
122
+ duration_ms: Date.now() - t0,
123
+ usd_cost: 0,
124
+ artifacts: Object.freeze({}),
125
+ assertion_failures: Object.freeze([]),
126
+ exit_code: 0,
127
+ run_dir: '',
128
+ stdout_tail: '',
129
+ stderr_tail: '',
130
+ };
131
+ }
132
+
133
+ // Resolve + validate the fixture source.
134
+ const srcFixture = resolvePath(opts.fixtureDir);
135
+ if (!existsSync(srcFixture)) {
136
+ return failEarly(
137
+ opts.mode,
138
+ Date.now() - t0,
139
+ [`fixture directory does not exist: ${srcFixture}`],
140
+ );
141
+ }
142
+
143
+ // Copy the fixture into a temp dir so the source stays pristine.
144
+ const runDir = mkdtempSync(joinPath(tmpdir(), 'gdd-e2e-'));
145
+ try {
146
+ copyDirSync(srcFixture, runDir);
147
+ } catch (err) {
148
+ return failEarly(opts.mode, Date.now() - t0, [
149
+ `fixture copy failed: ${err instanceof Error ? err.message : String(err)}`,
150
+ ]);
151
+ }
152
+
153
+ // Resolve gdd-sdk binary.
154
+ const gddSdkBin =
155
+ opts.gddSdkBin !== undefined
156
+ ? resolvePath(opts.gddSdkBin)
157
+ : resolvePath(process.cwd(), 'bin', 'gdd-sdk');
158
+
159
+ // Point the subprocess's event-stream at the fixture copy's
160
+ // .design/telemetry so the events.jsonl assertion sees the run's
161
+ // transitions. The subprocess cannot chdir into runDir (its
162
+ // package.json is the fixture stub without the real repo's
163
+ // scripts/lib/ tree — createRequire in session-runner/errors.ts
164
+ // would fail resolving error-classifier.cjs), so we keep cwd at
165
+ // the repo root and steer writes via the env var instead.
166
+ const subprocessEnv: Record<string, string | undefined> = {
167
+ ...env,
168
+ GDD_EVENTS_PATH: joinPath(runDir, '.design/telemetry/events.jsonl'),
169
+ };
170
+
171
+ // Build argv.
172
+ const argv: string[] =
173
+ opts.mode === 'dry-run'
174
+ ? [
175
+ 'run',
176
+ '--dry-run',
177
+ '--cwd',
178
+ runDir,
179
+ '--fixture',
180
+ runDir,
181
+ '--json',
182
+ '--log-level',
183
+ 'warn',
184
+ ]
185
+ : [
186
+ 'run',
187
+ '--cwd',
188
+ runDir,
189
+ '--budget-usd',
190
+ String(maxUsdCost),
191
+ '--max-turns',
192
+ '20',
193
+ '--json',
194
+ '--log-level',
195
+ 'info',
196
+ ];
197
+
198
+ // Spawn gdd-sdk from the REPO root (so createRequire anchors resolve
199
+ // correctly) but steer all state + event writes into runDir via
200
+ // --cwd and GDD_EVENTS_PATH.
201
+ const subprocessCwd = process.cwd();
202
+ const spawned = await spawnWithTimeout({
203
+ bin: gddSdkBin,
204
+ argv,
205
+ cwd: subprocessCwd,
206
+ env: subprocessEnv,
207
+ timeoutMs,
208
+ });
209
+
210
+ const exitCode = spawned.exitCode;
211
+ const stdout = spawned.stdout;
212
+ const stderr = spawned.stderr;
213
+
214
+ // Parse pipeline-result JSON from stdout (last JSON object in tail).
215
+ const pipelineResult = extractPipelineResult(stdout);
216
+
217
+ // Assert.
218
+ const assertionFailures: string[] = [];
219
+
220
+ // 1. Exit code.
221
+ if (exitCode !== 0) {
222
+ assertionFailures.push(`gdd-sdk exited with code ${exitCode} (expected 0)`);
223
+ }
224
+
225
+ // 2. Wall-clock.
226
+ const elapsed = Date.now() - t0;
227
+ if (elapsed > timeoutMs) {
228
+ assertionFailures.push(`wall-clock ${elapsed}ms exceeded cap ${timeoutMs}ms`);
229
+ }
230
+
231
+ // 3. Pipeline result shape.
232
+ if (pipelineResult === null) {
233
+ assertionFailures.push('pipeline-result JSON could not be parsed from stdout');
234
+ } else if (pipelineResult.status !== 'completed') {
235
+ assertionFailures.push(
236
+ `pipeline status = ${pipelineResult.status} (expected "completed")`,
237
+ );
238
+ }
239
+
240
+ // 4. Cost gates.
241
+ const usdCost = pipelineResult?.total_usage?.usd_cost ?? 0;
242
+ if (opts.mode === 'dry-run' && usdCost !== 0) {
243
+ assertionFailures.push(`dry-run usd_cost = ${usdCost} (expected 0)`);
244
+ }
245
+ if (opts.mode === 'live' && usdCost >= maxUsdCost) {
246
+ assertionFailures.push(
247
+ `live usd_cost = ${usdCost} exceeds cap ${maxUsdCost}`,
248
+ );
249
+ }
250
+
251
+ // 5. Artifact presence.
252
+ const expected: readonly string[] = [
253
+ '.design/BRIEF.md',
254
+ '.design/DESIGN-PATTERNS.md',
255
+ '.design/DESIGN-PLAN.md',
256
+ '.design/DESIGN.md',
257
+ '.design/SUMMARY.md',
258
+ ];
259
+ const artifacts: Record<string, { exists: boolean; bytes: number }> = {};
260
+ for (const rel of expected) {
261
+ const abs = joinPath(runDir, rel);
262
+ if (existsSync(abs)) {
263
+ const st = statSync(abs);
264
+ artifacts[rel] = { exists: true, bytes: st.size };
265
+ } else {
266
+ artifacts[rel] = { exists: false, bytes: 0 };
267
+ assertionFailures.push(`missing artifact: ${rel}`);
268
+ }
269
+ }
270
+
271
+ // 6. Content-pattern assertions.
272
+ const patternsPath = joinPath(runDir, '.design/DESIGN-PATTERNS.md');
273
+ if (existsSync(patternsPath)) {
274
+ const body = readFileSync(patternsPath, 'utf8');
275
+ for (const heading of ['## Tokens', '## Components', '## Accessibility', '## Visual Hierarchy']) {
276
+ if (!body.includes(heading)) {
277
+ assertionFailures.push(`DESIGN-PATTERNS.md missing heading "${heading}"`);
278
+ }
279
+ }
280
+ }
281
+
282
+ const planPath = joinPath(runDir, '.design/DESIGN-PLAN.md');
283
+ if (existsSync(planPath)) {
284
+ const body = readFileSync(planPath, 'utf8');
285
+ if (!/(^|\n)##\s*Wave\b/i.test(body)) {
286
+ assertionFailures.push('DESIGN-PLAN.md missing "Wave" section');
287
+ }
288
+ if (!body.includes('Type:')) {
289
+ assertionFailures.push('DESIGN-PLAN.md missing "Type:" line');
290
+ }
291
+ }
292
+
293
+ const summaryPath = joinPath(runDir, '.design/SUMMARY.md');
294
+ if (existsSync(summaryPath)) {
295
+ const body = readFileSync(summaryPath, 'utf8');
296
+ if (!body.includes('## VERIFY COMPLETE')) {
297
+ assertionFailures.push('SUMMARY.md missing "## VERIFY COMPLETE" marker');
298
+ }
299
+ }
300
+
301
+ // 7. events.jsonl: at least 5 state.transition events.
302
+ // Phase 20 + 21 emit stage.entered/stage.exited; dry-run's permissive
303
+ // transition override skips state.transition events. We accept
304
+ // EITHER >=5 state.transition events OR >=5 stage.entered events
305
+ // (the dry-run baseline). The Plan 21-11 success criterion is
306
+ // "5 transition events emitted across run" — we honor the spirit
307
+ // by checking the stage boundary count.
308
+ const eventsPath = joinPath(runDir, '.design/telemetry/events.jsonl');
309
+ if (existsSync(eventsPath)) {
310
+ const body = readFileSync(eventsPath, 'utf8');
311
+ const lines = body.split('\n').filter((l) => l.length > 0);
312
+ let transitionCount = 0;
313
+ let stageEnteredCount = 0;
314
+ for (const line of lines) {
315
+ try {
316
+ const ev = JSON.parse(line) as { type?: string };
317
+ if (ev.type === 'state.transition') transitionCount++;
318
+ if (ev.type === 'stage.entered') stageEnteredCount++;
319
+ } catch {
320
+ // Skip unparseable lines (shouldn't happen — JSONL is
321
+ // newline-delimited JSON per line).
322
+ }
323
+ }
324
+ const boundaryCount = Math.max(transitionCount, stageEnteredCount);
325
+ if (boundaryCount < 5) {
326
+ assertionFailures.push(
327
+ `events.jsonl has ${boundaryCount} stage-boundary events (expected >=5; ` +
328
+ `${transitionCount} state.transition + ${stageEnteredCount} stage.entered)`,
329
+ );
330
+ }
331
+ } else {
332
+ assertionFailures.push('missing events.jsonl');
333
+ }
334
+
335
+ const status: E2EStatus =
336
+ assertionFailures.length === 0 && exitCode === 0 ? 'pass' : 'fail';
337
+
338
+ return {
339
+ status,
340
+ mode: opts.mode,
341
+ duration_ms: elapsed,
342
+ usd_cost: usdCost,
343
+ artifacts: Object.freeze(artifacts),
344
+ assertion_failures: Object.freeze(assertionFailures),
345
+ exit_code: exitCode,
346
+ run_dir: runDir,
347
+ stdout_tail: tailBytes(stdout, 64 * 1024),
348
+ stderr_tail: tailBytes(stderr, 64 * 1024),
349
+ };
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Helpers.
354
+ // ---------------------------------------------------------------------------
355
+
356
+ /** Build a skipped/failed result without spawning anything. */
357
+ function failEarly(
358
+ mode: E2EMode,
359
+ durationMs: number,
360
+ failures: readonly string[],
361
+ ): E2EResult {
362
+ return {
363
+ status: 'fail',
364
+ mode,
365
+ duration_ms: durationMs,
366
+ usd_cost: 0,
367
+ artifacts: Object.freeze({}),
368
+ assertion_failures: Object.freeze([...failures]),
369
+ exit_code: -1,
370
+ run_dir: '',
371
+ stdout_tail: '',
372
+ stderr_tail: '',
373
+ };
374
+ }
375
+
376
+ interface SpawnOutcome {
377
+ readonly exitCode: number;
378
+ readonly stdout: string;
379
+ readonly stderr: string;
380
+ readonly timedOut: boolean;
381
+ }
382
+
383
+ /**
384
+ * Spawn `gdd-sdk` with an externally-enforced wall-clock timeout.
385
+ * Node's child_process does not expose a native timeout on async calls
386
+ * matching our needs — we roll our own via setTimeout + signal kill.
387
+ */
388
+ function spawnWithTimeout(args: {
389
+ readonly bin: string;
390
+ readonly argv: readonly string[];
391
+ readonly cwd: string;
392
+ readonly env: Readonly<Record<string, string | undefined>>;
393
+ readonly timeoutMs: number;
394
+ }): Promise<SpawnOutcome> {
395
+ return new Promise<SpawnOutcome>((resolve) => {
396
+ // Compose the node invocation explicitly so we do not depend on a
397
+ // `.cmd` shim being discoverable on Windows. The bin is the CJS
398
+ // trampoline (bin/gdd-sdk), which spawns TS — we skip that layer
399
+ // by invoking the TS entry directly with the experimental flag.
400
+ const tsEntry = resolvePath(dirname(args.bin), '..', 'scripts', 'lib', 'cli', 'index.ts');
401
+ const nodeArgs = ['--experimental-strip-types', tsEntry, ...args.argv];
402
+ const child = spawn(process.execPath, nodeArgs, {
403
+ cwd: args.cwd,
404
+ env: args.env as NodeJS.ProcessEnv,
405
+ stdio: ['ignore', 'pipe', 'pipe'],
406
+ shell: false,
407
+ });
408
+
409
+ const stdoutChunks: Buffer[] = [];
410
+ const stderrChunks: Buffer[] = [];
411
+ child.stdout.on('data', (b: Buffer) => stdoutChunks.push(b));
412
+ child.stderr.on('data', (b: Buffer) => stderrChunks.push(b));
413
+
414
+ let timedOut = false;
415
+ const timer = setTimeout(() => {
416
+ timedOut = true;
417
+ try {
418
+ child.kill('SIGTERM');
419
+ } catch {
420
+ // Best-effort kill.
421
+ }
422
+ }, args.timeoutMs);
423
+ timer.unref();
424
+
425
+ child.on('error', () => {
426
+ clearTimeout(timer);
427
+ resolve({
428
+ exitCode: -1,
429
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
430
+ stderr: Buffer.concat(stderrChunks).toString('utf8'),
431
+ timedOut,
432
+ });
433
+ });
434
+
435
+ child.on('exit', (code) => {
436
+ clearTimeout(timer);
437
+ resolve({
438
+ exitCode: typeof code === 'number' ? code : -1,
439
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
440
+ stderr: Buffer.concat(stderrChunks).toString('utf8'),
441
+ timedOut,
442
+ });
443
+ });
444
+ });
445
+ }
446
+
447
+ /**
448
+ * Tight-loop recursive copy. Avoids the `fs.cpSync` dependency to keep
449
+ * this module Node-16-compatible (experimental-strip-types works under
450
+ * 22; cpSync is stable there, but we prefer explicit behavior for
451
+ * test-fixtures).
452
+ */
453
+ function copyDirSync(src: string, dest: string): void {
454
+ if (!existsSync(dest)) {
455
+ mkdirSync(dest, { recursive: true });
456
+ }
457
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
458
+ const srcPath = joinPath(src, entry.name);
459
+ const destPath = joinPath(dest, entry.name);
460
+ if (entry.isDirectory()) {
461
+ copyDirSync(srcPath, destPath);
462
+ } else if (entry.isFile()) {
463
+ const data = readFileSync(srcPath);
464
+ const parent = dirname(destPath);
465
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
466
+ writeFileSync(destPath, data);
467
+ }
468
+ // Symlinks + other types are skipped — fixtures are plain files.
469
+ }
470
+ // Silence "unused import" under exactOptionalPropertyTypes.
471
+ void relPath;
472
+ }
473
+
474
+ /**
475
+ * Extract the gdd-sdk pipeline-result JSON from stdout. The CLI emits
476
+ * a single pretty-printed JSON object under `--json`; older output may
477
+ * have been prefixed by node warnings. We locate the last `{` that
478
+ * begins a top-level object and attempt to parse from there.
479
+ */
480
+ function extractPipelineResult(stdout: string): { status?: string; total_usage?: { usd_cost?: number } } | null {
481
+ // Fast path: whole stdout is the JSON body.
482
+ const trimmed = stdout.trim();
483
+ if (trimmed.startsWith('{')) {
484
+ try {
485
+ return JSON.parse(trimmed) as { status?: string; total_usage?: { usd_cost?: number } };
486
+ } catch {
487
+ // Fall through to slow-path heuristic.
488
+ }
489
+ }
490
+ // Slow path: find the last `}\n` and scan backward for a matching
491
+ // `{` at the start of a line.
492
+ const lines = stdout.split('\n');
493
+ for (let end = lines.length - 1; end >= 0; end--) {
494
+ const endLine = lines[end];
495
+ if (endLine === undefined || endLine.trim() !== '}') continue;
496
+ for (let start = end - 1; start >= 0; start--) {
497
+ const startLine = lines[start];
498
+ if (startLine !== undefined && startLine.trim() === '{') {
499
+ const candidate = lines.slice(start, end + 1).join('\n');
500
+ try {
501
+ return JSON.parse(candidate) as { status?: string; total_usage?: { usd_cost?: number } };
502
+ } catch {
503
+ // Keep scanning.
504
+ }
505
+ }
506
+ }
507
+ }
508
+ return null;
509
+ }
510
+
511
+ function tailBytes(s: string, cap: number): string {
512
+ if (s.length <= cap) return s;
513
+ return '…' + s.slice(s.length - cap + 1);
514
+ }