@bastani/atomic 0.5.3 → 0.5.4-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 (48) hide show
  1. package/README.md +110 -11
  2. package/dist/{chunk-mn870nrv.js → chunk-xkxndz5g.js} +213 -154
  3. package/dist/sdk/components/workflow-picker-panel.d.ts +120 -0
  4. package/dist/sdk/define-workflow.d.ts +1 -1
  5. package/dist/sdk/index.js +1 -1
  6. package/dist/sdk/runtime/discovery.d.ts +57 -3
  7. package/dist/sdk/runtime/executor.d.ts +15 -2
  8. package/dist/sdk/runtime/tmux.d.ts +9 -0
  9. package/dist/sdk/types.d.ts +63 -4
  10. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +61 -0
  11. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +48 -0
  12. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +25 -0
  13. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +91 -0
  14. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +56 -0
  15. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +48 -0
  16. package/dist/sdk/workflows/builtin/ralph/claude/index.js +6 -5
  17. package/dist/sdk/workflows/builtin/ralph/copilot/index.js +6 -5
  18. package/dist/sdk/workflows/builtin/ralph/opencode/index.js +6 -5
  19. package/dist/sdk/workflows/index.d.ts +4 -4
  20. package/dist/sdk/workflows/index.js +7 -1
  21. package/package.json +1 -1
  22. package/src/cli.ts +25 -3
  23. package/src/commands/cli/chat/index.ts +5 -5
  24. package/src/commands/cli/init/index.ts +79 -77
  25. package/src/commands/cli/workflow-command.test.ts +757 -0
  26. package/src/commands/cli/workflow.test.ts +310 -0
  27. package/src/commands/cli/workflow.ts +445 -105
  28. package/src/sdk/components/workflow-picker-panel.tsx +1462 -0
  29. package/src/sdk/define-workflow.test.ts +101 -0
  30. package/src/sdk/define-workflow.ts +62 -2
  31. package/src/sdk/runtime/discovery.ts +111 -8
  32. package/src/sdk/runtime/executor.ts +89 -32
  33. package/src/sdk/runtime/tmux.conf +55 -0
  34. package/src/sdk/runtime/tmux.ts +34 -10
  35. package/src/sdk/types.ts +67 -4
  36. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +294 -0
  37. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +276 -0
  38. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.ts +38 -0
  39. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +816 -0
  40. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +334 -0
  41. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +284 -0
  42. package/src/sdk/workflows/builtin/ralph/claude/index.ts +8 -4
  43. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +10 -4
  44. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +8 -4
  45. package/src/sdk/workflows/index.ts +9 -1
  46. package/src/services/system/auto-sync.ts +1 -1
  47. package/src/services/system/install-ui.ts +109 -39
  48. package/src/theme/colors.ts +65 -1
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Codebase scout: deterministic helpers for the deep-research-codebase workflow.
3
+ *
4
+ * Responsibilities:
5
+ * 1. Discover the codebase root (git toplevel, falling back to cwd).
6
+ * 2. List all source files, respecting .gitignore when in a git repo.
7
+ * 3. Count lines of code per file using batched `wc -l`.
8
+ * 4. Render a compact directory tree (depth-bounded) for prompt context.
9
+ * 5. Build "partition units" by aggregating LOC at depth-1, then drilling
10
+ * down on any unit that is too large to live in a single explorer.
11
+ * 6. Bin-pack partition units into N balanced groups (largest-first).
12
+ *
13
+ * Everything here is pure TypeScript + child_process — no LLM calls.
14
+ */
15
+
16
+ import { spawnSync } from "node:child_process";
17
+
18
+ /** Source-file extensions we treat as "code" for LOC accounting. */
19
+ const CODE_EXTENSIONS = new Set<string>([
20
+ // Web / TS / JS
21
+ "ts", "tsx", "js", "jsx", "mjs", "cjs",
22
+ "vue", "svelte", "astro",
23
+ // Systems
24
+ "c", "cc", "cpp", "cxx", "h", "hpp", "rs", "go", "zig",
25
+ // JVM / .NET
26
+ "java", "kt", "kts", "scala", "groovy", "cs", "fs",
27
+ // Scripting
28
+ "py", "rb", "php", "pl", "lua", "sh", "bash", "zsh", "fish",
29
+ // Mobile
30
+ "swift", "m", "mm",
31
+ // Functional / niche
32
+ "ex", "exs", "erl", "elm", "hs", "ml", "clj", "cljs", "edn",
33
+ "r", "jl", "dart", "nim",
34
+ // Schemas / DSLs that materially shape behavior
35
+ "sql", "graphql", "proto",
36
+ ]);
37
+
38
+ /** Directories we always exclude even when not using git ls-files. */
39
+ const FIND_IGNORE_PATTERNS = [
40
+ "node_modules", ".git", "dist", "build", "out",
41
+ ".next", ".nuxt", ".turbo", ".vercel", ".cache",
42
+ "target", "vendor", "__pycache__", ".venv", "venv", "coverage",
43
+ ];
44
+
45
+ /** Per-file LOC + path. */
46
+ export type FileStats = { path: string; loc: number };
47
+
48
+ /**
49
+ * A "partition unit" is the atomic chunk of work that gets bin-packed into
50
+ * an explorer. It is always one directory (possibly drilled down to depth 2)
51
+ * with all of the code files that live anywhere underneath it.
52
+ */
53
+ export type PartitionUnit = {
54
+ /** Repo-relative path, e.g. "src/cli" or "packages/foo/src". */
55
+ path: string;
56
+ loc: number;
57
+ fileCount: number;
58
+ /** Repo-relative file paths inside this unit (full recursive listing). */
59
+ files: string[];
60
+ };
61
+
62
+ export type CodebaseScout = {
63
+ /** Absolute path to the repository root. */
64
+ root: string;
65
+ totalLoc: number;
66
+ totalFiles: number;
67
+ /** Compact rendered directory tree (depth-bounded) for prompt context. */
68
+ tree: string;
69
+ /** Partition units, sorted by LOC descending. */
70
+ units: PartitionUnit[];
71
+ };
72
+
73
+ /** Resolve the project root. Prefers `git rev-parse --show-toplevel`. */
74
+ export function getCodebaseRoot(): string {
75
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
76
+ encoding: "utf8",
77
+ stdio: ["ignore", "pipe", "ignore"],
78
+ });
79
+ if (r.status === 0 && r.stdout) {
80
+ return r.stdout.trim();
81
+ }
82
+ return process.cwd();
83
+ }
84
+
85
+ function isCodeFile(p: string): boolean {
86
+ const dot = p.lastIndexOf(".");
87
+ if (dot < 0 || dot === p.length - 1) return false;
88
+ const ext = p.slice(dot + 1).toLowerCase();
89
+ return CODE_EXTENSIONS.has(ext);
90
+ }
91
+
92
+ /** List all files in the repository. Prefers git ls-files (respects .gitignore). */
93
+ function listAllFiles(root: string): string[] {
94
+ const git = spawnSync("git", ["ls-files"], {
95
+ cwd: root,
96
+ encoding: "utf8",
97
+ maxBuffer: 64 * 1024 * 1024,
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ });
100
+ if (git.status === 0 && git.stdout) {
101
+ return git.stdout.split("\n").filter((l) => l.length > 0);
102
+ }
103
+
104
+ // Fallback: shell out to find with the standard ignore patterns.
105
+ const args: string[] = [".", "-type", "f"];
106
+ for (const pattern of FIND_IGNORE_PATTERNS) {
107
+ args.push("-not", "-path", `*/${pattern}/*`);
108
+ }
109
+ const find = spawnSync("find", args, {
110
+ cwd: root,
111
+ encoding: "utf8",
112
+ maxBuffer: 64 * 1024 * 1024,
113
+ stdio: ["ignore", "pipe", "ignore"],
114
+ });
115
+ if (find.status === 0 && find.stdout) {
116
+ return find.stdout
117
+ .split("\n")
118
+ .map((p) => p.replace(/^\.\//, ""))
119
+ .filter((p) => p.length > 0);
120
+ }
121
+ return [];
122
+ }
123
+
124
+ /**
125
+ * Count lines for a batch of files using `wc -l`. Output format:
126
+ * " N filename"
127
+ * " N total" (when more than one file is passed)
128
+ *
129
+ * We batch to avoid command-line length limits.
130
+ */
131
+ function countLines(root: string, files: string[]): Map<string, number> {
132
+ const result = new Map<string, number>();
133
+ if (files.length === 0) return result;
134
+
135
+ const BATCH = 200;
136
+ for (let i = 0; i < files.length; i += BATCH) {
137
+ const batch = files.slice(i, i + BATCH);
138
+ const r = spawnSync("wc", ["-l", "--", ...batch], {
139
+ cwd: root,
140
+ encoding: "utf8",
141
+ maxBuffer: 32 * 1024 * 1024,
142
+ stdio: ["ignore", "pipe", "ignore"],
143
+ });
144
+ if (!r.stdout) continue;
145
+ for (const line of r.stdout.split("\n")) {
146
+ const m = line.match(/^\s*(\d+)\s+(.+)$/);
147
+ // Regex groups are typed `string | undefined` under strict mode even
148
+ // when the whole match succeeded — guard explicitly.
149
+ const countStr = m?.[1];
150
+ const filename = m?.[2]?.trim();
151
+ if (countStr === undefined || filename === undefined) continue;
152
+ if (filename === "total") continue;
153
+ result.set(filename, parseInt(countStr, 10));
154
+ }
155
+ }
156
+ return result;
157
+ }
158
+
159
+ /** Group file stats by directory at the given depth (1-indexed). */
160
+ function aggregateAtDepth(files: FileStats[], depth: number): PartitionUnit[] {
161
+ const map = new Map<string, PartitionUnit>();
162
+ for (const f of files) {
163
+ const parts = f.path.split("/");
164
+ const key = parts.length >= depth
165
+ ? parts.slice(0, depth).join("/")
166
+ : (parts.slice(0, parts.length - 1).join("/") || "(root)");
167
+ let cur = map.get(key);
168
+ if (!cur) {
169
+ cur = { path: key, loc: 0, fileCount: 0, files: [] };
170
+ map.set(key, cur);
171
+ }
172
+ cur.loc += f.loc;
173
+ cur.fileCount += 1;
174
+ cur.files.push(f.path);
175
+ }
176
+ return [...map.values()];
177
+ }
178
+
179
+ /**
180
+ * Build candidate partition units. Starts at depth 1 and drills down on any
181
+ * unit that is too large (> 20% of total LOC) to balance partitions better.
182
+ * A single drill-down pass is enough for typical codebases — we deliberately
183
+ * do not recurse to keep behavior predictable.
184
+ */
185
+ function buildPartitionUnits(files: FileStats[]): PartitionUnit[] {
186
+ if (files.length === 0) return [];
187
+
188
+ const totalLoc = files.reduce((s, f) => s + f.loc, 0);
189
+ const drillThreshold = Math.max(Math.floor(totalLoc * 0.2), 1);
190
+
191
+ const units = aggregateAtDepth(files, 1);
192
+
193
+ // Drill down on oversized units (single pass).
194
+ for (let i = 0; i < units.length; i++) {
195
+ const unit = units[i];
196
+ // noUncheckedIndexedAccess makes units[i] possibly undefined; we know
197
+ // it's defined because i < units.length, but TS can't prove that.
198
+ if (unit === undefined) continue;
199
+ if (unit.loc <= drillThreshold) continue;
200
+ const subFiles = files.filter((f) =>
201
+ f.path === unit.path || f.path.startsWith(unit.path + "/"),
202
+ );
203
+ const subUnits = aggregateAtDepth(subFiles, 2);
204
+ if (subUnits.length > 1) {
205
+ units.splice(i, 1, ...subUnits);
206
+ i += subUnits.length - 1;
207
+ }
208
+ }
209
+
210
+ return units.sort((a, b) => b.loc - a.loc);
211
+ }
212
+
213
+ /**
214
+ * Render a compact, depth-bounded ASCII tree of the codebase. Used as prompt
215
+ * context for the scout's architectural-overview LLM call.
216
+ *
217
+ * - `maxDepth`: how many directory levels to descend before stopping.
218
+ * - `maxLines`: hard cap on output lines; we append "└── ..." if exceeded.
219
+ *
220
+ * Only directories show recursive file counts; leaf files appear as bare names.
221
+ */
222
+ function renderTree(files: string[], maxDepth = 3, maxLines = 200): string {
223
+ type Node = { children: Map<string, Node>; isFile: boolean; fileCount: number };
224
+ const root: Node = { children: new Map(), isFile: false, fileCount: 0 };
225
+
226
+ for (const file of files) {
227
+ const parts = file.split("/");
228
+ let cur = root;
229
+ cur.fileCount += 1;
230
+ for (let i = 0; i < parts.length && i < maxDepth; i++) {
231
+ const part = parts[i];
232
+ if (part === undefined) continue; // unreachable in practice
233
+ let child = cur.children.get(part);
234
+ if (!child) {
235
+ child = { children: new Map(), isFile: false, fileCount: 0 };
236
+ cur.children.set(part, child);
237
+ }
238
+ child.fileCount += 1;
239
+ cur = child;
240
+ if (i === parts.length - 1) cur.isFile = true;
241
+ }
242
+ }
243
+
244
+ const lines: string[] = [];
245
+ let truncated = false;
246
+
247
+ function walk(node: Node, prefix: string): void {
248
+ if (truncated) return;
249
+ const entries = [...node.children.entries()].sort((a, b) => {
250
+ const aIsDir = a[1].children.size > 0;
251
+ const bIsDir = b[1].children.size > 0;
252
+ if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
253
+ return a[0].localeCompare(b[0]);
254
+ });
255
+ for (let i = 0; i < entries.length; i++) {
256
+ if (lines.length >= maxLines) {
257
+ lines.push(prefix + "└── ...");
258
+ truncated = true;
259
+ return;
260
+ }
261
+ const entry = entries[i];
262
+ if (entry === undefined) continue; // unreachable in practice
263
+ const [name, child] = entry;
264
+ const last = i === entries.length - 1;
265
+ const branch = last ? "└── " : "├── ";
266
+ const isDir = child.children.size > 0;
267
+ const label = isDir ? `${name}/ (${child.fileCount} files)` : name;
268
+ lines.push(prefix + branch + label);
269
+ walk(child, prefix + (last ? " " : "│ "));
270
+ if (truncated) return;
271
+ }
272
+ }
273
+
274
+ walk(root, "");
275
+ return lines.join("\n");
276
+ }
277
+
278
+ /**
279
+ * Run the full scout: list files, count LOC, render tree, build partition units.
280
+ */
281
+ export function scoutCodebase(root: string): CodebaseScout {
282
+ const allPaths = listAllFiles(root);
283
+ const codePaths = allPaths.filter(isCodeFile);
284
+ const locMap = countLines(root, codePaths);
285
+
286
+ const fileStats: FileStats[] = codePaths.map((p) => ({
287
+ path: p,
288
+ loc: locMap.get(p) ?? 0,
289
+ }));
290
+
291
+ const totalLoc = fileStats.reduce((s, f) => s + f.loc, 0);
292
+ const totalFiles = fileStats.length;
293
+ const treeSource = allPaths.length > 0 ? allPaths : codePaths;
294
+ const tree = renderTree(treeSource, 3, 200);
295
+ const units = buildPartitionUnits(fileStats);
296
+
297
+ return { root, totalLoc, totalFiles, tree, units };
298
+ }
299
+
300
+ /**
301
+ * Bin-pack partition units into `count` balanced groups. Greedy
302
+ * largest-first: assign each unit to the currently-lightest bin.
303
+ *
304
+ * If there are fewer units than requested bins, the result has exactly
305
+ * `units.length` non-empty bins (we never return empty bins).
306
+ */
307
+ export function partitionUnits(
308
+ units: PartitionUnit[],
309
+ count: number,
310
+ ): PartitionUnit[][] {
311
+ if (units.length === 0) return [];
312
+ const n = Math.max(1, Math.min(count, units.length));
313
+ const bins: PartitionUnit[][] = Array.from({ length: n }, () => []);
314
+ const totals: number[] = Array.from({ length: n }, () => 0);
315
+
316
+ const sorted = [...units].sort((a, b) => b.loc - a.loc);
317
+ for (const u of sorted) {
318
+ let minIdx = 0;
319
+ let minTotal = totals[0] ?? 0;
320
+ for (let i = 1; i < n; i++) {
321
+ const t = totals[i] ?? 0;
322
+ if (t < minTotal) {
323
+ minIdx = i;
324
+ minTotal = t;
325
+ }
326
+ }
327
+ const bin = bins[minIdx];
328
+ if (bin === undefined) continue; // unreachable: minIdx ∈ [0, n)
329
+ bin.push(u);
330
+ totals[minIdx] = minTotal + u.loc;
331
+ }
332
+
333
+ return bins;
334
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * deep-research-codebase / opencode
3
+ *
4
+ * OpenCode replica of the Claude deep-research-codebase workflow. The Claude
5
+ * version dispatches specialist sub-agents (codebase-locator, codebase-
6
+ * analyzer, etc.) inside a single explorer session via `@"name (agent)"`
7
+ * syntax — a Claude-specific feature. OpenCode sessions are bound to a
8
+ * single agent for their lifetime, so we keep the SAME graph topology
9
+ * (scout ∥ history → explorer-1..N → aggregator) but drive each explorer
10
+ * through the locate → analyze → patterns → synthesize sequence inline using
11
+ * the default agent's built-in file tools.
12
+ *
13
+ * Topology (identical to Claude version):
14
+ *
15
+ * ┌─→ codebase-scout
16
+ * parent ─┤
17
+ * └─→ research-history
18
+ * │
19
+ * ▼
20
+ * ┌──────────────────────────────────────────────────┐
21
+ * │ explorer-1 explorer-2 ... explorer-N │ (Promise.all)
22
+ * └──────────────────────────────────────────────────┘
23
+ * │
24
+ * ▼
25
+ * aggregator
26
+ *
27
+ * OpenCode-specific concerns baked in:
28
+ *
29
+ * • F5 — every `ctx.stage()` call is a FRESH session with no memory of
30
+ * prior stages. We forward the scout overview, history overview, and
31
+ * partition assignment explicitly into each explorer's first prompt.
32
+ *
33
+ * • F9 — `s.save()` receives the `{ info, parts }` payload from
34
+ * `s.client.session.prompt()` via `result.data!`. Passing the full
35
+ * `result` (with its wrapping) or raw `result.data.parts` breaks
36
+ * downstream `transcript()` reads.
37
+ *
38
+ * • F6 — every prompt explicitly requires trailing prose AFTER any tool
39
+ * call so the rendered transcript has content. OpenCode's `parts` array
40
+ * mixes text/tool/reasoning/file parts; without trailing text the
41
+ * transcript extractor returns an empty string.
42
+ *
43
+ * • F3 — transcript extraction relies on the runtime's text-only rendering
44
+ * of `result.data.parts`. The helpers call `ctx.transcript(handle)` which
45
+ * returns `{ path, content }` where content is already text-filtered.
46
+ */
47
+
48
+ import { defineWorkflow } from "../../../index.ts";
49
+ import { mkdir } from "node:fs/promises";
50
+ import path from "node:path";
51
+
52
+ import {
53
+ getCodebaseRoot,
54
+ partitionUnits,
55
+ scoutCodebase,
56
+ } from "../helpers/scout.ts";
57
+ import {
58
+ calculateExplorerCount,
59
+ explainHeuristic,
60
+ } from "../helpers/heuristic.ts";
61
+ import {
62
+ buildAggregatorPrompt,
63
+ buildExplorerPromptGeneric,
64
+ buildHistoryPromptGeneric,
65
+ buildScoutPrompt,
66
+ slugifyPrompt,
67
+ } from "../helpers/prompts.ts";
68
+
69
+ export default defineWorkflow<"opencode">({
70
+ name: "deep-research-codebase",
71
+ description:
72
+ "Deterministic deep codebase research: scout → LOC-driven parallel explorers → aggregator",
73
+ })
74
+ .run(async (ctx) => {
75
+ // Free-form workflows receive their positional prompt under
76
+ // `inputs.prompt`; destructure once so every stage below can close
77
+ // over a bare `prompt` string without re-reaching into ctx.inputs.
78
+ const prompt = ctx.inputs.prompt ?? "";
79
+ const root = getCodebaseRoot();
80
+ const startedAt = new Date();
81
+ const isoDate = startedAt.toISOString().slice(0, 10);
82
+ const slug = slugifyPrompt(prompt);
83
+
84
+ // ── Stages 1a + 1b: codebase-scout ∥ research-history ──────────────────
85
+ const [scout, history] = await Promise.all([
86
+ ctx.stage(
87
+ {
88
+ name: "codebase-scout",
89
+ description: "Map codebase, count LOC, partition for parallel explorers",
90
+ },
91
+ {},
92
+ { title: "codebase-scout" },
93
+ async (s) => {
94
+ // 1. Deterministic scouting (pure TypeScript — no LLM).
95
+ const data = scoutCodebase(root);
96
+ if (data.units.length === 0) {
97
+ throw new Error(
98
+ `deep-research-codebase: scout found no source files under ${root}. ` +
99
+ `Run from inside a code repository or check the CODE_EXTENSIONS list.`,
100
+ );
101
+ }
102
+
103
+ // 2. Heuristic decides explorer count (capped by available units).
104
+ const targetCount = calculateExplorerCount(data.totalLoc);
105
+ const partitions = partitionUnits(data.units, targetCount);
106
+ const actualCount = partitions.length;
107
+
108
+ // 3. Scratch directory for explorer outputs (timestamped to avoid
109
+ // collisions across runs).
110
+ const scratchDir = path.join(
111
+ root,
112
+ "research",
113
+ "docs",
114
+ `.deep-research-${startedAt.getTime()}`,
115
+ );
116
+ await mkdir(scratchDir, { recursive: true });
117
+
118
+ // 4. Short LLM call: architectural orientation for downstream
119
+ // explorers. The prompt forbids the agent from answering the
120
+ // research question — its only job here is to orient.
121
+ const result = await s.client.session.prompt({
122
+ sessionID: s.session.id,
123
+ parts: [
124
+ {
125
+ type: "text",
126
+ text: buildScoutPrompt({
127
+ question: prompt,
128
+ tree: data.tree,
129
+ totalLoc: data.totalLoc,
130
+ totalFiles: data.totalFiles,
131
+ explorerCount: actualCount,
132
+ partitionPreview: partitions,
133
+ }),
134
+ },
135
+ ],
136
+ });
137
+ // F9: OpenCode takes the unwrapped { info, parts } object.
138
+ s.save(result.data!);
139
+
140
+ return {
141
+ root,
142
+ totalLoc: data.totalLoc,
143
+ totalFiles: data.totalFiles,
144
+ tree: data.tree,
145
+ partitions,
146
+ explorerCount: actualCount,
147
+ scratchDir,
148
+ heuristicNote: explainHeuristic(data.totalLoc, actualCount),
149
+ };
150
+ },
151
+ ),
152
+ ctx.stage(
153
+ {
154
+ name: "research-history",
155
+ description: "Surface prior research from research/ directory",
156
+ },
157
+ {},
158
+ { title: "research-history" },
159
+ async (s) => {
160
+ // The generic history prompt drives a single default-agent session
161
+ // through locate → analyze → synthesize inline, instead of Claude's
162
+ // sub-agent dispatch.
163
+ const result = await s.client.session.prompt({
164
+ sessionID: s.session.id,
165
+ parts: [
166
+ {
167
+ type: "text",
168
+ text: buildHistoryPromptGeneric({
169
+ question: prompt,
170
+ root,
171
+ }),
172
+ },
173
+ ],
174
+ });
175
+ s.save(result.data!);
176
+ },
177
+ ),
178
+ ]);
179
+
180
+ const {
181
+ partitions,
182
+ explorerCount,
183
+ scratchDir,
184
+ totalLoc,
185
+ totalFiles,
186
+ } = scout.result;
187
+
188
+ // Pull both scout transcripts ONCE at the workflow level so every
189
+ // explorer + the aggregator can embed them in their prompts (F5). Both
190
+ // stages have completed here (we're past Promise.all), so these reads
191
+ // are safe (F13).
192
+ const scoutOverview = (await ctx.transcript(scout)).content;
193
+ const historyOverview = (await ctx.transcript(history)).content;
194
+
195
+ // ── Stage 2: parallel explorers ────────────────────────────────────────
196
+ // Each explorer is a separate OpenCode session, running concurrently via
197
+ // Promise.all. Because the session is fresh (F5), every piece of context
198
+ // it needs — question, architectural orientation, historical context,
199
+ // partition assignment, scratch path — is injected into the first prompt
200
+ // via buildExplorerPromptGeneric.
201
+ const explorerHandles = await Promise.all(
202
+ partitions.map((partition, idx) => {
203
+ const i = idx + 1;
204
+ const scratchPath = path.join(scratchDir, `explorer-${i}.md`);
205
+ return ctx.stage(
206
+ {
207
+ name: `explorer-${i}`,
208
+ description: `Explore ${partition
209
+ .map((u) => u.path)
210
+ .join(", ")} (${partition.reduce((s, u) => s + u.fileCount, 0)} files)`,
211
+ },
212
+ {},
213
+ { title: `explorer-${i}` },
214
+ async (s) => {
215
+ const result = await s.client.session.prompt({
216
+ sessionID: s.session.id,
217
+ parts: [
218
+ {
219
+ type: "text",
220
+ text: buildExplorerPromptGeneric({
221
+ question: prompt,
222
+ index: i,
223
+ total: explorerCount,
224
+ partition,
225
+ scoutOverview,
226
+ historyOverview,
227
+ scratchPath,
228
+ root,
229
+ }),
230
+ },
231
+ ],
232
+ });
233
+ s.save(result.data!);
234
+
235
+ // Returning structured metadata lets the aggregator stage reach
236
+ // each explorer's scratch path without re-parsing transcripts.
237
+ return { index: i, scratchPath, partition };
238
+ },
239
+ );
240
+ }),
241
+ );
242
+
243
+ // ── Stage 3: aggregator ────────────────────────────────────────────────
244
+ // Reads explorer findings via FILE PATHS (filesystem-context skill) to
245
+ // keep the aggregator's own context lean — we deliberately do NOT inline
246
+ // N transcripts into the prompt. Token cost stays roughly constant in N.
247
+ const finalPath = path.join(
248
+ root,
249
+ "research",
250
+ "docs",
251
+ `${isoDate}-${slug}.md`,
252
+ );
253
+
254
+ await ctx.stage(
255
+ {
256
+ name: "aggregator",
257
+ description: "Synthesize explorer findings + history into final research doc",
258
+ },
259
+ {},
260
+ { title: "aggregator" },
261
+ async (s) => {
262
+ const result = await s.client.session.prompt({
263
+ sessionID: s.session.id,
264
+ parts: [
265
+ {
266
+ type: "text",
267
+ text: buildAggregatorPrompt({
268
+ question: prompt,
269
+ totalLoc,
270
+ totalFiles,
271
+ explorerCount,
272
+ explorerFiles: explorerHandles.map((h) => h.result),
273
+ finalPath,
274
+ scoutOverview,
275
+ historyOverview,
276
+ }),
277
+ },
278
+ ],
279
+ });
280
+ s.save(result.data!);
281
+ },
282
+ );
283
+ })
284
+ .compile();
@@ -37,6 +37,10 @@ export default defineWorkflow<"claude">({
37
37
  "Plan → orchestrate → review → debug loop with bounded iteration",
38
38
  })
39
39
  .run(async (ctx) => {
40
+ // Free-form workflows receive their positional prompt under
41
+ // `inputs.prompt`; destructure once so every stage below can close
42
+ // over a bare `prompt` string without re-reaching into ctx.inputs.
43
+ const prompt = ctx.inputs.prompt ?? "";
40
44
  let consecutiveClean = 0;
41
45
  let debuggerReport = "";
42
46
 
@@ -51,7 +55,7 @@ export default defineWorkflow<"claude">({
51
55
  await s.session.query(
52
56
  asAgentCall(
53
57
  "planner",
54
- buildPlannerPrompt(s.userPrompt, {
58
+ buildPlannerPrompt(prompt, {
55
59
  iteration,
56
60
  debuggerReport: debuggerReport || undefined,
57
61
  }),
@@ -72,7 +76,7 @@ export default defineWorkflow<"claude">({
72
76
  await s.session.query(
73
77
  asAgentCall(
74
78
  "orchestrator",
75
- buildOrchestratorPrompt(s.userPrompt),
79
+ buildOrchestratorPrompt(prompt),
76
80
  ),
77
81
  );
78
82
  s.save(s.sessionId);
@@ -91,7 +95,7 @@ export default defineWorkflow<"claude">({
91
95
  const result = await s.session.query(
92
96
  asAgentCall(
93
97
  "reviewer",
94
- buildReviewPrompt(s.userPrompt, { gitStatus, iteration }),
98
+ buildReviewPrompt(prompt, { gitStatus, iteration }),
95
99
  ),
96
100
  );
97
101
  s.save(s.sessionId);
@@ -118,7 +122,7 @@ export default defineWorkflow<"claude">({
118
122
  const result = await s.session.query(
119
123
  asAgentCall(
120
124
  "reviewer",
121
- buildReviewPrompt(s.userPrompt, {
125
+ buildReviewPrompt(prompt, {
122
126
  gitStatus,
123
127
  iteration,
124
128
  isConfirmationPass: true,