@os-eco/overstory-cli 0.6.1
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/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { AgentError } from "../errors.ts";
|
|
4
|
+
import type { OverlayConfig, QualityGate } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the path to the overlay template file.
|
|
8
|
+
* The template lives at `templates/overlay.md.tmpl` relative to the repo root.
|
|
9
|
+
*/
|
|
10
|
+
function getTemplatePath(): string {
|
|
11
|
+
// src/agents/overlay.ts -> repo root is ../../
|
|
12
|
+
return join(dirname(import.meta.dir), "..", "templates", "overlay.md.tmpl");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Format the file scope list as a markdown bullet list.
|
|
17
|
+
* Returns a human-readable fallback if no files are scoped.
|
|
18
|
+
*/
|
|
19
|
+
function formatFileScope(fileScope: readonly string[]): string {
|
|
20
|
+
if (fileScope.length === 0) {
|
|
21
|
+
return "No file scope restrictions";
|
|
22
|
+
}
|
|
23
|
+
return fileScope.map((f) => `- \`${f}\``).join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format mulch domains as a `mulch prime` command.
|
|
28
|
+
* Returns a human-readable fallback if no domains are configured.
|
|
29
|
+
*/
|
|
30
|
+
function formatMulchDomains(domains: readonly string[]): string {
|
|
31
|
+
if (domains.length === 0) {
|
|
32
|
+
return "No specific expertise domains configured";
|
|
33
|
+
}
|
|
34
|
+
return `\`\`\`bash\nmulch prime ${domains.join(" ")}\n\`\`\``;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Format pre-fetched mulch expertise for embedding in the overlay.
|
|
39
|
+
* Returns empty string if no expertise was provided (omits the section entirely).
|
|
40
|
+
* When expertise IS provided, renders it under a 'Pre-loaded Expertise' heading
|
|
41
|
+
* with a brief intro explaining it was loaded at spawn time based on file scope.
|
|
42
|
+
*/
|
|
43
|
+
function formatMulchExpertise(expertise: string | undefined): string {
|
|
44
|
+
if (!expertise || expertise.trim().length === 0) {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
return [
|
|
48
|
+
"### Pre-loaded Expertise",
|
|
49
|
+
"",
|
|
50
|
+
"The following expertise was automatically loaded at spawn time based on your file scope:",
|
|
51
|
+
"",
|
|
52
|
+
expertise,
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Capabilities that are read-only and should not get quality gates for commits/tests/lint. */
|
|
57
|
+
const READ_ONLY_CAPABILITIES = new Set(["scout", "reviewer"]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The skip-scout section injected into lead overlays when --skip-scout is passed.
|
|
61
|
+
* Instructs the lead to bypass Phase 1 (exploration) and go straight to Phase 2 (build).
|
|
62
|
+
*/
|
|
63
|
+
const SKIP_SCOUT_SECTION = `
|
|
64
|
+
## Skip Scout Mode
|
|
65
|
+
|
|
66
|
+
**IMPORTANT**: You have been spawned with \`--skip-scout\`. Skip Phase 1 (Scout) entirely.
|
|
67
|
+
Go directly to Phase 2 (Build): write specs from your existing knowledge and the
|
|
68
|
+
pre-loaded expertise above, then spawn builders immediately.
|
|
69
|
+
|
|
70
|
+
Do NOT spawn scout agents. Do NOT explore the codebase extensively.
|
|
71
|
+
Your parent has already gathered the context you need.
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format the quality gates section. Read-only agents (scout, reviewer) get
|
|
76
|
+
* a lightweight section that only tells them to close the issue and report.
|
|
77
|
+
* Writable agents get the full quality gates (tests, lint, build, commit).
|
|
78
|
+
*/
|
|
79
|
+
/** Default quality gates used when none are configured. */
|
|
80
|
+
const DEFAULT_GATES: QualityGate[] = [
|
|
81
|
+
{ name: "Tests", command: "bun test", description: "all tests must pass" },
|
|
82
|
+
{ name: "Lint", command: "bun run lint", description: "zero errors" },
|
|
83
|
+
{ name: "Typecheck", command: "bun run typecheck", description: "no TypeScript errors" },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
function formatQualityGates(config: OverlayConfig): string {
|
|
87
|
+
if (READ_ONLY_CAPABILITIES.has(config.capability)) {
|
|
88
|
+
return [
|
|
89
|
+
"## Completion",
|
|
90
|
+
"",
|
|
91
|
+
"Before reporting completion:",
|
|
92
|
+
"",
|
|
93
|
+
`1. **Record mulch learnings:** \`mulch record <domain> --type <convention|pattern|reference> --description "..."\` — capture reusable knowledge from your work`,
|
|
94
|
+
`2. **Close issue:** \`${config.trackerCli ?? "bd"} close ${config.beadId} --reason "summary of findings"\``,
|
|
95
|
+
`3. **Send results:** \`overstory mail send --to ${config.parentAgent ?? "coordinator"} --subject "done" --body "Summary" --type result --agent ${config.agentName}\``,
|
|
96
|
+
"",
|
|
97
|
+
"You are a read-only agent. Do NOT commit, modify files, or run quality gates.",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const gates =
|
|
102
|
+
config.qualityGates && config.qualityGates.length > 0 ? config.qualityGates : DEFAULT_GATES;
|
|
103
|
+
|
|
104
|
+
const gateLines = gates.map(
|
|
105
|
+
(gate, i) => `${i + 1}. **${gate.name}:** \`${gate.command}\` — ${gate.description}`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
"## Quality Gates",
|
|
110
|
+
"",
|
|
111
|
+
"Before reporting completion, you MUST pass all quality gates:",
|
|
112
|
+
"",
|
|
113
|
+
...gateLines,
|
|
114
|
+
`${gateLines.length + 1}. **Commit:** all changes committed to your branch (${config.branchName})`,
|
|
115
|
+
`${gateLines.length + 2}. **Record mulch learnings:** \`mulch record <domain> --type <convention|pattern|failure|decision> --description "..." --outcome-status success --outcome-agent ${config.agentName}\` — capture insights from your work`,
|
|
116
|
+
`${gateLines.length + 3}. **Signal completion:** send \`worker_done\` mail to ${config.parentAgent ?? "coordinator"}: \`overstory mail send --to ${config.parentAgent ?? "coordinator"} --subject "Worker done: ${config.beadId}" --body "Quality gates passed." --type worker_done --agent ${config.agentName}\``,
|
|
117
|
+
`${gateLines.length + 4}. **Close issue:** \`${config.trackerCli ?? "bd"} close ${config.beadId} --reason "summary of changes"\``,
|
|
118
|
+
"",
|
|
119
|
+
"Do NOT push to the canonical branch. Your work will be merged by the",
|
|
120
|
+
"coordinator via `overstory merge`.",
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Format the constraints section. Read-only agents get read-only constraints.
|
|
126
|
+
* Writable agents get file-scope and branch constraints.
|
|
127
|
+
*/
|
|
128
|
+
function formatConstraints(config: OverlayConfig): string {
|
|
129
|
+
if (READ_ONLY_CAPABILITIES.has(config.capability)) {
|
|
130
|
+
return [
|
|
131
|
+
"## Constraints",
|
|
132
|
+
"",
|
|
133
|
+
"- You are **read-only**: do NOT modify, create, or delete any files",
|
|
134
|
+
"- Do NOT commit, push, or make any git state changes",
|
|
135
|
+
`- Report completion via \`${config.trackerCli ?? "bd"} close\` AND \`overstory mail send --type result\``,
|
|
136
|
+
"- If you encounter a blocking issue, send mail with `--priority urgent --type error`",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [
|
|
141
|
+
"## Constraints",
|
|
142
|
+
"",
|
|
143
|
+
`- **WORKTREE ISOLATION**: All writes MUST target files within your worktree at \`${config.worktreePath}\``,
|
|
144
|
+
"- NEVER write to the canonical repo root — all writes go to your worktree copy",
|
|
145
|
+
"- Only modify files in your File Scope",
|
|
146
|
+
`- Commit only to your branch: ${config.branchName}`,
|
|
147
|
+
"- Never push to the canonical branch",
|
|
148
|
+
`- Report completion via \`${config.trackerCli ?? "bd"} close\` AND \`overstory mail send --type result\``,
|
|
149
|
+
"- If you encounter a blocking issue, send mail with `--priority urgent --type error`",
|
|
150
|
+
].join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Format the can-spawn section. If the agent can spawn sub-workers,
|
|
155
|
+
* include an example sling command. Otherwise, state the restriction.
|
|
156
|
+
*/
|
|
157
|
+
function formatCanSpawn(config: OverlayConfig): string {
|
|
158
|
+
if (!config.canSpawn) {
|
|
159
|
+
return "You may NOT spawn sub-workers.";
|
|
160
|
+
}
|
|
161
|
+
return [
|
|
162
|
+
"You may spawn sub-workers using `overstory sling`. Example:",
|
|
163
|
+
"",
|
|
164
|
+
"```bash",
|
|
165
|
+
"overstory sling <task-id> --capability builder --name <worker-name> \\",
|
|
166
|
+
` --parent ${config.agentName} --depth ${config.depth + 1}`,
|
|
167
|
+
"```",
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Generate a per-worker CLAUDE.md overlay from the template.
|
|
173
|
+
*
|
|
174
|
+
* Reads `templates/overlay.md.tmpl` and replaces all `{{VARIABLE}}`
|
|
175
|
+
* placeholders with values derived from the provided config.
|
|
176
|
+
*
|
|
177
|
+
* @param config - The overlay configuration for this agent/task
|
|
178
|
+
* @returns The rendered overlay content as a string
|
|
179
|
+
* @throws {AgentError} If the template file cannot be found or read
|
|
180
|
+
*/
|
|
181
|
+
export async function generateOverlay(config: OverlayConfig): Promise<string> {
|
|
182
|
+
const templatePath = getTemplatePath();
|
|
183
|
+
const file = Bun.file(templatePath);
|
|
184
|
+
const exists = await file.exists();
|
|
185
|
+
|
|
186
|
+
if (!exists) {
|
|
187
|
+
throw new AgentError(`Overlay template not found: ${templatePath}`, {
|
|
188
|
+
agentName: config.agentName,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let template: string;
|
|
193
|
+
try {
|
|
194
|
+
template = await file.text();
|
|
195
|
+
} catch (err) {
|
|
196
|
+
throw new AgentError(`Failed to read overlay template: ${templatePath}`, {
|
|
197
|
+
agentName: config.agentName,
|
|
198
|
+
cause: err instanceof Error ? err : undefined,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const specInstruction = config.specPath
|
|
203
|
+
? "Read your task spec at the path above. It contains the full description of\nwhat you need to build or review."
|
|
204
|
+
: "No task spec was provided. Check your mail or ask your parent agent for details.";
|
|
205
|
+
|
|
206
|
+
const replacements: Record<string, string> = {
|
|
207
|
+
"{{AGENT_NAME}}": config.agentName,
|
|
208
|
+
"{{BEAD_ID}}": config.beadId,
|
|
209
|
+
"{{SPEC_PATH}}": config.specPath ?? "No spec file provided",
|
|
210
|
+
"{{BRANCH_NAME}}": config.branchName,
|
|
211
|
+
"{{WORKTREE_PATH}}": config.worktreePath,
|
|
212
|
+
"{{PARENT_AGENT}}": config.parentAgent ?? "coordinator",
|
|
213
|
+
"{{DEPTH}}": String(config.depth),
|
|
214
|
+
"{{FILE_SCOPE}}": formatFileScope(config.fileScope),
|
|
215
|
+
"{{MULCH_DOMAINS}}": formatMulchDomains(config.mulchDomains),
|
|
216
|
+
"{{MULCH_EXPERTISE}}": formatMulchExpertise(config.mulchExpertise),
|
|
217
|
+
"{{CAN_SPAWN}}": formatCanSpawn(config),
|
|
218
|
+
"{{QUALITY_GATES}}": formatQualityGates(config),
|
|
219
|
+
"{{CONSTRAINTS}}": formatConstraints(config),
|
|
220
|
+
"{{SPEC_INSTRUCTION}}": specInstruction,
|
|
221
|
+
"{{SKIP_SCOUT}}": config.skipScout ? SKIP_SCOUT_SECTION : "",
|
|
222
|
+
"{{BASE_DEFINITION}}": config.baseDefinition,
|
|
223
|
+
"{{TRACKER_CLI}}": config.trackerCli ?? "bd",
|
|
224
|
+
"{{TRACKER_NAME}}": config.trackerName ?? "beads",
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
let result = template;
|
|
228
|
+
for (const [placeholder, value] of Object.entries(replacements)) {
|
|
229
|
+
// Replace all occurrences — some placeholders appear multiple times
|
|
230
|
+
while (result.includes(placeholder)) {
|
|
231
|
+
result = result.replace(placeholder, value);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check whether a directory is the canonical project root by comparing resolved paths.
|
|
240
|
+
*
|
|
241
|
+
* Agent overlays must NEVER be written to the canonical repo root -- they belong
|
|
242
|
+
* in worktrees. Writing an overlay to the project root overwrites the orchestrator's
|
|
243
|
+
* `.claude/CLAUDE.md`, breaking the user's own Claude Code session (overstory-uwg4).
|
|
244
|
+
*
|
|
245
|
+
* Uses deterministic path comparison instead of checking for `.overstory/config.yaml`
|
|
246
|
+
* because when dogfooding (running overstory on its own repo), that file is tracked
|
|
247
|
+
* in git and appears in every worktree checkout (overstory-p4st).
|
|
248
|
+
*
|
|
249
|
+
* @param dir - Absolute path to check
|
|
250
|
+
* @param canonicalRoot - Absolute path to the canonical project root
|
|
251
|
+
* @returns true if dir resolves to the same path as canonicalRoot
|
|
252
|
+
*/
|
|
253
|
+
export function isCanonicalRoot(dir: string, canonicalRoot: string): boolean {
|
|
254
|
+
return resolve(dir) === resolve(canonicalRoot);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Generate the overlay and write it to `{worktreePath}/.claude/CLAUDE.md`.
|
|
259
|
+
* Creates the `.claude/` directory if it does not exist.
|
|
260
|
+
*
|
|
261
|
+
* Includes a safety guard that prevents writing to the canonical project root.
|
|
262
|
+
* Agent overlays belong in worktrees, never at the orchestrator's root.
|
|
263
|
+
*
|
|
264
|
+
* @param worktreePath - Absolute path to the agent's git worktree
|
|
265
|
+
* @param config - The overlay configuration for this agent/task
|
|
266
|
+
* @param canonicalRoot - Absolute path to the canonical project root (for guard check)
|
|
267
|
+
* @throws {AgentError} If worktreePath is the canonical project root, or if
|
|
268
|
+
* the directory cannot be created or the file cannot be written
|
|
269
|
+
*/
|
|
270
|
+
export async function writeOverlay(
|
|
271
|
+
worktreePath: string,
|
|
272
|
+
config: OverlayConfig,
|
|
273
|
+
canonicalRoot: string,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
// Guard: never write agent overlays to the canonical project root.
|
|
276
|
+
// The project root's .claude/CLAUDE.md belongs to the orchestrator/user.
|
|
277
|
+
// Uses path comparison instead of file-existence heuristic to handle
|
|
278
|
+
// dogfooding scenarios where .overstory/config.yaml is tracked in git
|
|
279
|
+
// and appears in every worktree checkout (overstory-p4st).
|
|
280
|
+
if (isCanonicalRoot(worktreePath, canonicalRoot)) {
|
|
281
|
+
throw new AgentError(
|
|
282
|
+
`Refusing to write overlay to canonical project root: ${worktreePath}. Agent overlays must target a worktree, not the orchestrator's root directory. This prevents overwriting the user's .claude/CLAUDE.md.`,
|
|
283
|
+
{ agentName: config.agentName },
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const content = await generateOverlay(config);
|
|
288
|
+
const claudeDir = join(worktreePath, ".claude");
|
|
289
|
+
const outputPath = join(claudeDir, "CLAUDE.md");
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
await mkdir(claudeDir, { recursive: true });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
throw new AgentError(`Failed to create .claude/ directory at: ${claudeDir}`, {
|
|
295
|
+
agentName: config.agentName,
|
|
296
|
+
cause: err instanceof Error ? err : undefined,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await Bun.write(outputPath, content);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
throw new AgentError(`Failed to write overlay to: ${outputPath}`, {
|
|
304
|
+
agentName: config.agentName,
|
|
305
|
+
cause: err instanceof Error ? err : undefined,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { AgentError } from "../errors.ts";
|
|
4
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
5
|
+
import { type BeadsClient, createBeadsClient } from "./client.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if the bd CLI is available on this machine (synchronous).
|
|
9
|
+
* Uses Bun.spawnSync so the result is available at test registration time
|
|
10
|
+
* for use with test.skipIf().
|
|
11
|
+
*/
|
|
12
|
+
function isBdAvailable(): boolean {
|
|
13
|
+
try {
|
|
14
|
+
const result = Bun.spawnSync(["bd", "--version"], {
|
|
15
|
+
stdout: "pipe",
|
|
16
|
+
stderr: "pipe",
|
|
17
|
+
});
|
|
18
|
+
return result.exitCode === 0;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initialize beads in a git repo directory.
|
|
26
|
+
*/
|
|
27
|
+
async function initBeads(cwd: string): Promise<void> {
|
|
28
|
+
const proc = Bun.spawn(["bd", "init"], {
|
|
29
|
+
cwd,
|
|
30
|
+
stdout: "pipe",
|
|
31
|
+
stderr: "pipe",
|
|
32
|
+
});
|
|
33
|
+
const exitCode = await proc.exited;
|
|
34
|
+
if (exitCode !== 0) {
|
|
35
|
+
const stderr = await new Response(proc.stderr).text();
|
|
36
|
+
throw new Error(`bd init failed: ${stderr}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const bdAvailable = isBdAvailable();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Optimized test suite: uses a single shared repo (beforeAll) instead of
|
|
44
|
+
* creating a fresh repo per test. All 16 original tests share one repo
|
|
45
|
+
* since they create issues with unique IDs and use toContain/not.toContain
|
|
46
|
+
* assertions. This reduces setup from ~96 subprocess spawns to ~6.
|
|
47
|
+
*/
|
|
48
|
+
describe("createBeadsClient (integration)", () => {
|
|
49
|
+
let tempDir: string;
|
|
50
|
+
let client: BeadsClient;
|
|
51
|
+
|
|
52
|
+
// Pre-created issue IDs for tests that need existing issues
|
|
53
|
+
let openIssueId: string;
|
|
54
|
+
let claimedIssueId: string;
|
|
55
|
+
let closedIssueId: string;
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
if (!bdAvailable) return;
|
|
59
|
+
// realpathSync resolves macOS /var -> /private/var symlink so paths match
|
|
60
|
+
tempDir = realpathSync(await createTempGitRepo());
|
|
61
|
+
await initBeads(tempDir);
|
|
62
|
+
client = createBeadsClient(tempDir);
|
|
63
|
+
|
|
64
|
+
// Pre-create issues used by read-only tests (list, ready, show)
|
|
65
|
+
openIssueId = await client.create("Pre-created open issue");
|
|
66
|
+
claimedIssueId = await client.create("Pre-created claimed issue");
|
|
67
|
+
await client.claim(claimedIssueId);
|
|
68
|
+
closedIssueId = await client.create("Pre-created closed issue");
|
|
69
|
+
await client.close(closedIssueId);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
if (!bdAvailable) return;
|
|
74
|
+
await cleanupTempDir(tempDir);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("create", () => {
|
|
78
|
+
test.skipIf(!bdAvailable)("returns an issue ID", async () => {
|
|
79
|
+
const id = await client.create("Integration test issue");
|
|
80
|
+
|
|
81
|
+
expect(typeof id).toBe("string");
|
|
82
|
+
expect(id.length).toBeGreaterThan(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test.skipIf(!bdAvailable)("returns ID with type and priority options", async () => {
|
|
86
|
+
const id = await client.create("Typed issue", {
|
|
87
|
+
type: "bug",
|
|
88
|
+
priority: 1,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(typeof id).toBe("string");
|
|
92
|
+
expect(id.length).toBeGreaterThan(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test.skipIf(!bdAvailable)("returns ID with description option", async () => {
|
|
96
|
+
const id = await client.create("Described issue", {
|
|
97
|
+
description: "A detailed description",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(typeof id).toBe("string");
|
|
101
|
+
expect(id.length).toBeGreaterThan(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("show", () => {
|
|
106
|
+
test.skipIf(!bdAvailable)("returns issue details for a valid ID", async () => {
|
|
107
|
+
const id = await client.create("Show test issue", {
|
|
108
|
+
type: "task",
|
|
109
|
+
priority: 2,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const issue = await client.show(id);
|
|
113
|
+
|
|
114
|
+
expect(issue.id).toBe(id);
|
|
115
|
+
expect(issue.title).toBe("Show test issue");
|
|
116
|
+
expect(issue.status).toBe("open");
|
|
117
|
+
expect(issue.priority).toBe(2);
|
|
118
|
+
expect(issue.type).toBe("task");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("claim", () => {
|
|
123
|
+
test.skipIf(!bdAvailable)("changes issue status to in_progress and returns void", async () => {
|
|
124
|
+
const id = await client.create("Claim test issue");
|
|
125
|
+
|
|
126
|
+
const result = await client.claim(id);
|
|
127
|
+
expect(result).toBeUndefined();
|
|
128
|
+
|
|
129
|
+
const issue = await client.show(id);
|
|
130
|
+
expect(issue.status).toBe("in_progress");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("close", () => {
|
|
135
|
+
test.skipIf(!bdAvailable)("closes issues with and without reason", async () => {
|
|
136
|
+
const id1 = await client.create("Close test issue");
|
|
137
|
+
const id2 = await client.create("Close reason test");
|
|
138
|
+
|
|
139
|
+
await client.close(id1);
|
|
140
|
+
await client.close(id2, "Completed all acceptance criteria");
|
|
141
|
+
|
|
142
|
+
const issue1 = await client.show(id1);
|
|
143
|
+
expect(issue1.status).toBe("closed");
|
|
144
|
+
|
|
145
|
+
const issue2 = await client.show(id2);
|
|
146
|
+
expect(issue2.status).toBe("closed");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("list", () => {
|
|
151
|
+
test.skipIf(!bdAvailable)("returns all issues", async () => {
|
|
152
|
+
const issues = await client.list();
|
|
153
|
+
|
|
154
|
+
// Pre-created issues should be present (plus any from other tests)
|
|
155
|
+
expect(issues.length).toBeGreaterThanOrEqual(3);
|
|
156
|
+
const titles = issues.map((i) => i.title);
|
|
157
|
+
expect(titles).toContain("Pre-created open issue");
|
|
158
|
+
expect(titles).toContain("Pre-created claimed issue");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test.skipIf(!bdAvailable)("filters by status", async () => {
|
|
162
|
+
const openIssues = await client.list({ status: "open" });
|
|
163
|
+
const openIds = openIssues.map((i) => i.id);
|
|
164
|
+
expect(openIds).toContain(openIssueId);
|
|
165
|
+
expect(openIds).not.toContain(claimedIssueId);
|
|
166
|
+
expect(openIds).not.toContain(closedIssueId);
|
|
167
|
+
|
|
168
|
+
const inProgressIssues = await client.list({ status: "in_progress" });
|
|
169
|
+
const inProgressIds = inProgressIssues.map((i) => i.id);
|
|
170
|
+
expect(inProgressIds).toContain(claimedIssueId);
|
|
171
|
+
expect(inProgressIds).not.toContain(openIssueId);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test.skipIf(!bdAvailable)("respects limit option", async () => {
|
|
175
|
+
const limited = await client.list({ limit: 1 });
|
|
176
|
+
expect(limited).toHaveLength(1);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("ready", () => {
|
|
181
|
+
test.skipIf(!bdAvailable)(
|
|
182
|
+
"returns open unblocked issues but not claimed or closed",
|
|
183
|
+
async () => {
|
|
184
|
+
const readyIssues = await client.ready();
|
|
185
|
+
const readyIds = readyIssues.map((i) => i.id);
|
|
186
|
+
|
|
187
|
+
// Open issue should appear in ready
|
|
188
|
+
expect(readyIds).toContain(openIssueId);
|
|
189
|
+
// Claimed issue should not appear in ready
|
|
190
|
+
expect(readyIds).not.toContain(claimedIssueId);
|
|
191
|
+
// Closed issue should not appear in ready
|
|
192
|
+
expect(readyIds).not.toContain(closedIssueId);
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("error handling", () => {
|
|
198
|
+
test.skipIf(!bdAvailable)("show throws AgentError for nonexistent ID", async () => {
|
|
199
|
+
await expect(client.show("nonexistent-id")).rejects.toThrow(AgentError);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test.skipIf(!bdAvailable)(
|
|
203
|
+
"throws AgentError when bd is run without beads initialized",
|
|
204
|
+
async () => {
|
|
205
|
+
// Create a git repo without bd init — independent from shared repo
|
|
206
|
+
const bareDir = realpathSync(await createTempGitRepo());
|
|
207
|
+
const bareClient = createBeadsClient(bareDir);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await expect(bareClient.list()).rejects.toThrow(AgentError);
|
|
211
|
+
} finally {
|
|
212
|
+
await cleanupTempDir(bareDir);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|