@os-eco/overstory-cli 0.6.11 → 0.7.2

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 (87) hide show
  1. package/README.md +12 -13
  2. package/agents/builder.md +1 -1
  3. package/agents/coordinator.md +12 -11
  4. package/agents/lead.md +25 -24
  5. package/agents/monitor.md +4 -4
  6. package/agents/reviewer.md +1 -1
  7. package/agents/scout.md +5 -5
  8. package/agents/supervisor.md +36 -32
  9. package/package.json +5 -3
  10. package/src/agents/guard-rules.ts +97 -0
  11. package/src/agents/hooks-deployer.ts +7 -90
  12. package/src/agents/overlay.test.ts +30 -7
  13. package/src/agents/overlay.ts +10 -9
  14. package/src/commands/agents.test.ts +5 -0
  15. package/src/commands/clean.test.ts +3 -0
  16. package/src/commands/completions.ts +1 -1
  17. package/src/commands/coordinator.test.ts +1 -0
  18. package/src/commands/coordinator.ts +34 -18
  19. package/src/commands/costs.test.ts +6 -1
  20. package/src/commands/costs.ts +13 -20
  21. package/src/commands/dashboard.ts +38 -138
  22. package/src/commands/doctor.test.ts +1 -1
  23. package/src/commands/doctor.ts +2 -2
  24. package/src/commands/ecosystem.ts +2 -1
  25. package/src/commands/errors.test.ts +4 -5
  26. package/src/commands/errors.ts +4 -62
  27. package/src/commands/feed.test.ts +2 -2
  28. package/src/commands/feed.ts +12 -106
  29. package/src/commands/init.test.ts +1 -2
  30. package/src/commands/init.ts +1 -8
  31. package/src/commands/inspect.test.ts +14 -0
  32. package/src/commands/inspect.ts +10 -44
  33. package/src/commands/log.test.ts +14 -0
  34. package/src/commands/log.ts +39 -0
  35. package/src/commands/logs.ts +7 -63
  36. package/src/commands/mail.test.ts +5 -0
  37. package/src/commands/metrics.test.ts +2 -2
  38. package/src/commands/metrics.ts +3 -17
  39. package/src/commands/monitor.ts +30 -16
  40. package/src/commands/nudge.test.ts +1 -0
  41. package/src/commands/prime.test.ts +2 -0
  42. package/src/commands/prime.ts +6 -2
  43. package/src/commands/replay.test.ts +2 -2
  44. package/src/commands/replay.ts +12 -135
  45. package/src/commands/run.test.ts +1 -0
  46. package/src/commands/run.ts +7 -23
  47. package/src/commands/sling.test.ts +68 -1
  48. package/src/commands/sling.ts +62 -24
  49. package/src/commands/status.test.ts +1 -0
  50. package/src/commands/status.ts +4 -17
  51. package/src/commands/stop.test.ts +1 -0
  52. package/src/commands/supervisor.ts +35 -18
  53. package/src/commands/trace.test.ts +6 -6
  54. package/src/commands/trace.ts +11 -109
  55. package/src/commands/worktree.test.ts +9 -0
  56. package/src/config.ts +39 -0
  57. package/src/doctor/consistency.test.ts +14 -0
  58. package/src/e2e/init-sling-lifecycle.test.ts +3 -5
  59. package/src/index.ts +2 -1
  60. package/src/logging/format.ts +214 -0
  61. package/src/logging/theme.ts +132 -0
  62. package/src/mail/broadcast.test.ts +1 -0
  63. package/src/merge/resolver.ts +23 -4
  64. package/src/metrics/store.test.ts +46 -0
  65. package/src/metrics/store.ts +11 -0
  66. package/src/mulch/client.test.ts +20 -0
  67. package/src/mulch/client.ts +312 -45
  68. package/src/runtimes/claude.test.ts +616 -0
  69. package/src/runtimes/claude.ts +218 -0
  70. package/src/runtimes/pi-guards.test.ts +433 -0
  71. package/src/runtimes/pi-guards.ts +349 -0
  72. package/src/runtimes/pi.test.ts +620 -0
  73. package/src/runtimes/pi.ts +244 -0
  74. package/src/runtimes/registry.test.ts +86 -0
  75. package/src/runtimes/registry.ts +46 -0
  76. package/src/runtimes/types.ts +188 -0
  77. package/src/schema-consistency.test.ts +1 -0
  78. package/src/sessions/compat.ts +1 -0
  79. package/src/sessions/store.test.ts +31 -0
  80. package/src/sessions/store.ts +37 -4
  81. package/src/types.ts +21 -0
  82. package/src/watchdog/daemon.test.ts +7 -4
  83. package/src/watchdog/daemon.ts +1 -1
  84. package/src/watchdog/health.test.ts +1 -0
  85. package/src/watchdog/triage.ts +14 -4
  86. package/src/worktree/tmux.test.ts +28 -13
  87. package/src/worktree/tmux.ts +14 -28
