@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,245 @@
1
+ // scripts/lib/init-runner/researchers.ts — researcher dispatch for the
2
+ // `gdd-sdk init` runner (Plan 21-08, SDK-20).
3
+ //
4
+ // Two exports:
5
+ //
6
+ // * spawnResearcher(spec, opts) — one session, returns ResearcherOutcome.
7
+ // * spawnResearchersParallel(specs, opts) — semaphore-bound concurrent dispatch.
8
+ //
9
+ // Each researcher runs through `session-runner.run()` so the session
10
+ // layer owns budget + turn-cap + sanitizer + transcript policy. This
11
+ // module only orchestrates and packages outcomes.
12
+ //
13
+ // Tool scope resolution:
14
+ // * If `spec.agentPath` is set and the file exists, parse its
15
+ // frontmatter tools list via `parseAgentTools`.
16
+ // * Otherwise (or if parse returns `null` meaning wildcard/absent),
17
+ // use the `init` stage scope from `tool-scoping`.
18
+ //
19
+ // Never-throws contract: a thrown session + a session with `status !==
20
+ // 'completed'` both land as `ResearcherOutcome.status = 'error'`. The
21
+ // outer `spawnResearchersParallel` therefore never rejects even if
22
+ // every researcher explodes.
23
+
24
+ import { existsSync } from 'node:fs';
25
+
26
+ import { run as runSession } from '../session-runner/index.ts';
27
+ import type {
28
+ BudgetCap,
29
+ QueryOverride,
30
+ SessionResult,
31
+ } from '../session-runner/types.ts';
32
+ import { enforceScope, parseAgentTools } from '../tool-scoping/index.ts';
33
+ import type { ResearcherOutcome, ResearcherSpec } from './types.ts';
34
+ import { fileSize } from './scaffold.ts';
35
+
36
+ /** Monotonic-enough wall-clock helper. Used for duration_ms measurement.
37
+ * We use `Date.now()` rather than `performance.now()` because the Node
38
+ * `perf_hooks` module is unavailable in some sandboxed test runners;
39
+ * ms-precision is plenty for a researcher that runs for seconds. */
40
+ function nowMs(): number {
41
+ return Date.now();
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // spawnResearcher — single researcher session
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export interface SpawnResearcherOptions {
49
+ readonly budget: BudgetCap;
50
+ readonly maxTurns: number;
51
+ /** Test-injectable `queryOverride` forwarded into session-runner. */
52
+ readonly runOverride?: QueryOverride;
53
+ readonly cwd: string;
54
+ }
55
+
56
+ /**
57
+ * Spawn one researcher session through `session-runner.run()`. Returns
58
+ * a structured `ResearcherOutcome` regardless of outcome; never throws
59
+ * for any session-level failure mode.
60
+ *
61
+ * Dual measurement:
62
+ * * `output_exists` + `output_bytes` — measured on disk AFTER the
63
+ * session returns; a session that claimed success but failed to
64
+ * call the Write tool lands with `output_exists: false`.
65
+ * * `usage`, `duration_ms`, `error` — read off the `SessionResult`.
66
+ */
67
+ export async function spawnResearcher(
68
+ spec: ResearcherSpec,
69
+ opts: SpawnResearcherOptions,
70
+ ): Promise<ResearcherOutcome> {
71
+ const start = nowMs();
72
+
73
+ // Resolve allowed tool list via tool-scoping.
74
+ //
75
+ // `parseAgentTools` returns:
76
+ // null — file missing, no frontmatter, tools absent, or wildcard
77
+ // [] — explicit MCP-only (tools: [])
78
+ // string[] — declared list
79
+ //
80
+ // Pass `agentTools` to `enforceScope` ONLY when we got a concrete list
81
+ // or an explicit empty array; `null` → omit so the stage default wins.
82
+ let agentTools: readonly string[] | null = null;
83
+ if (spec.agentPath !== undefined && existsSync(spec.agentPath)) {
84
+ agentTools = parseAgentTools(spec.agentPath);
85
+ }
86
+
87
+ let allowedTools: readonly string[];
88
+ try {
89
+ allowedTools = enforceScope({
90
+ stage: 'init',
91
+ ...(agentTools !== null ? { agentTools } : {}),
92
+ });
93
+ } catch (err) {
94
+ // Scope enforcement failure — package as a researcher error without
95
+ // throwing. This is a precondition bug; the caller can present it
96
+ // alongside any other researcher failures.
97
+ return packageErrorOutcome(spec, start, 'SCOPE_ENFORCEMENT', err);
98
+ }
99
+
100
+ // Build the session options. `runOverride` is forwarded as the
101
+ // session-runner's `queryOverride` (same shape).
102
+ let session: SessionResult;
103
+ try {
104
+ session = await runSession({
105
+ prompt: spec.prompt,
106
+ stage: 'init',
107
+ budget: opts.budget,
108
+ turnCap: { maxTurns: opts.maxTurns },
109
+ allowedTools: [...allowedTools],
110
+ ...(opts.runOverride !== undefined
111
+ ? { queryOverride: opts.runOverride }
112
+ : {}),
113
+ });
114
+ } catch (err) {
115
+ // session-runner.run() is documented to never throw — but be
116
+ // defensive: if a test injects a runOverride that throws during
117
+ // setup we still package a clean outcome.
118
+ return packageErrorOutcome(spec, start, 'SESSION_THREW', err);
119
+ }
120
+
121
+ const duration = nowMs() - start;
122
+ const outputExists = existsSync(spec.outputPath);
123
+ const outputBytes = outputExists ? fileSize(spec.outputPath) : 0;
124
+
125
+ if (session.status === 'completed') {
126
+ return Object.freeze({
127
+ name: spec.name,
128
+ status: 'completed' as const,
129
+ output_exists: outputExists,
130
+ output_bytes: outputBytes,
131
+ usage: {
132
+ input_tokens: session.usage.input_tokens,
133
+ output_tokens: session.usage.output_tokens,
134
+ usd_cost: session.usage.usd_cost,
135
+ },
136
+ duration_ms: duration,
137
+ });
138
+ }
139
+
140
+ // Non-completed statuses (budget_exceeded, turn_cap_exceeded,
141
+ // aborted, error) all land here as researcher errors. Preserve the
142
+ // session-runner's error code/message when present, otherwise
143
+ // synthesize one from the status.
144
+ const code = session.error?.code ?? session.status.toUpperCase();
145
+ const message = session.error?.message ?? `session ended: ${session.status}`;
146
+ return Object.freeze({
147
+ name: spec.name,
148
+ status: 'error' as const,
149
+ output_exists: outputExists,
150
+ output_bytes: outputBytes,
151
+ usage: {
152
+ input_tokens: session.usage.input_tokens,
153
+ output_tokens: session.usage.output_tokens,
154
+ usd_cost: session.usage.usd_cost,
155
+ },
156
+ duration_ms: duration,
157
+ error: { code, message },
158
+ });
159
+ }
160
+
161
+ /** Build a ResearcherOutcome for a local (non-session) error. */
162
+ function packageErrorOutcome(
163
+ spec: ResearcherSpec,
164
+ start: number,
165
+ code: string,
166
+ err: unknown,
167
+ ): ResearcherOutcome {
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ const outputExists = existsSync(spec.outputPath);
170
+ const outputBytes = outputExists ? fileSize(spec.outputPath) : 0;
171
+ return Object.freeze({
172
+ name: spec.name,
173
+ status: 'error' as const,
174
+ output_exists: outputExists,
175
+ output_bytes: outputBytes,
176
+ usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
177
+ duration_ms: nowMs() - start,
178
+ error: { code, message },
179
+ });
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // spawnResearchersParallel — semaphore-bound dispatch
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export interface SpawnParallelOptions {
187
+ readonly concurrency: number;
188
+ readonly budget: BudgetCap;
189
+ readonly maxTurns: number;
190
+ readonly runOverride?: QueryOverride;
191
+ readonly cwd: string;
192
+ }
193
+
194
+ /**
195
+ * Run all `specs` in parallel with a semaphore cap of
196
+ * `opts.concurrency`. The returned outcomes are ordered to match the
197
+ * input `specs` order so consumers can zip the two arrays directly;
198
+ * completion ordering within a batch is timing-dependent and not
199
+ * stable.
200
+ *
201
+ * Never rejects: every outcome is packaged via `spawnResearcher`, which
202
+ * itself never throws.
203
+ */
204
+ export async function spawnResearchersParallel(
205
+ specs: readonly ResearcherSpec[],
206
+ opts: SpawnParallelOptions,
207
+ ): Promise<readonly ResearcherOutcome[]> {
208
+ const concurrency = Math.max(1, Math.floor(opts.concurrency));
209
+ const outcomes: ResearcherOutcome[] = new Array<ResearcherOutcome>(
210
+ specs.length,
211
+ );
212
+
213
+ // Simple index-based worker pool. Workers race for the next slot
214
+ // until all indices are claimed.
215
+ let next = 0;
216
+ async function worker(): Promise<void> {
217
+ for (;;) {
218
+ const i = next;
219
+ next += 1;
220
+ if (i >= specs.length) return;
221
+ const spec = specs[i];
222
+ // specs[i] is guaranteed present (i < specs.length) but the
223
+ // noUncheckedIndexedAccess flag forces the guard.
224
+ if (spec === undefined) return;
225
+ outcomes[i] = await spawnResearcher(spec, {
226
+ budget: opts.budget,
227
+ maxTurns: opts.maxTurns,
228
+ cwd: opts.cwd,
229
+ ...(opts.runOverride !== undefined
230
+ ? { runOverride: opts.runOverride }
231
+ : {}),
232
+ });
233
+ }
234
+ }
235
+
236
+ // Launch `concurrency` workers and await all.
237
+ const workers: Promise<void>[] = [];
238
+ const workerCount = Math.min(concurrency, specs.length);
239
+ for (let w = 0; w < workerCount; w += 1) {
240
+ workers.push(worker());
241
+ }
242
+ await Promise.all(workers);
243
+
244
+ return outcomes;
245
+ }
@@ -0,0 +1,224 @@
1
+ // scripts/lib/init-runner/scaffold.ts — filesystem helpers for the
2
+ // `gdd-sdk init` runner (Plan 21-08, SDK-20).
3
+ //
4
+ // This module is synchronous + side-effectful; every helper either
5
+ // mutates disk in a well-defined way or reports a boolean/null/string
6
+ // result. No session-runner / SDK dependencies — keep it cheap to test.
7
+ //
8
+ // Helpers exported:
9
+ //
10
+ // * writeStateFromTemplate — copy reference/STATE-TEMPLATE.md →
11
+ // .design/STATE.md with `{TODAY}` replaced.
12
+ // * backupExistingDesignDir — rename `.design/` to `.design.backup.<ISO>/`.
13
+ // * resolveStateTemplatePath — walk up from process.argv[1]/cwd to find
14
+ // the plugin package root, then join
15
+ // reference/STATE-TEMPLATE.md.
16
+ // * ensureDesignDirs — `mkdir -p` both `.design/` and
17
+ // `.design/research/`.
18
+ //
19
+ // All helpers are pure w.r.t. filesystem ordering — every caller can
20
+ // invoke them in any order without corrupting a concurrent init.
21
+
22
+ import {
23
+ copyFileSync,
24
+ cpSync,
25
+ existsSync,
26
+ mkdirSync,
27
+ readFileSync,
28
+ renameSync,
29
+ rmSync,
30
+ statSync,
31
+ writeFileSync,
32
+ } from 'node:fs';
33
+ import { dirname, join, resolve } from 'node:path';
34
+
35
+ /** The plugin's package.json `name` field used to anchor the walk-up in
36
+ * `resolveStateTemplatePath`. */
37
+ const PLUGIN_PACKAGE_NAME = '@hegemonart/get-design-done';
38
+
39
+ /** Maximum directories to climb looking for the plugin root. Eight
40
+ * matches session-runner's repo-root discovery depth — a forgiving
41
+ * upper bound without being pathological. */
42
+ const MAX_WALKUP_DEPTH = 8;
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // writeStateFromTemplate
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Copy `templatePath` to `destPath`, replacing every `{TODAY}` token
50
+ * with today's ISO date (`YYYY-MM-DD`). Other placeholders (e.g.,
51
+ * `{PROJECT_NAME}`) are left verbatim for the user to fill in later.
52
+ *
53
+ * Returns `true` on success, `false` when the template is missing.
54
+ * Never throws for expected failure modes; unexpected errors (permission
55
+ * denied, out of disk) surface as thrown errors since the caller cannot
56
+ * meaningfully recover.
57
+ */
58
+ export function writeStateFromTemplate(args: {
59
+ readonly cwd: string;
60
+ readonly templatePath: string;
61
+ readonly destPath: string;
62
+ }): boolean {
63
+ const { templatePath, destPath } = args;
64
+
65
+ if (!existsSync(templatePath)) return false;
66
+
67
+ // Read template → substitute `{TODAY}` → write to dest. If the template
68
+ // has no `{TODAY}` token we write it verbatim (plan spec: "Template
69
+ // without placeholder → copied verbatim").
70
+ const raw = readFileSync(templatePath, 'utf8');
71
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
72
+ const body = raw.includes('{TODAY}')
73
+ ? raw.split('{TODAY}').join(today)
74
+ : raw;
75
+
76
+ // Ensure destination directory exists before writing.
77
+ mkdirSync(dirname(destPath), { recursive: true });
78
+ writeFileSync(destPath, body, 'utf8');
79
+ return true;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // backupExistingDesignDir
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * If `.design/` exists inside `cwd`, move it aside to
88
+ * `.design.backup.<ISO>/` (ISO safe-for-filename form) and return the
89
+ * backup directory path. Returns `null` when nothing exists to back up.
90
+ *
91
+ * Rename is the default (atomic on same filesystem); on EXDEV or any
92
+ * other rename error we fall back to recursive copy + rm.
93
+ */
94
+ export function backupExistingDesignDir(cwd: string): string | null {
95
+ const designDir = resolve(cwd, '.design');
96
+ if (!existsSync(designDir)) return null;
97
+
98
+ // ISO → filesystem-safe: replace ':' and '.' (e.g. 2026-04-24T10:15:30.123Z
99
+ // → 2026-04-24T10-15-30-123Z) so Windows accepts the directory name.
100
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
101
+ let backupDir = resolve(cwd, `.design.backup.${stamp}`);
102
+
103
+ // Collision guard — sub-millisecond double-invoke could land on the
104
+ // same ISO stamp. Append a numeric suffix until the path is free.
105
+ let suffix = 0;
106
+ while (existsSync(backupDir)) {
107
+ suffix += 1;
108
+ backupDir = resolve(cwd, `.design.backup.${stamp}-${suffix}`);
109
+ if (suffix > 1000) {
110
+ // Pathological — bail rather than loop forever.
111
+ throw new Error(`backupExistingDesignDir: could not find a free backup directory after ${suffix} attempts`);
112
+ }
113
+ }
114
+
115
+ try {
116
+ renameSync(designDir, backupDir);
117
+ } catch (err) {
118
+ // EXDEV (cross-volume), EPERM (Windows permissions), or anything
119
+ // else — try recursive copy + rm as a slower fallback.
120
+ const code = (err as NodeJS.ErrnoException | null)?.code;
121
+ if (code === 'EXDEV' || code === 'EPERM' || code === 'ENOTEMPTY') {
122
+ cpSync(designDir, backupDir, { recursive: true });
123
+ rmSync(designDir, { recursive: true, force: true });
124
+ } else {
125
+ throw err;
126
+ }
127
+ }
128
+
129
+ return backupDir;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // ensureDesignDirs
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Create `.design/` and `.design/research/` inside `cwd` (`mkdir -p`).
138
+ * Idempotent — safe to call on an already-initialized project. Returns
139
+ * the resolved absolute paths so callers can drop them directly into
140
+ * `writeStateFromTemplate({destPath: ...})`.
141
+ */
142
+ export function ensureDesignDirs(cwd: string): {
143
+ readonly design_dir: string;
144
+ readonly research_dir: string;
145
+ } {
146
+ const designDir = resolve(cwd, '.design');
147
+ const researchDir = join(designDir, 'research');
148
+ mkdirSync(designDir, { recursive: true });
149
+ mkdirSync(researchDir, { recursive: true });
150
+ return Object.freeze({ design_dir: designDir, research_dir: researchDir });
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // resolveStateTemplatePath
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Walk up from `process.argv[1]`'s directory (falling back to cwd if
159
+ * argv[1] isn't a real path) looking for a `package.json` whose `name`
160
+ * field matches `@hegemonart/get-design-done`. When found, return
161
+ * `<pkg-root>/reference/STATE-TEMPLATE.md`. Return `null` if we run out
162
+ * of parent directories without finding the plugin root — e.g., when
163
+ * invoked from a fork that has renamed the package.
164
+ *
165
+ * The walk is bounded to `MAX_WALKUP_DEPTH` (8) to stop us from
166
+ * traversing the entire filesystem on pathological inputs.
167
+ */
168
+ export function resolveStateTemplatePath(): string | null {
169
+ const startCandidates: string[] = [];
170
+ // argv[1] is the executing script's path (e.g., bin wrapper).
171
+ const argv1 = process.argv[1];
172
+ if (argv1 !== undefined && argv1.length > 0 && existsSync(argv1)) {
173
+ startCandidates.push(dirname(resolve(argv1)));
174
+ }
175
+ // Fall back to cwd for cases where argv[1] isn't meaningful (tests,
176
+ // repl, etc.).
177
+ startCandidates.push(process.cwd());
178
+
179
+ for (const start of startCandidates) {
180
+ let dir = start;
181
+ for (let depth = 0; depth < MAX_WALKUP_DEPTH; depth += 1) {
182
+ const pkgPath = join(dir, 'package.json');
183
+ if (existsSync(pkgPath)) {
184
+ try {
185
+ const raw = readFileSync(pkgPath, 'utf8');
186
+ const parsed = JSON.parse(raw) as { name?: unknown };
187
+ if (parsed.name === PLUGIN_PACKAGE_NAME) {
188
+ const tpl = join(dir, 'reference', 'STATE-TEMPLATE.md');
189
+ if (existsSync(tpl)) return tpl;
190
+ }
191
+ } catch {
192
+ // Malformed package.json — keep walking; maybe a parent has it.
193
+ }
194
+ }
195
+ const parent = dirname(dir);
196
+ if (parent === dir) break;
197
+ dir = parent;
198
+ }
199
+ }
200
+
201
+ return null;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Internal helpers (kept exported for tests that want to drill in)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /** Expose copy-then-write as a unit for tests that want to bypass the
209
+ * template-path existence check. Public but undocumented in the index. */
210
+ export function _copyTemplateVerbatim(src: string, dest: string): void {
211
+ mkdirSync(dirname(dest), { recursive: true });
212
+ copyFileSync(src, dest);
213
+ }
214
+
215
+ /** Size of a file on disk, or 0 if missing. Used by the researcher
216
+ * dispatcher to measure `output_bytes` without bubbling up EEXIST /
217
+ * ENOENT. */
218
+ export function fileSize(p: string): number {
219
+ try {
220
+ return statSync(p).size;
221
+ } catch {
222
+ return 0;
223
+ }
224
+ }
@@ -0,0 +1,224 @@
1
+ // scripts/lib/init-runner/synthesizer.ts — composes
2
+ // `.design/DESIGN-CONTEXT.md` from researcher outputs (Plan 21-08, SDK-20).
3
+ //
4
+ // The synthesizer is a single headless session spawned through
5
+ // `session-runner.run()` with a prompt that embeds every researcher's
6
+ // content. Its success contract is simple: did the agent write
7
+ // `.design/DESIGN-CONTEXT.md` to disk? We don't parse its text output —
8
+ // the file's presence is the sole pass/fail signal.
9
+ //
10
+ // Exports:
11
+ // * DEFAULT_SYNTHESIZER_PROMPT — the embedded prompt (frozen string).
12
+ // * buildSynthesizerPrompt — splice researcher bodies into the prompt.
13
+ // * spawnSynthesizer — session dispatch + file-presence check.
14
+
15
+ import { existsSync } from 'node:fs';
16
+ import { resolve } from 'node:path';
17
+
18
+ import { run as runSession } from '../session-runner/index.ts';
19
+ import type {
20
+ BudgetCap,
21
+ QueryOverride,
22
+ } from '../session-runner/types.ts';
23
+ import { enforceScope } from '../tool-scoping/index.ts';
24
+ import type { ResearcherName } from './types.ts';
25
+
26
+ /**
27
+ * Default synthesizer prompt with a `{{RESEARCH_BLOCKS}}` placeholder
28
+ * that `buildSynthesizerPrompt` substitutes with the researcher
29
+ * content. The template encodes the DESIGN-CONTEXT.md schema the
30
+ * downstream discuss stage expects (decisions, must-haves, connections,
31
+ * ARM, flow diagram, open questions).
32
+ *
33
+ * The prompt is explicit about "write file; emit no prose" because the
34
+ * synthesizer's success is measured by file-on-disk — we don't need the
35
+ * agent's text stream in the transcript.
36
+ */
37
+ export const DEFAULT_SYNTHESIZER_PROMPT: string = Object.freeze(
38
+ `You are the init-synthesizer. Four researchers have produced these outputs:
39
+
40
+ {{RESEARCH_BLOCKS}}
41
+
42
+ Compose .design/DESIGN-CONTEXT.md following this schema:
43
+
44
+ ---
45
+ cycle: init
46
+ generated_at: <ISO>
47
+ ---
48
+
49
+ # Design Context (Draft)
50
+
51
+ ## Decisions
52
+ (Draft as D-01, D-02, … from researcher outputs. Each decision gets id,
53
+ rationale, source researcher.)
54
+
55
+ ## Must-Haves
56
+ (Draft as M-01, M-02, … from researcher blockers. Each must-have gets id,
57
+ stakeholder, test.)
58
+
59
+ ## Connections
60
+ (Which AI-design-tool connections the project's tech stack recommends.)
61
+
62
+ ## Architectural Responsibility Map
63
+ (ARM tiers inferred from the codebase.)
64
+
65
+ ## Flow Diagram
66
+ \`\`\`mermaid
67
+ flowchart TD
68
+ ...
69
+ \`\`\`
70
+
71
+ ## Open Questions
72
+ (Anything that requires the user to answer before brief can start.)
73
+
74
+ Write the final content to .design/DESIGN-CONTEXT.md via Write tool.
75
+ Do NOT emit any prose response — only write the file.
76
+ `,
77
+ ) as string;
78
+
79
+ export interface SynthesizerInput {
80
+ readonly name: ResearcherName;
81
+ readonly path: string;
82
+ readonly content: string;
83
+ }
84
+
85
+ /**
86
+ * Build the synthesizer prompt by substituting `{{RESEARCH_BLOCKS}}`
87
+ * with concatenated research bodies. Each block is wrapped in an HTML
88
+ * comment header so the agent can parse the boundaries deterministically.
89
+ *
90
+ * Exported for tests + for callers that want to assert prompt content
91
+ * without running a session.
92
+ */
93
+ export function buildSynthesizerPrompt(
94
+ inputs: readonly SynthesizerInput[],
95
+ override?: string,
96
+ ): string {
97
+ const template = override ?? DEFAULT_SYNTHESIZER_PROMPT;
98
+ const blocks = inputs
99
+ .map((inp) => `<!-- ${inp.name} -->\n${inp.content}`)
100
+ .join('\n\n');
101
+ // If the caller's override doesn't include the placeholder, append
102
+ // the blocks. This keeps custom prompts functional without forcing
103
+ // them to adopt our template marker.
104
+ if (!template.includes('{{RESEARCH_BLOCKS}}')) {
105
+ return `${template}\n\n${blocks}`;
106
+ }
107
+ return template.split('{{RESEARCH_BLOCKS}}').join(blocks);
108
+ }
109
+
110
+ export interface SpawnSynthesizerArgs {
111
+ readonly researcherOutputs: readonly SynthesizerInput[];
112
+ readonly cwd: string;
113
+ readonly budget: BudgetCap;
114
+ readonly maxTurns: number;
115
+ readonly runOverride?: QueryOverride;
116
+ readonly promptOverride?: string;
117
+ }
118
+
119
+ export interface SpawnSynthesizerResult {
120
+ readonly status: 'completed' | 'error';
121
+ /** Absolute path to where .design/DESIGN-CONTEXT.md should live. */
122
+ readonly design_context_path: string;
123
+ readonly usage: {
124
+ readonly input_tokens: number;
125
+ readonly output_tokens: number;
126
+ readonly usd_cost: number;
127
+ };
128
+ readonly error?: string;
129
+ }
130
+
131
+ /**
132
+ * Spawn one synthesizer session. Success is determined by the presence
133
+ * of `.design/DESIGN-CONTEXT.md` inside `cwd` after the session ends —
134
+ * we do NOT inspect the session's text output.
135
+ *
136
+ * Never throws; session-level errors and missing-file outcomes both
137
+ * land as `status: 'error'` with `error` populated.
138
+ */
139
+ export async function spawnSynthesizer(
140
+ args: SpawnSynthesizerArgs,
141
+ ): Promise<SpawnSynthesizerResult> {
142
+ const designContextPath = resolve(
143
+ args.cwd,
144
+ '.design',
145
+ 'DESIGN-CONTEXT.md',
146
+ );
147
+
148
+ // Resolve allowed tools. The synthesizer only needs Read (to load
149
+ // research outputs if the prompt drops the inline bodies) and Write
150
+ // (to produce DESIGN-CONTEXT.md). The init stage scope covers both.
151
+ let allowedTools: readonly string[];
152
+ try {
153
+ allowedTools = enforceScope({ stage: 'init' });
154
+ } catch (err) {
155
+ const message = err instanceof Error ? err.message : String(err);
156
+ return Object.freeze({
157
+ status: 'error' as const,
158
+ design_context_path: designContextPath,
159
+ usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
160
+ error: `scope enforcement failed: ${message}`,
161
+ });
162
+ }
163
+
164
+ const prompt = buildSynthesizerPrompt(
165
+ args.researcherOutputs,
166
+ args.promptOverride,
167
+ );
168
+
169
+ let usage = { input_tokens: 0, output_tokens: 0, usd_cost: 0 };
170
+ try {
171
+ const session = await runSession({
172
+ prompt,
173
+ stage: 'init',
174
+ budget: args.budget,
175
+ turnCap: { maxTurns: args.maxTurns },
176
+ allowedTools: [...allowedTools],
177
+ ...(args.runOverride !== undefined
178
+ ? { queryOverride: args.runOverride }
179
+ : {}),
180
+ });
181
+ usage = {
182
+ input_tokens: session.usage.input_tokens,
183
+ output_tokens: session.usage.output_tokens,
184
+ usd_cost: session.usage.usd_cost,
185
+ };
186
+
187
+ if (session.status !== 'completed') {
188
+ const code = session.error?.code ?? session.status.toUpperCase();
189
+ const msg = session.error?.message ?? `session ended: ${session.status}`;
190
+ return Object.freeze({
191
+ status: 'error' as const,
192
+ design_context_path: designContextPath,
193
+ usage,
194
+ error: `${code}: ${msg}`,
195
+ });
196
+ }
197
+ } catch (err) {
198
+ // session-runner is documented never-throws, but a test-injected
199
+ // runOverride could throw during setup. Package gracefully.
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ return Object.freeze({
202
+ status: 'error' as const,
203
+ design_context_path: designContextPath,
204
+ usage,
205
+ error: `session threw: ${message}`,
206
+ });
207
+ }
208
+
209
+ // Success is file-on-disk, NOT session.status.
210
+ if (!existsSync(designContextPath)) {
211
+ return Object.freeze({
212
+ status: 'error' as const,
213
+ design_context_path: designContextPath,
214
+ usage,
215
+ error: 'synthesizer did not produce .design/DESIGN-CONTEXT.md',
216
+ });
217
+ }
218
+
219
+ return Object.freeze({
220
+ status: 'completed' as const,
221
+ design_context_path: designContextPath,
222
+ usage,
223
+ });
224
+ }