@bastani/atomic 0.5.3-1 → 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.
- package/README.md +110 -11
- package/dist/{chunk-mn870nrv.js → chunk-xkxndz5g.js} +213 -154
- package/dist/sdk/components/workflow-picker-panel.d.ts +120 -0
- package/dist/sdk/define-workflow.d.ts +1 -1
- package/dist/sdk/index.js +1 -1
- package/dist/sdk/runtime/discovery.d.ts +57 -3
- package/dist/sdk/runtime/executor.d.ts +15 -2
- package/dist/sdk/runtime/tmux.d.ts +9 -0
- package/dist/sdk/types.d.ts +63 -4
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +61 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +48 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +25 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +91 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +56 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +48 -0
- package/dist/sdk/workflows/builtin/ralph/claude/index.js +6 -5
- package/dist/sdk/workflows/builtin/ralph/copilot/index.js +6 -5
- package/dist/sdk/workflows/builtin/ralph/opencode/index.js +6 -5
- package/dist/sdk/workflows/index.d.ts +4 -4
- package/dist/sdk/workflows/index.js +7 -1
- package/package.json +1 -1
- package/src/cli.ts +25 -3
- package/src/commands/cli/chat/index.ts +5 -5
- package/src/commands/cli/init/index.ts +79 -77
- package/src/commands/cli/workflow-command.test.ts +757 -0
- package/src/commands/cli/workflow.test.ts +310 -0
- package/src/commands/cli/workflow.ts +445 -105
- package/src/sdk/components/workflow-picker-panel.tsx +1462 -0
- package/src/sdk/define-workflow.test.ts +101 -0
- package/src/sdk/define-workflow.ts +62 -2
- package/src/sdk/runtime/discovery.ts +111 -8
- package/src/sdk/runtime/executor.ts +89 -32
- package/src/sdk/runtime/tmux.conf +55 -0
- package/src/sdk/runtime/tmux.ts +34 -10
- package/src/sdk/types.ts +67 -4
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +294 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +276 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.ts +38 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +816 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +334 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +284 -0
- package/src/sdk/workflows/builtin/ralph/claude/index.ts +8 -4
- package/src/sdk/workflows/builtin/ralph/copilot/index.ts +10 -4
- package/src/sdk/workflows/builtin/ralph/opencode/index.ts +8 -4
- package/src/sdk/workflows/index.ts +9 -1
- package/src/services/system/auto-sync.ts +1 -1
- package/src/services/system/install-ui.ts +109 -39
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
125
|
+
buildReviewPrompt(prompt, {
|
|
122
126
|
gitStatus,
|
|
123
127
|
iteration,
|
|
124
128
|
isConfirmationPass: true,
|