@@ -0,0 +1,218 @@
1
+ // Claude Code runtime adapter for overstory's AgentRuntime interface.
2
+ // Pure extraction — no new behavior. All implementation delegates to existing code.
3
+ // Phase 0: file exists and compiles. Callers are not rewired until Phase 2.
4
+
5
+ import { mkdir } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { deployHooks } from "../agents/hooks-deployer.ts";
8
+ import { estimateCost, parseTranscriptUsage } from "../metrics/transcript.ts";
9
+ import type { ResolvedModel } from "../types.ts";
10
+ import type {
11
+ AgentRuntime,
12
+ HooksDef,
13
+ OverlayContent,
14
+ ReadyState,
15
+ SpawnOpts,
16
+ TranscriptSummary,
17
+ } from "./types.ts";
18
+
19
+ /**
20
+ * Claude Code runtime adapter.
21
+ *
22
+ * Implements AgentRuntime for the `claude` CLI (Anthropic's Claude Code).
23
+ * All methods delegate to existing overstory subsystems — this adapter
24
+ * only provides the runtime-agnostic interface layer.
25
+ *
26
+ * Phase 0: file exists, compiles, and exports the class.
27
+ * Phase 2 will rewire callers (sling.ts, coordinator.ts, etc.) to use this adapter.
28
+ */
29
+ export class ClaudeRuntime implements AgentRuntime {
30
+ /** Unique identifier for this runtime. */
31
+ readonly id = "claude";
32
+
33
+ /** Relative path to the instruction file within a worktree. */
34
+ readonly instructionPath = ".claude/CLAUDE.md";
35
+
36
+ /**
37
+ * Build the shell command string to spawn an interactive Claude Code agent.
38
+ *
39
+ * Maps SpawnOpts to the `claude` CLI flags:
40
+ * - `model` → `--model <model>`
41
+ * - `permissionMode` → `--permission-mode <mode>`
42
+ * - "bypass" maps to "bypassPermissions"
43
+ * - "ask" maps to "default"
44
+ * - `appendSystemPrompt` → `--append-system-prompt '<escaped>'`
45
+ *
46
+ * The returned string is passed directly to tmux as the initial command.
47
+ * The `cwd` and `env` fields of SpawnOpts are handled by the tmux session
48
+ * creator, not embedded in the command string.
49
+ *
50
+ * @param opts - Spawn options (model, permissionMode, appendSystemPrompt)
51
+ * @returns Shell command string suitable for tmux new-session -c
52
+ */
53
+ buildSpawnCommand(opts: SpawnOpts): string {
54
+ const permMode = opts.permissionMode === "bypass" ? "bypassPermissions" : "default";
55
+ let cmd = `claude --model ${opts.model} --permission-mode ${permMode}`;
56
+
57
+ if (opts.appendSystemPrompt) {
58
+ // Single-quote the content for safe shell expansion.
59
+ // POSIX single-quoted strings cannot contain single quotes, so escape
60
+ // them using the standard technique: end quote, escaped quote, start quote.
61
+ const escaped = opts.appendSystemPrompt.replace(/'/g, "'\\''");
62
+ cmd += ` --append-system-prompt '${escaped}'`;
63
+ }
64
+
65
+ return cmd;
66
+ }
67
+
68
+ /**
69
+ * Build the argv array for a headless one-shot Claude invocation.
70
+ *
71
+ * Returns an argv array suitable for `Bun.spawn()`. The `--print` flag
72
+ * causes Claude Code to run the prompt and exit, writing output to stdout.
73
+ *
74
+ * Used by merge/resolver.ts (AI-assisted conflict resolution) and
75
+ * watchdog/triage.ts (AI-assisted failure classification).
76
+ *
77
+ * @param prompt - The prompt to pass via `-p`
78
+ * @param model - Optional model override (omit to use Claude Code's default)
79
+ * @returns Argv array for Bun.spawn
80
+ */
81
+ buildPrintCommand(prompt: string, model?: string): string[] {
82
+ const cmd = ["claude", "--print", "-p", prompt];
83
+ if (model !== undefined) {
84
+ cmd.push("--model", model);
85
+ }
86
+ return cmd;
87
+ }
88
+
89
+ /**
90
+ * Deploy per-agent instructions and guards to a worktree.
91
+ *
92
+ * For Claude Code this means writes to the worktree's `.claude/` directory:
93
+ * 1. `CLAUDE.md` — the agent's task-specific overlay (generated by ov sling).
94
+ * Skipped when overlay is undefined (hooks-only deployment for coordinator/supervisor/monitor).
95
+ * 2. `settings.local.json` — Claude Code hooks for security guards
96
+ *
97
+ * The `overlay.content` is written verbatim when provided. The hooks are generated by
98
+ * `deployHooks()` from `src/agents/hooks-deployer.ts`.
99
+ *
100
+ * @param worktreePath - Absolute path to the agent's git worktree
101
+ * @param overlay - Overlay content to write as CLAUDE.md, or undefined for hooks-only deployment
102
+ * @param hooks - Hook definition used by deployHooks
103
+ * @throws {AgentError} If the hooks template is missing or writes fail
104
+ */
105
+ async deployConfig(
106
+ worktreePath: string,
107
+ overlay: OverlayContent | undefined,
108
+ hooks: HooksDef,
109
+ ): Promise<void> {
110
+ if (overlay) {
111
+ const claudeDir = join(worktreePath, ".claude");
112
+ await mkdir(claudeDir, { recursive: true });
113
+
114
+ const claudeMdPath = join(claudeDir, "CLAUDE.md");
115
+ await Bun.write(claudeMdPath, overlay.content);
116
+ }
117
+
118
+ await deployHooks(hooks.worktreePath, hooks.agentName, hooks.capability, hooks.qualityGates);
119
+ }
120
+
121
+ /**
122
+ * Detect Claude Code TUI readiness from a tmux pane content snapshot.
123
+ *
124
+ * Uses the same heuristics as `waitForTuiReady()` in `src/worktree/tmux.ts`,
125
+ * but operates on a pre-captured pane string rather than polling tmux directly.
126
+ * The caller is responsible for capturing pane content and acting on the result
127
+ * (e.g. sending "Enter" to dismiss a trust dialog).
128
+ *
129
+ * Detection phases:
130
+ * - Trust dialog: "trust this folder" detected → `{ phase: "dialog", action: "Enter" }`
131
+ * - Ready: prompt indicator (❯ or 'Try "') AND status bar ("bypass permissions"
132
+ * or "shift+tab") both present → `{ phase: "ready" }`
133
+ * - Otherwise → `{ phase: "loading" }`
134
+ *
135
+ * @param paneContent - Captured tmux pane content to analyze
136
+ * @returns Current readiness phase
137
+ */
138
+ detectReady(paneContent: string): ReadyState {
139
+ // Trust dialog takes precedence — it replaces the normal TUI temporarily.
140
+ // The caller should send the action key to dismiss it.
141
+ if (paneContent.includes("trust this folder")) {
142
+ return { phase: "dialog", action: "Enter" };
143
+ }
144
+
145
+ // Phase 1: prompt indicator confirms Claude Code has started.
146
+ // ❯ is the claude prompt character; 'Try "' appears in the welcome banner.
147
+ const hasPrompt = paneContent.includes("\u276f") || paneContent.includes('Try "');
148
+
149
+ // Phase 2: status bar text confirms full TUI render.
150
+ const hasStatusBar =
151
+ paneContent.includes("bypass permissions") || paneContent.includes("shift+tab");
152
+
153
+ if (hasPrompt && hasStatusBar) {
154
+ return { phase: "ready" };
155
+ }
156
+
157
+ return { phase: "loading" };
158
+ }
159
+
160
+ /**
161
+ * Parse a Claude Code transcript JSONL file into normalized token usage.
162
+ *
163
+ * Reads the JSONL file at `path` and aggregates token usage across all
164
+ * assistant turns. Returns null if the file does not exist or cannot be read.
165
+ *
166
+ * Delegates to `parseTranscriptUsage()` and `estimateCost()` from
167
+ * `src/metrics/transcript.ts`. The `estimatedCostUsd` is computed but
168
+ * not exposed here because `TranscriptSummary` only carries the three
169
+ * core fields (inputTokens, outputTokens, model). Cost data is available
170
+ * via `src/metrics/transcript.ts` directly for callers that need it.
171
+ *
172
+ * @param path - Absolute path to the transcript JSONL file
173
+ * @returns Aggregated token usage, or null if unavailable
174
+ */
175
+ async parseTranscript(path: string): Promise<TranscriptSummary | null> {
176
+ const file = Bun.file(path);
177
+ if (!(await file.exists())) {
178
+ return null;
179
+ }
180
+
181
+ try {
182
+ const usage = await parseTranscriptUsage(path);
183
+ // estimateCost is called to validate the model is recognized,
184
+ // though the result is not surfaced in TranscriptSummary.
185
+ if (usage.modelUsed !== null) {
186
+ estimateCost(usage);
187
+ }
188
+ return {
189
+ inputTokens: usage.inputTokens,
190
+ outputTokens: usage.outputTokens,
191
+ model: usage.modelUsed ?? "",
192
+ };
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Build runtime-specific environment variables for model/provider routing.
200
+ *
201
+ * Returns the provider environment variables from the resolved model.
202
+ * For Anthropic native: may include ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL.
203
+ * For gateway providers: may include gateway-specific auth and routing vars.
204
+ *
205
+ * Returns an empty object if the resolved model has no provider env vars.
206
+ * Callers (sling.ts, coordinator.ts) merge this with OVERSTORY_AGENT_NAME
207
+ * and OVERSTORY_WORKTREE_PATH before passing to createSession().
208
+ *
209
+ * @param model - Resolved model with optional provider env vars
210
+ * @returns Environment variable map (may be empty)
211
+ */
212
+ buildEnv(model: ResolvedModel): Record<string, string> {
213
+ return model.env ?? {};
214
+ }
215
+ }
216
+
217
+ /** Singleton instance for use in callers that do not need DI. */
218
+ export const claudeRuntime = new ClaudeRuntime();
@@ -0,0 +1,433 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { INTERACTIVE_TOOLS, NATIVE_TEAM_TOOLS } from "../agents/guard-rules.ts";
3
+ import { PiRuntime } from "./pi.ts";
4
+ import { generatePiGuardExtension } from "./pi-guards.ts";
5
+ import type { HooksDef } from "./types.ts";
6
+
7
+ const WORKTREE = "/project/.overstory/worktrees/test-agent";
8
+
9
+ function builderHooks(name = "test-builder"): HooksDef {
10
+ return { agentName: name, capability: "builder", worktreePath: WORKTREE };
11
+ }
12
+
13
+ function scoutHooks(name = "test-scout"): HooksDef {
14
+ return { agentName: name, capability: "scout", worktreePath: WORKTREE };
15
+ }
16
+
17
+ function coordinatorHooks(name = "test-coordinator"): HooksDef {
18
+ return { agentName: name, capability: "coordinator", worktreePath: WORKTREE };
19
+ }
20
+
21
+ describe("generatePiGuardExtension", () => {
22
+ describe("header and identity", () => {
23
+ test("embeds agent name in generated code", () => {
24
+ const generated = generatePiGuardExtension(builderHooks("my-builder"));
25
+ expect(generated).toContain('const AGENT_NAME = "my-builder";');
26
+ });
27
+
28
+ test("embeds worktree path in generated code", () => {
29
+ const generated = generatePiGuardExtension(builderHooks());
30
+ expect(generated).toContain(`const WORKTREE_PATH = "${WORKTREE}";`);
31
+ });
32
+
33
+ test("embeds capability in file header comment", () => {
34
+ const generated = generatePiGuardExtension(builderHooks());
35
+ expect(generated).toContain("Capability: builder");
36
+ });
37
+
38
+ test("imports Pi Extension type", () => {
39
+ const generated = generatePiGuardExtension(builderHooks());
40
+ expect(generated).toContain(
41
+ 'import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";',
42
+ );
43
+ });
44
+
45
+ test("exports a default Pi Extension factory", () => {
46
+ const generated = generatePiGuardExtension(builderHooks());
47
+ expect(generated).toContain("export default function (pi: ExtensionAPI) {");
48
+ expect(generated).toContain('pi.on("tool_call", async (event) => {');
49
+ });
50
+ });
51
+
52
+ describe("TEAM_BLOCKED / INTERACTIVE_BLOCKED — separate sets per category (all capabilities)", () => {
53
+ test("all NATIVE_TEAM_TOOLS appear in TEAM_BLOCKED for builder", () => {
54
+ const generated = generatePiGuardExtension(builderHooks());
55
+ const teamSection = extractTeamBlockedSection(generated);
56
+ for (const tool of NATIVE_TEAM_TOOLS) {
57
+ expect(teamSection).toContain(`"${tool}"`);
58
+ }
59
+ });
60
+
61
+ test("all INTERACTIVE_TOOLS appear in INTERACTIVE_BLOCKED for builder", () => {
62
+ const generated = generatePiGuardExtension(builderHooks());
63
+ const interactiveSection = extractInteractiveBlockedSection(generated);
64
+ for (const tool of INTERACTIVE_TOOLS) {
65
+ expect(interactiveSection).toContain(`"${tool}"`);
66
+ }
67
+ });
68
+
69
+ test("TEAM_BLOCKED and INTERACTIVE_BLOCKED checks use has() for efficiency", () => {
70
+ const generated = generatePiGuardExtension(builderHooks());
71
+ expect(generated).toContain("TEAM_BLOCKED.has(event.toolName)");
72
+ expect(generated).toContain("INTERACTIVE_BLOCKED.has(event.toolName)");
73
+ });
74
+
75
+ test("team tools use delegation block reason", () => {
76
+ const generated = generatePiGuardExtension(builderHooks());
77
+ expect(generated).toContain("Overstory agents must use 'ov sling' for delegation");
78
+ });
79
+
80
+ test("interactive tools use human-interaction block reason", () => {
81
+ const generated = generatePiGuardExtension(builderHooks());
82
+ expect(generated).toContain(
83
+ "requires human interaction — use ov mail (--type question) to escalate",
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("Builder — implementation capability", () => {
89
+ test("write tools are NOT in TEAM_BLOCKED or INTERACTIVE_BLOCKED for builder", () => {
90
+ const generated = generatePiGuardExtension(builderHooks());
91
+ const teamSection = extractTeamBlockedSection(generated);
92
+ const interactiveSection = extractInteractiveBlockedSection(generated);
93
+ expect(teamSection).not.toContain('"Write"');
94
+ expect(teamSection).not.toContain('"Edit"');
95
+ expect(teamSection).not.toContain('"NotebookEdit"');
96
+ expect(interactiveSection).not.toContain('"Write"');
97
+ expect(interactiveSection).not.toContain('"Edit"');
98
+ expect(interactiveSection).not.toContain('"NotebookEdit"');
99
+ });
100
+
101
+ test("WRITE_BLOCKED constant is absent for builder", () => {
102
+ const generated = generatePiGuardExtension(builderHooks());
103
+ expect(generated).not.toContain("const WRITE_BLOCKED =");
104
+ });
105
+
106
+ test("has FILE_MODIFYING_PATTERNS section", () => {
107
+ const generated = generatePiGuardExtension(builderHooks());
108
+ expect(generated).toContain("FILE_MODIFYING_PATTERNS.some");
109
+ });
110
+
111
+ test("has SAFE_PREFIXES array", () => {
112
+ const generated = generatePiGuardExtension(builderHooks());
113
+ expect(generated).toContain("const SAFE_PREFIXES =");
114
+ });
115
+
116
+ test("does NOT have DANGEROUS_PATTERNS blocklist guard", () => {
117
+ const generated = generatePiGuardExtension(builderHooks());
118
+ expect(generated).not.toContain("DANGEROUS_PATTERNS.some");
119
+ });
120
+
121
+ test("Bash path boundary check for file-modifying commands", () => {
122
+ const generated = generatePiGuardExtension(builderHooks());
123
+ expect(generated).toContain("FILE_MODIFYING_PATTERNS.some((re) => re.test(cmd))");
124
+ expect(generated).toContain("Bash path boundary violation");
125
+ });
126
+
127
+ test("builder Bash path boundary uses + '/' and exact match", () => {
128
+ const generated = generatePiGuardExtension(builderHooks());
129
+ expect(generated).toContain('!p.startsWith(WORKTREE_PATH + "/")');
130
+ expect(generated).toContain("p !== WORKTREE_PATH");
131
+ });
132
+
133
+ test("builder does NOT use cmd.trimStart() (no safe prefix check)", () => {
134
+ const generated = generatePiGuardExtension(builderHooks());
135
+ expect(generated).not.toContain("cmd.trimStart()");
136
+ });
137
+ });
138
+
139
+ describe("Scout — non-implementation capability", () => {
140
+ test("write tools ARE in WRITE_BLOCKED for scout", () => {
141
+ const generated = generatePiGuardExtension(scoutHooks());
142
+ const writeSection = extractWriteBlockedSection(generated);
143
+ expect(writeSection).toContain('"Write"');
144
+ expect(writeSection).toContain('"Edit"');
145
+ expect(writeSection).toContain('"NotebookEdit"');
146
+ });
147
+
148
+ test("WRITE_BLOCKED uses capability-specific block reason", () => {
149
+ const generated = generatePiGuardExtension(scoutHooks());
150
+ expect(generated).toContain("scout agents cannot modify files");
151
+ });
152
+
153
+ test("has whitelist+blocklist pattern (SAFE_PREFIXES then DANGEROUS_PATTERNS)", () => {
154
+ const generated = generatePiGuardExtension(scoutHooks());
155
+ expect(generated).toContain("SAFE_PREFIXES.some((p) => trimmed.startsWith(p))");
156
+ expect(generated).toContain("DANGEROUS_PATTERNS.some((re) => re.test(cmd))");
157
+ });
158
+
159
+ test("safe prefix check uses cmd.trimStart() for leading whitespace tolerance", () => {
160
+ const generated = generatePiGuardExtension(scoutHooks());
161
+ expect(generated).toContain("const trimmed = cmd.trimStart();");
162
+ expect(generated).toContain("trimmed.startsWith(p)");
163
+ });
164
+
165
+ test("SAFE_PREFIXES check comes before DANGEROUS_PATTERNS check", () => {
166
+ const generated = generatePiGuardExtension(scoutHooks());
167
+ const safeIdx = generated.indexOf("SAFE_PREFIXES.some");
168
+ const dangerIdx = generated.indexOf("DANGEROUS_PATTERNS.some");
169
+ expect(safeIdx).toBeGreaterThan(-1);
170
+ expect(dangerIdx).toBeGreaterThan(-1);
171
+ expect(safeIdx).toBeLessThan(dangerIdx);
172
+ });
173
+
174
+ test("does NOT have FILE_MODIFYING_PATTERNS guard", () => {
175
+ const generated = generatePiGuardExtension(scoutHooks());
176
+ expect(generated).not.toContain("FILE_MODIFYING_PATTERNS.some");
177
+ });
178
+
179
+ test("block reason references capability name", () => {
180
+ const generated = generatePiGuardExtension(scoutHooks());
181
+ expect(generated).toContain("scout agents cannot modify files");
182
+ });
183
+ });
184
+
185
+ describe("Coordinator — coordination capability", () => {
186
+ test("safe prefixes include git add and git commit", () => {
187
+ const generated = generatePiGuardExtension(coordinatorHooks());
188
+ const safePrefixesSection = extractSafePrefixesSection(generated);
189
+ expect(safePrefixesSection).toContain('"git add"');
190
+ expect(safePrefixesSection).toContain('"git commit"');
191
+ });
192
+
193
+ test("write tools are in WRITE_BLOCKED (coordination is non-implementation)", () => {
194
+ const generated = generatePiGuardExtension(coordinatorHooks());
195
+ const writeSection = extractWriteBlockedSection(generated);
196
+ expect(writeSection).toContain('"Write"');
197
+ });
198
+
199
+ test("builder does NOT have git add/commit in safe prefixes", () => {
200
+ const generated = generatePiGuardExtension(builderHooks());
201
+ const safePrefixesSection = extractSafePrefixesSection(generated);
202
+ expect(safePrefixesSection).not.toContain('"git add"');
203
+ expect(safePrefixesSection).not.toContain('"git commit"');
204
+ });
205
+ });
206
+
207
+ describe("path boundary guards (all capabilities)", () => {
208
+ test("WRITE_SCOPE_TOOLS constant is always present", () => {
209
+ for (const hooks of [builderHooks(), scoutHooks(), coordinatorHooks()]) {
210
+ const generated = generatePiGuardExtension(hooks);
211
+ expect(generated).toContain(
212
+ 'const WRITE_SCOPE_TOOLS = new Set<string>(["write", "edit", "Write", "Edit", "NotebookEdit"]);',
213
+ );
214
+ }
215
+ });
216
+
217
+ test("path boundary check uses WORKTREE_PATH + '/' for subpath safety", () => {
218
+ const generated = generatePiGuardExtension(builderHooks());
219
+ expect(generated).toContain('filePath.startsWith(WORKTREE_PATH + "/")');
220
+ });
221
+
222
+ test("path boundary allows exact worktree path match", () => {
223
+ const generated = generatePiGuardExtension(builderHooks());
224
+ expect(generated).toContain("filePath !== WORKTREE_PATH");
225
+ });
226
+
227
+ test("path boundary checks file_path and notebook_path fields", () => {
228
+ const generated = generatePiGuardExtension(builderHooks());
229
+ expect(generated).toContain("file_path");
230
+ expect(generated).toContain("notebook_path");
231
+ });
232
+
233
+ test("path boundary block reason is clear", () => {
234
+ const generated = generatePiGuardExtension(builderHooks());
235
+ expect(generated).toContain(
236
+ "Path boundary violation: file is outside your assigned worktree",
237
+ );
238
+ });
239
+ });
240
+
241
+ describe("universal Bash danger guards (all capabilities)", () => {
242
+ test("blocks git push for builder", () => {
243
+ const generated = generatePiGuardExtension(builderHooks());
244
+ expect(generated).toContain("git push is blocked");
245
+ });
246
+
247
+ test("blocks git push for scout", () => {
248
+ const generated = generatePiGuardExtension(scoutHooks());
249
+ expect(generated).toContain("git push is blocked");
250
+ });
251
+
252
+ test("blocks git reset --hard", () => {
253
+ const generated = generatePiGuardExtension(builderHooks());
254
+ expect(generated).toContain("git reset --hard is not allowed");
255
+ });
256
+
257
+ test("enforces branch naming convention using AGENT_NAME", () => {
258
+ const generated = generatePiGuardExtension(builderHooks("my-agent"));
259
+ // These strings intentionally contain literal ${...} — they appear in the generated code
260
+ // as template literal expressions, not as interpolations in this test file.
261
+ expect(generated).toContain("overstory/$" + "{AGENT_NAME}/");
262
+ expect(generated).toContain(
263
+ "Branch must follow overstory/$" + "{AGENT_NAME}/{task-id} convention",
264
+ );
265
+ });
266
+
267
+ test("bash guard matches both Bash and bash tool names", () => {
268
+ const generated = generatePiGuardExtension(builderHooks());
269
+ expect(generated).toContain('event.toolName === "Bash"');
270
+ expect(generated).toContain('event.toolName === "bash"');
271
+ });
272
+ });
273
+
274
+ describe("quality gate prefixes", () => {
275
+ test("custom quality gate commands appear in SAFE_PREFIXES", () => {
276
+ const hooks: HooksDef = {
277
+ agentName: "test-reviewer",
278
+ capability: "reviewer",
279
+ worktreePath: WORKTREE,
280
+ qualityGates: [
281
+ { name: "Tests", command: "bun test", description: "all tests must pass" },
282
+ { name: "Lint", command: "bun run lint", description: "lint clean" },
283
+ ],
284
+ };
285
+ const generated = generatePiGuardExtension(hooks);
286
+ const safePrefixesSection = extractSafePrefixesSection(generated);
287
+ expect(safePrefixesSection).toContain('"bun test"');
288
+ expect(safePrefixesSection).toContain('"bun run lint"');
289
+ });
290
+
291
+ test("default quality gates provide SAFE_PREFIXES entries", () => {
292
+ // Without custom gates, DEFAULT_QUALITY_GATES are used
293
+ const generated = generatePiGuardExtension(scoutHooks());
294
+ expect(generated).toContain("const SAFE_PREFIXES =");
295
+ // bun test is the default quality gate command
296
+ const safePrefixesSection = extractSafePrefixesSection(generated);
297
+ expect(safePrefixesSection).toContain('"bun test"');
298
+ });
299
+ });
300
+
301
+ describe("generated code is self-contained", () => {
302
+ test("output is non-empty TypeScript string", () => {
303
+ const generated = generatePiGuardExtension(builderHooks());
304
+ expect(typeof generated).toBe("string");
305
+ expect(generated.length).toBeGreaterThan(500);
306
+ });
307
+
308
+ test("output ends with newline", () => {
309
+ const generated = generatePiGuardExtension(builderHooks());
310
+ expect(generated.endsWith("\n")).toBe(true);
311
+ });
312
+
313
+ test("DANGEROUS_PATTERNS constant is always present", () => {
314
+ const generated = generatePiGuardExtension(builderHooks());
315
+ expect(generated).toContain("const DANGEROUS_PATTERNS =");
316
+ });
317
+
318
+ test("FILE_MODIFYING_PATTERNS constant is always present", () => {
319
+ const generated = generatePiGuardExtension(builderHooks());
320
+ expect(generated).toContain("const FILE_MODIFYING_PATTERNS =");
321
+ });
322
+
323
+ test("returns { type: 'allow' } as default", () => {
324
+ const generated = generatePiGuardExtension(builderHooks());
325
+ // Pi's ExtensionAPI uses implicit undefined return for allow (no explicit { type: "allow" } needed).
326
+ // The generated code uses a comment marker "// Default: allow." instead.
327
+ expect(generated).toContain("// Default: allow.");
328
+ });
329
+
330
+ test("uses String() for safe property access on event.input", () => {
331
+ const generated = generatePiGuardExtension(builderHooks());
332
+ expect(generated).toContain("String(");
333
+ expect(generated).toContain("event.input as Record<string, unknown>");
334
+ });
335
+
336
+ test("deterministic output for same inputs", () => {
337
+ const hooks = builderHooks("consistent-builder");
338
+ const g1 = generatePiGuardExtension(hooks);
339
+ const g2 = generatePiGuardExtension(hooks);
340
+ expect(g1).toBe(g2);
341
+ });
342
+ });
343
+
344
+ describe("activity tracking events", () => {
345
+ test('generated code contains pi.on("tool_call", ...)', () => {
346
+ const generated = generatePiGuardExtension(builderHooks());
347
+ expect(generated).toContain('pi.on("tool_call",');
348
+ });
349
+
350
+ test("generated code contains pi.exec ov log tool-start in tool_call handler", () => {
351
+ const generated = generatePiGuardExtension(builderHooks());
352
+ expect(generated).toContain('pi.exec("ov", ["log", "tool-start", "--agent", AGENT_NAME])');
353
+ });
354
+
355
+ test('generated code contains pi.on("tool_execution_end", ...)', () => {
356
+ const generated = generatePiGuardExtension(builderHooks());
357
+ expect(generated).toContain('pi.on("tool_execution_end",');
358
+ });
359
+
360
+ test("generated code contains pi.exec ov log tool-end in tool_execution_end handler", () => {
361
+ const generated = generatePiGuardExtension(builderHooks());
362
+ expect(generated).toContain('pi.exec("ov", ["log", "tool-end", "--agent", AGENT_NAME])');
363
+ });
364
+
365
+ test('generated code contains pi.on("session_shutdown", ...)', () => {
366
+ const generated = generatePiGuardExtension(builderHooks());
367
+ expect(generated).toContain('pi.on("session_shutdown",');
368
+ });
369
+
370
+ test("generated code awaits pi.exec ov log session-end in session_shutdown handler", () => {
371
+ const generated = generatePiGuardExtension(builderHooks());
372
+ expect(generated).toContain(
373
+ 'await pi.exec("ov", ["log", "session-end", "--agent", AGENT_NAME])',
374
+ );
375
+ });
376
+ });
377
+
378
+ describe("PiRuntime integration", () => {
379
+ test("PiRuntime.requiresBeaconVerification() returns false", () => {
380
+ const runtime = new PiRuntime();
381
+ expect(runtime.requiresBeaconVerification()).toBe(false);
382
+ });
383
+ });
384
+ });
385
+
386
+ // --- Helpers ---
387
+
388
+ /**
389
+ * Extract the TEAM_BLOCKED Set literal section from generated code.
390
+ * Returns the text between "TEAM_BLOCKED = new Set" and the first "]);"
391
+ * after that point.
392
+ */
393
+ function extractTeamBlockedSection(generated: string): string {
394
+ const start = generated.indexOf("TEAM_BLOCKED = new Set");
395
+ const end = generated.indexOf("]);", start);
396
+ if (start === -1 || end === -1) return "";
397
+ return generated.slice(start, end + 3);
398
+ }
399
+
400
+ /**
401
+ * Extract the INTERACTIVE_BLOCKED Set literal section from generated code.
402
+ * Returns the text between "INTERACTIVE_BLOCKED = new Set" and the first "]);"
403
+ * after that point.
404
+ */
405
+ function extractInteractiveBlockedSection(generated: string): string {
406
+ const start = generated.indexOf("INTERACTIVE_BLOCKED = new Set");
407
+ const end = generated.indexOf("]);", start);
408
+ if (start === -1 || end === -1) return "";
409
+ return generated.slice(start, end + 3);
410
+ }
411
+
412
+ /**
413
+ * Extract the WRITE_BLOCKED Set literal section from generated code.
414
+ * Returns the text between "WRITE_BLOCKED = new Set" and the first "]);"
415
+ * after that point.
416
+ */
417
+ function extractWriteBlockedSection(generated: string): string {
418
+ const start = generated.indexOf("WRITE_BLOCKED = new Set");
419
+ const end = generated.indexOf("]);", start);
420
+ if (start === -1 || end === -1) return "";
421
+ return generated.slice(start, end + 3);
422
+ }
423
+
424
+ /**
425
+ * Extract the SAFE_PREFIXES array literal section from generated code.
426
+ * Returns the text between "SAFE_PREFIXES =" and the next "];"
427
+ */
428
+ function extractSafePrefixesSection(generated: string): string {
429
+ const start = generated.indexOf("const SAFE_PREFIXES =");
430
+ const end = generated.indexOf("];", start);
431
+ if (start === -1 || end === -1) return "";
432
+ return generated.slice(start, end + 2);
433
+ }