@os-eco/overstory-cli 0.6.1 → 0.6.5
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 +8 -7
- package/package.json +12 -4
- package/src/agents/checkpoint.test.ts +2 -2
- package/src/agents/hooks-deployer.test.ts +131 -16
- package/src/agents/hooks-deployer.ts +33 -1
- package/src/agents/identity.test.ts +27 -27
- package/src/agents/identity.ts +10 -10
- package/src/agents/lifecycle.test.ts +6 -6
- package/src/agents/lifecycle.ts +2 -2
- package/src/agents/manifest.test.ts +86 -0
- package/src/agents/overlay.test.ts +9 -9
- package/src/agents/overlay.ts +4 -4
- package/src/commands/agents.test.ts +8 -8
- package/src/commands/agents.ts +62 -91
- package/src/commands/clean.test.ts +36 -51
- package/src/commands/clean.ts +28 -49
- package/src/commands/completions.ts +14 -0
- package/src/commands/coordinator.test.ts +133 -26
- package/src/commands/coordinator.ts +101 -64
- package/src/commands/costs.test.ts +47 -47
- package/src/commands/costs.ts +96 -75
- package/src/commands/dashboard.test.ts +2 -2
- package/src/commands/dashboard.ts +75 -95
- package/src/commands/doctor.test.ts +2 -2
- package/src/commands/doctor.ts +92 -79
- package/src/commands/errors.test.ts +2 -2
- package/src/commands/errors.ts +56 -50
- package/src/commands/feed.test.ts +2 -2
- package/src/commands/feed.ts +86 -83
- package/src/commands/group.ts +167 -177
- package/src/commands/hooks.test.ts +2 -2
- package/src/commands/hooks.ts +52 -42
- package/src/commands/init.test.ts +19 -19
- package/src/commands/init.ts +7 -16
- package/src/commands/inspect.test.ts +18 -18
- package/src/commands/inspect.ts +55 -58
- package/src/commands/log.test.ts +26 -31
- package/src/commands/log.ts +97 -91
- package/src/commands/logs.test.ts +1 -1
- package/src/commands/logs.ts +101 -104
- package/src/commands/mail.test.ts +5 -5
- package/src/commands/mail.ts +157 -169
- package/src/commands/merge.test.ts +28 -66
- package/src/commands/merge.ts +21 -51
- package/src/commands/metrics.test.ts +8 -8
- package/src/commands/metrics.ts +34 -35
- package/src/commands/monitor.test.ts +3 -3
- package/src/commands/monitor.ts +57 -62
- package/src/commands/nudge.test.ts +1 -1
- package/src/commands/nudge.ts +41 -89
- package/src/commands/prime.test.ts +19 -51
- package/src/commands/prime.ts +13 -50
- package/src/commands/replay.test.ts +2 -2
- package/src/commands/replay.ts +79 -86
- package/src/commands/run.test.ts +1 -1
- package/src/commands/run.ts +97 -77
- package/src/commands/sling.test.ts +201 -5
- package/src/commands/sling.ts +37 -64
- package/src/commands/spec.test.ts +14 -40
- package/src/commands/spec.ts +32 -101
- package/src/commands/status.test.ts +97 -1
- package/src/commands/status.ts +63 -58
- package/src/commands/stop.test.ts +22 -40
- package/src/commands/stop.ts +18 -33
- package/src/commands/supervisor.test.ts +12 -14
- package/src/commands/supervisor.ts +144 -165
- package/src/commands/trace.test.ts +15 -15
- package/src/commands/trace.ts +59 -82
- package/src/commands/watch.test.ts +2 -2
- package/src/commands/watch.ts +38 -45
- package/src/commands/worktree.test.ts +213 -37
- package/src/commands/worktree.ts +110 -55
- package/src/config.test.ts +96 -0
- package/src/doctor/consistency.test.ts +14 -14
- package/src/doctor/databases.test.ts +22 -2
- package/src/doctor/databases.ts +16 -0
- package/src/doctor/dependencies.test.ts +55 -1
- package/src/doctor/dependencies.ts +113 -18
- package/src/doctor/merge-queue.test.ts +4 -4
- package/src/e2e/init-sling-lifecycle.test.ts +8 -8
- package/src/errors.ts +1 -1
- package/src/index.ts +223 -213
- package/src/logging/color.test.ts +74 -91
- package/src/logging/color.ts +52 -46
- package/src/logging/reporter.test.ts +10 -10
- package/src/logging/reporter.ts +6 -5
- package/src/mail/broadcast.test.ts +1 -1
- package/src/mail/client.test.ts +6 -6
- package/src/mail/store.test.ts +3 -3
- package/src/merge/queue.test.ts +73 -7
- package/src/merge/queue.ts +17 -2
- package/src/merge/resolver.test.ts +159 -7
- package/src/merge/resolver.ts +46 -2
- package/src/metrics/store.test.ts +44 -44
- package/src/metrics/store.ts +2 -2
- package/src/metrics/summary.test.ts +35 -35
- package/src/mulch/client.test.ts +1 -1
- package/src/schema-consistency.test.ts +239 -0
- package/src/sessions/compat.test.ts +3 -3
- package/src/sessions/compat.ts +2 -2
- package/src/sessions/store.test.ts +41 -4
- package/src/sessions/store.ts +13 -2
- package/src/types.ts +14 -14
- package/src/watchdog/daemon.test.ts +10 -10
- package/src/watchdog/daemon.ts +1 -1
- package/src/watchdog/health.test.ts +1 -1
- package/src/worktree/manager.test.ts +20 -20
- package/src/worktree/manager.ts +120 -4
- package/src/worktree/tmux.test.ts +98 -9
- package/src/worktree/tmux.ts +18 -0
package/README.md
CHANGED
|
@@ -127,6 +127,7 @@ overstory sling <task-id> Spawn a worker agent
|
|
|
127
127
|
--parent <agent-name> Parent (for hierarchy tracking)
|
|
128
128
|
--depth <n> Current hierarchy depth
|
|
129
129
|
--skip-scout Skip scout phase (passed to lead overlay)
|
|
130
|
+
--skip-task-check Skip task existence validation
|
|
130
131
|
--json JSON output
|
|
131
132
|
|
|
132
133
|
overstory stop <agent-name> Terminate a running agent
|
|
@@ -221,7 +222,7 @@ overstory inspect <agent> Deep per-agent inspection
|
|
|
221
222
|
--no-tmux Skip tmux capture
|
|
222
223
|
--limit <n> Limit events shown
|
|
223
224
|
|
|
224
|
-
overstory spec write <
|
|
225
|
+
overstory spec write <task-id> Write a task specification
|
|
225
226
|
--body <content> Spec content (or pipe via stdin)
|
|
226
227
|
|
|
227
228
|
overstory errors Aggregated error view across agents
|
|
@@ -269,16 +270,16 @@ Global Flags:
|
|
|
269
270
|
## Tech Stack
|
|
270
271
|
|
|
271
272
|
- **Runtime**: Bun (TypeScript directly, no build step)
|
|
272
|
-
- **Dependencies**:
|
|
273
|
+
- **Dependencies**: Minimal runtime — `chalk` (color output), `commander` (CLI framework), core I/O via Bun built-in APIs
|
|
273
274
|
- **Database**: SQLite via `bun:sqlite` (WAL mode for concurrent access)
|
|
274
275
|
- **Linting**: Biome (formatter + linter)
|
|
275
|
-
- **Testing**: `bun test` (
|
|
276
|
+
- **Testing**: `bun test` (2145 tests across 76 files, colocated with source)
|
|
276
277
|
- **External CLIs**: `bd` (beads) or `sd` (seeds), `mulch`, `git`, `tmux` — invoked as subprocesses
|
|
277
278
|
|
|
278
279
|
## Development
|
|
279
280
|
|
|
280
281
|
```bash
|
|
281
|
-
# Run tests (
|
|
282
|
+
# Run tests (2145 tests across 76 files)
|
|
282
283
|
bun test
|
|
283
284
|
|
|
284
285
|
# Run a single test
|
|
@@ -307,14 +308,14 @@ Use the bump script to update both:
|
|
|
307
308
|
bun run version:bump <major|minor|patch>
|
|
308
309
|
```
|
|
309
310
|
|
|
310
|
-
Git tags are
|
|
311
|
+
Git tags, npm publishing, and GitHub releases are handled automatically by the `publish.yml` workflow when a version bump is pushed to `main`.
|
|
311
312
|
|
|
312
313
|
## Project Structure
|
|
313
314
|
|
|
314
315
|
```
|
|
315
316
|
overstory/
|
|
316
317
|
src/
|
|
317
|
-
index.ts CLI entry point (
|
|
318
|
+
index.ts CLI entry point (Commander.js program)
|
|
318
319
|
types.ts Shared types and interfaces
|
|
319
320
|
config.ts Config loader + validation
|
|
320
321
|
errors.ts Custom error types
|
|
@@ -322,7 +323,7 @@ overstory/
|
|
|
322
323
|
agents.ts Agent discovery and querying
|
|
323
324
|
coordinator.ts Persistent orchestrator lifecycle
|
|
324
325
|
supervisor.ts Team lead management
|
|
325
|
-
dashboard.ts Live TUI dashboard (ANSI
|
|
326
|
+
dashboard.ts Live TUI dashboard (ANSI via Chalk)
|
|
326
327
|
hooks.ts Orchestrator hooks management
|
|
327
328
|
sling.ts Agent spawning
|
|
328
329
|
group.ts Task group batch tracking
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@os-eco/overstory-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Multi-agent orchestration for Claude Code — spawn worker agents in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution",
|
|
5
5
|
"author": "Jaymin West",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,10 +21,15 @@
|
|
|
21
21
|
"developer-tools"
|
|
22
22
|
],
|
|
23
23
|
"bin": {
|
|
24
|
-
"overstory": "./src/index.ts"
|
|
24
|
+
"overstory": "./src/index.ts",
|
|
25
|
+
"ov": "./src/index.ts"
|
|
25
26
|
},
|
|
26
27
|
"main": "src/index.ts",
|
|
27
|
-
"files": [
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"agents",
|
|
31
|
+
"templates"
|
|
32
|
+
],
|
|
28
33
|
"publishConfig": {
|
|
29
34
|
"access": "public"
|
|
30
35
|
},
|
|
@@ -38,7 +43,10 @@
|
|
|
38
43
|
"typecheck": "tsc --noEmit",
|
|
39
44
|
"version:bump": "bun scripts/version-bump.ts"
|
|
40
45
|
},
|
|
41
|
-
"dependencies": {
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"chalk": "^5.6.2",
|
|
48
|
+
"commander": "^14.0.3"
|
|
49
|
+
},
|
|
42
50
|
"devDependencies": {
|
|
43
51
|
"@types/bun": "latest",
|
|
44
52
|
"typescript": "^5.9.0",
|
|
@@ -9,7 +9,7 @@ import { clearCheckpoint, loadCheckpoint, saveCheckpoint } from "./checkpoint.ts
|
|
|
9
9
|
function makeCheckpoint(overrides?: Partial<SessionCheckpoint>): SessionCheckpoint {
|
|
10
10
|
return {
|
|
11
11
|
agentName: "test-agent",
|
|
12
|
-
|
|
12
|
+
taskId: "overstory-abc1",
|
|
13
13
|
sessionId: "session-001",
|
|
14
14
|
timestamp: "2025-01-01T00:00:00.000Z",
|
|
15
15
|
progressSummary: "Implemented checkpoint module",
|
|
@@ -40,7 +40,7 @@ describe("checkpoint", () => {
|
|
|
40
40
|
|
|
41
41
|
expect(loaded).not.toBeNull();
|
|
42
42
|
expect(loaded?.agentName).toBe("test-agent");
|
|
43
|
-
expect(loaded?.
|
|
43
|
+
expect(loaded?.taskId).toBe("overstory-abc1");
|
|
44
44
|
expect(loaded?.sessionId).toBe("session-001");
|
|
45
45
|
expect(loaded?.progressSummary).toBe("Implemented checkpoint module");
|
|
46
46
|
expect(loaded?.filesModified).toEqual(["src/agents/checkpoint.ts"]);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
buildBashPathBoundaryScript,
|
|
9
9
|
buildPathBoundaryGuardScript,
|
|
10
10
|
deployHooks,
|
|
11
|
+
escapeForSingleQuotedShell,
|
|
11
12
|
getBashPathBoundaryGuards,
|
|
12
13
|
getCapabilityGuards,
|
|
13
14
|
getDangerGuards,
|
|
@@ -894,35 +895,37 @@ describe("isOverstoryHookEntry", () => {
|
|
|
894
895
|
describe("getCapabilityGuards", () => {
|
|
895
896
|
// 10 native team tool blocks apply to ALL capabilities
|
|
896
897
|
const NATIVE_TEAM_TOOL_COUNT = 10;
|
|
898
|
+
// 3 interactive tool blocks (AskUserQuestion, EnterPlanMode, EnterWorktree) apply to ALL capabilities
|
|
899
|
+
const INTERACTIVE_TOOL_COUNT = 3;
|
|
897
900
|
|
|
898
|
-
test("returns
|
|
901
|
+
test("returns 17 guards for scout (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
|
|
899
902
|
const guards = getCapabilityGuards("scout");
|
|
900
|
-
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + 4);
|
|
903
|
+
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
|
|
901
904
|
});
|
|
902
905
|
|
|
903
|
-
test("returns
|
|
906
|
+
test("returns 17 guards for reviewer (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
|
|
904
907
|
const guards = getCapabilityGuards("reviewer");
|
|
905
|
-
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + 4);
|
|
908
|
+
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
|
|
906
909
|
});
|
|
907
910
|
|
|
908
|
-
test("returns
|
|
911
|
+
test("returns 17 guards for lead (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
|
|
909
912
|
const guards = getCapabilityGuards("lead");
|
|
910
|
-
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + 4);
|
|
913
|
+
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
|
|
911
914
|
});
|
|
912
915
|
|
|
913
|
-
test("returns
|
|
916
|
+
test("returns 14 guards for builder (10 team + 3 interactive + 1 bash path boundary)", () => {
|
|
914
917
|
const guards = getCapabilityGuards("builder");
|
|
915
|
-
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + 1);
|
|
918
|
+
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 1);
|
|
916
919
|
});
|
|
917
920
|
|
|
918
|
-
test("returns
|
|
921
|
+
test("returns 14 guards for merger (10 team + 3 interactive + 1 bash path boundary)", () => {
|
|
919
922
|
const guards = getCapabilityGuards("merger");
|
|
920
|
-
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + 1);
|
|
923
|
+
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 1);
|
|
921
924
|
});
|
|
922
925
|
|
|
923
|
-
test("returns
|
|
926
|
+
test("returns 13 guards for unknown capability (10 team + 3 interactive tool blocks)", () => {
|
|
924
927
|
const guards = getCapabilityGuards("unknown");
|
|
925
|
-
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT);
|
|
928
|
+
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT);
|
|
926
929
|
});
|
|
927
930
|
|
|
928
931
|
test("builder gets Bash path boundary guard", () => {
|
|
@@ -1039,14 +1042,90 @@ describe("getCapabilityGuards", () => {
|
|
|
1039
1042
|
expect(taskGuard?.hooks[0]?.command).toContain('[ -z "$OVERSTORY_AGENT_NAME" ] && exit 0;');
|
|
1040
1043
|
});
|
|
1041
1044
|
|
|
1042
|
-
test("coordinator gets
|
|
1045
|
+
test("coordinator gets 17 guards (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
|
|
1043
1046
|
const guards = getCapabilityGuards("coordinator");
|
|
1044
|
-
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + 4);
|
|
1047
|
+
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
|
|
1045
1048
|
});
|
|
1046
1049
|
|
|
1047
|
-
test("supervisor gets
|
|
1050
|
+
test("supervisor gets 17 guards (10 team + 3 interactive + 3 tool blocks + 1 bash file guard)", () => {
|
|
1048
1051
|
const guards = getCapabilityGuards("supervisor");
|
|
1049
|
-
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + 4);
|
|
1052
|
+
expect(guards.length).toBe(NATIVE_TEAM_TOOL_COUNT + INTERACTIVE_TOOL_COUNT + 4);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test("all capabilities get AskUserQuestion blocked", () => {
|
|
1056
|
+
for (const cap of [
|
|
1057
|
+
"scout",
|
|
1058
|
+
"reviewer",
|
|
1059
|
+
"lead",
|
|
1060
|
+
"coordinator",
|
|
1061
|
+
"supervisor",
|
|
1062
|
+
"builder",
|
|
1063
|
+
"merger",
|
|
1064
|
+
"unknown",
|
|
1065
|
+
]) {
|
|
1066
|
+
const guards = getCapabilityGuards(cap);
|
|
1067
|
+
const guard = guards.find((g) => g.matcher === "AskUserQuestion");
|
|
1068
|
+
expect(guard).toBeDefined();
|
|
1069
|
+
expect(guard?.hooks[0]?.command).toContain("human interaction");
|
|
1070
|
+
expect(guard?.hooks[0]?.command).toContain("overstory mail");
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test("all capabilities get EnterPlanMode blocked", () => {
|
|
1075
|
+
for (const cap of [
|
|
1076
|
+
"scout",
|
|
1077
|
+
"reviewer",
|
|
1078
|
+
"lead",
|
|
1079
|
+
"coordinator",
|
|
1080
|
+
"supervisor",
|
|
1081
|
+
"builder",
|
|
1082
|
+
"merger",
|
|
1083
|
+
"unknown",
|
|
1084
|
+
]) {
|
|
1085
|
+
const guards = getCapabilityGuards(cap);
|
|
1086
|
+
const guard = guards.find((g) => g.matcher === "EnterPlanMode");
|
|
1087
|
+
expect(guard).toBeDefined();
|
|
1088
|
+
expect(guard?.hooks[0]?.command).toContain("human interaction");
|
|
1089
|
+
expect(guard?.hooks[0]?.command).toContain("overstory mail");
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test("all capabilities get EnterWorktree blocked", () => {
|
|
1094
|
+
for (const cap of [
|
|
1095
|
+
"scout",
|
|
1096
|
+
"reviewer",
|
|
1097
|
+
"lead",
|
|
1098
|
+
"coordinator",
|
|
1099
|
+
"supervisor",
|
|
1100
|
+
"builder",
|
|
1101
|
+
"merger",
|
|
1102
|
+
"unknown",
|
|
1103
|
+
]) {
|
|
1104
|
+
const guards = getCapabilityGuards(cap);
|
|
1105
|
+
const guard = guards.find((g) => g.matcher === "EnterWorktree");
|
|
1106
|
+
expect(guard).toBeDefined();
|
|
1107
|
+
expect(guard?.hooks[0]?.command).toContain("human interaction");
|
|
1108
|
+
expect(guard?.hooks[0]?.command).toContain("overstory mail");
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("interactive guards include env var guard prefix", () => {
|
|
1113
|
+
const guards = getCapabilityGuards("builder");
|
|
1114
|
+
for (const tool of ["AskUserQuestion", "EnterPlanMode", "EnterWorktree"]) {
|
|
1115
|
+
const guard = guards.find((g) => g.matcher === tool);
|
|
1116
|
+
expect(guard).toBeDefined();
|
|
1117
|
+
expect(guard?.hooks[0]?.command).toContain('[ -z "$OVERSTORY_AGENT_NAME" ] && exit 0;');
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test("interactive guard block reason mentions tool name", () => {
|
|
1122
|
+
const guards = getCapabilityGuards("scout");
|
|
1123
|
+
const askGuard = guards.find((g) => g.matcher === "AskUserQuestion");
|
|
1124
|
+
expect(askGuard?.hooks[0]?.command).toContain("AskUserQuestion");
|
|
1125
|
+
const planGuard = guards.find((g) => g.matcher === "EnterPlanMode");
|
|
1126
|
+
expect(planGuard?.hooks[0]?.command).toContain("EnterPlanMode");
|
|
1127
|
+
const worktreeGuard = guards.find((g) => g.matcher === "EnterWorktree");
|
|
1128
|
+
expect(worktreeGuard?.hooks[0]?.command).toContain("EnterWorktree");
|
|
1050
1129
|
});
|
|
1051
1130
|
});
|
|
1052
1131
|
|
|
@@ -2038,3 +2117,39 @@ describe("bash path boundary integration", () => {
|
|
|
2038
2117
|
expect(universalGuard.hooks[0].command).toContain('"decision":"block"');
|
|
2039
2118
|
});
|
|
2040
2119
|
});
|
|
2120
|
+
|
|
2121
|
+
describe("escapeForSingleQuotedShell", () => {
|
|
2122
|
+
test("no single quotes: string passes through unchanged", () => {
|
|
2123
|
+
expect(escapeForSingleQuotedShell("hello world")).toBe("hello world");
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
test("single quotes escaped: it's becomes it'\\''s", () => {
|
|
2127
|
+
expect(escapeForSingleQuotedShell("it's")).toBe("it'\\''s");
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
test("multiple single quotes: each one is escaped independently", () => {
|
|
2131
|
+
expect(escapeForSingleQuotedShell("can't won't")).toBe("can'\\''t won'\\''t");
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
test("empty string: returns empty string", () => {
|
|
2135
|
+
expect(escapeForSingleQuotedShell("")).toBe("");
|
|
2136
|
+
});
|
|
2137
|
+
|
|
2138
|
+
test("blockGuard shell command outputs valid JSON when executed", async () => {
|
|
2139
|
+
const guards = getCapabilityGuards("builder");
|
|
2140
|
+
const taskGuard = guards.find((g) => g.matcher === "Task");
|
|
2141
|
+
expect(taskGuard).toBeDefined();
|
|
2142
|
+
const cmd = taskGuard?.hooks[0]?.command ?? "";
|
|
2143
|
+
const echoCmd = cmd.replace('[ -z "$OVERSTORY_AGENT_NAME" ] && exit 0; ', "");
|
|
2144
|
+
const proc = Bun.spawn(["sh", "-c", echoCmd], {
|
|
2145
|
+
stdout: "pipe",
|
|
2146
|
+
stderr: "pipe",
|
|
2147
|
+
env: { ...process.env, OVERSTORY_AGENT_NAME: "test-agent" },
|
|
2148
|
+
});
|
|
2149
|
+
const output = await new Response(proc.stdout).text();
|
|
2150
|
+
await proc.exited;
|
|
2151
|
+
const parsed = JSON.parse(output.trim());
|
|
2152
|
+
expect(parsed.decision).toBe("block");
|
|
2153
|
+
expect(parsed.reason).toContain("overstory sling");
|
|
2154
|
+
});
|
|
2155
|
+
});
|
|
@@ -46,6 +46,13 @@ const NATIVE_TEAM_TOOLS = [
|
|
|
46
46
|
"TaskStop",
|
|
47
47
|
];
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Tools that require human interaction and block indefinitely in non-interactive
|
|
51
|
+
* tmux sessions. Agents run non-interactively and must never call these tools.
|
|
52
|
+
* Use overstory mail (--type question) to escalate to the orchestrator instead.
|
|
53
|
+
*/
|
|
54
|
+
const INTERACTIVE_TOOLS = ["AskUserQuestion", "EnterPlanMode", "EnterWorktree"];
|
|
55
|
+
|
|
49
56
|
/** Tools that non-implementation agents must not use. */
|
|
50
57
|
const WRITE_TOOLS = ["Write", "Edit", "NotebookEdit"];
|
|
51
58
|
|
|
@@ -198,6 +205,20 @@ export function getPathBoundaryGuards(): HookEntry[] {
|
|
|
198
205
|
];
|
|
199
206
|
}
|
|
200
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Escape a string for use inside a single-quoted POSIX shell string.
|
|
210
|
+
*
|
|
211
|
+
* POSIX single-quoted strings cannot contain single quotes at all.
|
|
212
|
+
* The standard technique is to end the single-quoted segment, emit an escaped
|
|
213
|
+
* single quote using $'\'', then start a new single-quoted segment:
|
|
214
|
+
* 'it'\''s fine' → it's fine
|
|
215
|
+
*
|
|
216
|
+
* Exported so tests can verify escaping directly.
|
|
217
|
+
*/
|
|
218
|
+
export function escapeForSingleQuotedShell(str: string): string {
|
|
219
|
+
return str.replace(/'/g, "'\\''");
|
|
220
|
+
}
|
|
221
|
+
|
|
201
222
|
/**
|
|
202
223
|
* Build a PreToolUse guard that blocks a specific tool.
|
|
203
224
|
*
|
|
@@ -211,7 +232,7 @@ function blockGuard(toolName: string, reason: string): HookEntry {
|
|
|
211
232
|
hooks: [
|
|
212
233
|
{
|
|
213
234
|
type: "command",
|
|
214
|
-
command: `${ENV_GUARD} echo '${response}'`,
|
|
235
|
+
command: `${ENV_GUARD} echo '${escapeForSingleQuotedShell(response)}'`,
|
|
215
236
|
},
|
|
216
237
|
],
|
|
217
238
|
};
|
|
@@ -445,6 +466,17 @@ export function getCapabilityGuards(capability: string): HookEntry[] {
|
|
|
445
466
|
);
|
|
446
467
|
guards.push(...teamToolGuards);
|
|
447
468
|
|
|
469
|
+
// Block interactive tools for ALL overstory agents.
|
|
470
|
+
// These tools require a human to respond and block indefinitely in tmux sessions.
|
|
471
|
+
// Agents must use overstory mail (--type question) to escalate instead.
|
|
472
|
+
const interactiveGuards = INTERACTIVE_TOOLS.map((tool) =>
|
|
473
|
+
blockGuard(
|
|
474
|
+
tool,
|
|
475
|
+
`${tool} requires human interaction -- agents run non-interactively. Use overstory mail (--type question) to escalate`,
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
guards.push(...interactiveGuards);
|
|
479
|
+
|
|
448
480
|
if (NON_IMPLEMENTATION_CAPABILITIES.has(capability)) {
|
|
449
481
|
const toolGuards = WRITE_TOOLS.map((tool) =>
|
|
450
482
|
blockGuard(tool, `${capability} agents cannot modify files — ${tool} is not allowed`),
|
|
@@ -72,12 +72,12 @@ describe("identity", () => {
|
|
|
72
72
|
expertiseDomains: [],
|
|
73
73
|
recentTasks: [
|
|
74
74
|
{
|
|
75
|
-
|
|
75
|
+
taskId: "beads-001",
|
|
76
76
|
summary: "Fixed authentication bug",
|
|
77
77
|
completedAt: "2024-01-15T12:00:00Z",
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
|
-
|
|
80
|
+
taskId: "beads-002",
|
|
81
81
|
summary: "Added user profile page",
|
|
82
82
|
completedAt: "2024-01-16T14:30:00Z",
|
|
83
83
|
},
|
|
@@ -89,10 +89,10 @@ describe("identity", () => {
|
|
|
89
89
|
const filePath = join(tempDir, "test-agent", "identity.yaml");
|
|
90
90
|
const content = await Bun.file(filePath).text();
|
|
91
91
|
expect(content).toContain("recentTasks:");
|
|
92
|
-
expect(content).toContain("\t-
|
|
92
|
+
expect(content).toContain("\t- taskId: beads-001");
|
|
93
93
|
expect(content).toContain("\t\tsummary: Fixed authentication bug");
|
|
94
94
|
expect(content).toContain('\t\tcompletedAt: "2024-01-15T12:00:00Z"');
|
|
95
|
-
expect(content).toContain("\t-
|
|
95
|
+
expect(content).toContain("\t- taskId: beads-002");
|
|
96
96
|
expect(content).toContain("\t\tsummary: Added user profile page");
|
|
97
97
|
expect(content).toContain('\t\tcompletedAt: "2024-01-16T14:30:00Z"');
|
|
98
98
|
});
|
|
@@ -106,7 +106,7 @@ describe("identity", () => {
|
|
|
106
106
|
expertiseDomains: ["domain: with colon", "domain#with hash", " leading space"],
|
|
107
107
|
recentTasks: [
|
|
108
108
|
{
|
|
109
|
-
|
|
109
|
+
taskId: "beads-001",
|
|
110
110
|
summary: 'Fixed bug: "memory leak"',
|
|
111
111
|
completedAt: "2024-01-15T12:00:00Z",
|
|
112
112
|
},
|
|
@@ -198,7 +198,7 @@ describe("identity", () => {
|
|
|
198
198
|
expertiseDomains: ["typescript", "testing"],
|
|
199
199
|
recentTasks: [
|
|
200
200
|
{
|
|
201
|
-
|
|
201
|
+
taskId: "beads-001",
|
|
202
202
|
summary: "Fixed bug",
|
|
203
203
|
completedAt: "2024-01-15T12:00:00Z",
|
|
204
204
|
},
|
|
@@ -216,7 +216,7 @@ describe("identity", () => {
|
|
|
216
216
|
expect(loaded?.sessionsCompleted).toBe(7);
|
|
217
217
|
expect(loaded?.expertiseDomains).toEqual(["typescript", "testing"]);
|
|
218
218
|
expect(loaded?.recentTasks).toHaveLength(1);
|
|
219
|
-
expect(loaded?.recentTasks[0]?.
|
|
219
|
+
expect(loaded?.recentTasks[0]?.taskId).toBe("beads-001");
|
|
220
220
|
expect(loaded?.recentTasks[0]?.summary).toBe("Fixed bug");
|
|
221
221
|
expect(loaded?.recentTasks[0]?.completedAt).toBe("2024-01-15T12:00:00Z");
|
|
222
222
|
});
|
|
@@ -252,17 +252,17 @@ describe("identity", () => {
|
|
|
252
252
|
expertiseDomains: [],
|
|
253
253
|
recentTasks: [
|
|
254
254
|
{
|
|
255
|
-
|
|
255
|
+
taskId: "beads-001",
|
|
256
256
|
summary: "Task 1",
|
|
257
257
|
completedAt: "2024-01-15T12:00:00Z",
|
|
258
258
|
},
|
|
259
259
|
{
|
|
260
|
-
|
|
260
|
+
taskId: "beads-002",
|
|
261
261
|
summary: "Task 2",
|
|
262
262
|
completedAt: "2024-01-16T12:00:00Z",
|
|
263
263
|
},
|
|
264
264
|
{
|
|
265
|
-
|
|
265
|
+
taskId: "beads-003",
|
|
266
266
|
summary: "Task 3",
|
|
267
267
|
completedAt: "2024-01-17T12:00:00Z",
|
|
268
268
|
},
|
|
@@ -273,9 +273,9 @@ describe("identity", () => {
|
|
|
273
273
|
const loaded = await loadIdentity(tempDir, "test-agent");
|
|
274
274
|
|
|
275
275
|
expect(loaded?.recentTasks).toHaveLength(3);
|
|
276
|
-
expect(loaded?.recentTasks[0]?.
|
|
277
|
-
expect(loaded?.recentTasks[1]?.
|
|
278
|
-
expect(loaded?.recentTasks[2]?.
|
|
276
|
+
expect(loaded?.recentTasks[0]?.taskId).toBe("beads-001");
|
|
277
|
+
expect(loaded?.recentTasks[1]?.taskId).toBe("beads-002");
|
|
278
|
+
expect(loaded?.recentTasks[2]?.taskId).toBe("beads-003");
|
|
279
279
|
});
|
|
280
280
|
|
|
281
281
|
test("handles quoted strings with special characters", async () => {
|
|
@@ -287,7 +287,7 @@ describe("identity", () => {
|
|
|
287
287
|
expertiseDomains: ["domain: with colon", "domain#with hash"],
|
|
288
288
|
recentTasks: [
|
|
289
289
|
{
|
|
290
|
-
|
|
290
|
+
taskId: "beads-001",
|
|
291
291
|
summary: 'Fixed bug: "memory leak"',
|
|
292
292
|
completedAt: "2024-01-15T12:00:00Z",
|
|
293
293
|
},
|
|
@@ -311,7 +311,7 @@ describe("identity", () => {
|
|
|
311
311
|
expertiseDomains: [],
|
|
312
312
|
recentTasks: [
|
|
313
313
|
{
|
|
314
|
-
|
|
314
|
+
taskId: "beads-001",
|
|
315
315
|
summary: "Path: C:\\Users\\test\\file.txt",
|
|
316
316
|
completedAt: "2024-01-15T12:00:00Z",
|
|
317
317
|
},
|
|
@@ -435,14 +435,14 @@ recentTasks: []
|
|
|
435
435
|
const beforeUpdate = Date.now();
|
|
436
436
|
const updated = await updateIdentity(tempDir, "test-agent", {
|
|
437
437
|
completedTask: {
|
|
438
|
-
|
|
438
|
+
taskId: "beads-001",
|
|
439
439
|
summary: "Fixed authentication bug",
|
|
440
440
|
},
|
|
441
441
|
});
|
|
442
442
|
const afterUpdate = Date.now();
|
|
443
443
|
|
|
444
444
|
expect(updated.recentTasks).toHaveLength(1);
|
|
445
|
-
expect(updated.recentTasks[0]?.
|
|
445
|
+
expect(updated.recentTasks[0]?.taskId).toBe("beads-001");
|
|
446
446
|
expect(updated.recentTasks[0]?.summary).toBe("Fixed authentication bug");
|
|
447
447
|
|
|
448
448
|
// Verify timestamp is within the update window
|
|
@@ -454,7 +454,7 @@ recentTasks: []
|
|
|
454
454
|
test("caps recentTasks at 20 entries, dropping oldest", async () => {
|
|
455
455
|
// Create identity with 19 tasks
|
|
456
456
|
const existingTasks = Array.from({ length: 19 }, (_, i) => ({
|
|
457
|
-
|
|
457
|
+
taskId: `beads-${i.toString().padStart(3, "0")}`,
|
|
458
458
|
summary: `Task ${i}`,
|
|
459
459
|
completedAt: `2024-01-${(i + 1).toString().padStart(2, "0")}T12:00:00Z`,
|
|
460
460
|
}));
|
|
@@ -472,20 +472,20 @@ recentTasks: []
|
|
|
472
472
|
|
|
473
473
|
// Add two more tasks (total would be 21)
|
|
474
474
|
let updated = await updateIdentity(tempDir, "test-agent", {
|
|
475
|
-
completedTask: {
|
|
475
|
+
completedTask: { taskId: "beads-019", summary: "Task 19" },
|
|
476
476
|
});
|
|
477
477
|
|
|
478
478
|
expect(updated.recentTasks).toHaveLength(20);
|
|
479
|
-
expect(updated.recentTasks[0]?.
|
|
479
|
+
expect(updated.recentTasks[0]?.taskId).toBe("beads-000");
|
|
480
480
|
|
|
481
481
|
updated = await updateIdentity(tempDir, "test-agent", {
|
|
482
|
-
completedTask: {
|
|
482
|
+
completedTask: { taskId: "beads-020", summary: "Task 20" },
|
|
483
483
|
});
|
|
484
484
|
|
|
485
485
|
expect(updated.recentTasks).toHaveLength(20);
|
|
486
486
|
// Oldest task (beads-000) should be dropped
|
|
487
|
-
expect(updated.recentTasks[0]?.
|
|
488
|
-
expect(updated.recentTasks[19]?.
|
|
487
|
+
expect(updated.recentTasks[0]?.taskId).toBe("beads-001");
|
|
488
|
+
expect(updated.recentTasks[19]?.taskId).toBe("beads-020");
|
|
489
489
|
});
|
|
490
490
|
|
|
491
491
|
test("applies multiple updates simultaneously", async () => {
|
|
@@ -503,7 +503,7 @@ recentTasks: []
|
|
|
503
503
|
sessionsCompleted: 2,
|
|
504
504
|
expertiseDomains: ["testing", "architecture"],
|
|
505
505
|
completedTask: {
|
|
506
|
-
|
|
506
|
+
taskId: "beads-001",
|
|
507
507
|
summary: "Completed task",
|
|
508
508
|
},
|
|
509
509
|
});
|
|
@@ -554,12 +554,12 @@ recentTasks: []
|
|
|
554
554
|
expertiseDomains: ["typescript", "testing", "architecture"],
|
|
555
555
|
recentTasks: [
|
|
556
556
|
{
|
|
557
|
-
|
|
557
|
+
taskId: "beads-001",
|
|
558
558
|
summary: "Implemented feature X",
|
|
559
559
|
completedAt: "2024-01-15T12:00:00Z",
|
|
560
560
|
},
|
|
561
561
|
{
|
|
562
|
-
|
|
562
|
+
taskId: "beads-002",
|
|
563
563
|
summary: "Fixed bug in module Y",
|
|
564
564
|
completedAt: "2024-01-16T14:30:00Z",
|
|
565
565
|
},
|
|
@@ -587,7 +587,7 @@ recentTasks: []
|
|
|
587
587
|
],
|
|
588
588
|
recentTasks: [
|
|
589
589
|
{
|
|
590
|
-
|
|
590
|
+
taskId: "beads-001",
|
|
591
591
|
summary: 'Summary with "quotes" and: colons',
|
|
592
592
|
completedAt: "2024-01-15T12:00:00Z",
|
|
593
593
|
},
|
package/src/agents/identity.ts
CHANGED
|
@@ -39,7 +39,7 @@ function serializeIdentityYaml(identity: AgentIdentity): string {
|
|
|
39
39
|
} else {
|
|
40
40
|
lines.push("recentTasks:");
|
|
41
41
|
for (const task of identity.recentTasks) {
|
|
42
|
-
lines.push(`\t-
|
|
42
|
+
lines.push(`\t- taskId: ${quoteIfNeeded(task.taskId)}`);
|
|
43
43
|
lines.push(`\t\tsummary: ${quoteIfNeeded(task.summary)}`);
|
|
44
44
|
lines.push(`\t\tcompletedAt: ${quoteIfNeeded(task.completedAt)}`);
|
|
45
45
|
}
|
|
@@ -82,7 +82,7 @@ function quoteIfNeeded(value: string): string {
|
|
|
82
82
|
* This is a purpose-built parser for the identity YAML format. It handles:
|
|
83
83
|
* - Simple key: value pairs (strings, numbers)
|
|
84
84
|
* - Arrays of scalars (expertiseDomains)
|
|
85
|
-
* - Arrays of objects (recentTasks with
|
|
85
|
+
* - Arrays of objects (recentTasks with taskId, summary, completedAt)
|
|
86
86
|
* - Empty arrays (`[]`)
|
|
87
87
|
* - Quoted strings
|
|
88
88
|
* - Tab indentation
|
|
@@ -95,10 +95,10 @@ function parseIdentityYaml(text: string): AgentIdentity {
|
|
|
95
95
|
let created = "";
|
|
96
96
|
let sessionsCompleted = 0;
|
|
97
97
|
const expertiseDomains: string[] = [];
|
|
98
|
-
const recentTasks: Array<{
|
|
98
|
+
const recentTasks: Array<{ taskId: string; summary: string; completedAt: string }> = [];
|
|
99
99
|
|
|
100
100
|
let currentSection: "none" | "expertiseDomains" | "recentTasks" = "none";
|
|
101
|
-
let currentTask: {
|
|
101
|
+
let currentTask: { taskId: string; summary: string; completedAt: string } | null = null;
|
|
102
102
|
|
|
103
103
|
for (const rawLine of lines) {
|
|
104
104
|
const trimmed = rawLine.trim();
|
|
@@ -169,7 +169,7 @@ function parseIdentityYaml(text: string): AgentIdentity {
|
|
|
169
169
|
if (currentTask !== null) {
|
|
170
170
|
recentTasks.push(currentTask);
|
|
171
171
|
}
|
|
172
|
-
currentTask = {
|
|
172
|
+
currentTask = { taskId: "", summary: "", completedAt: "" };
|
|
173
173
|
|
|
174
174
|
// Parse the key-value on the same line as the dash
|
|
175
175
|
const itemContent = trimmed.slice(2).trim();
|
|
@@ -210,13 +210,13 @@ function parseIdentityYaml(text: string): AgentIdentity {
|
|
|
210
210
|
* Assign a parsed field value to a task object by key name.
|
|
211
211
|
*/
|
|
212
212
|
function assignTaskField(
|
|
213
|
-
task: {
|
|
213
|
+
task: { taskId: string; summary: string; completedAt: string },
|
|
214
214
|
key: string,
|
|
215
215
|
value: string,
|
|
216
216
|
): void {
|
|
217
217
|
switch (key) {
|
|
218
|
-
case "
|
|
219
|
-
task.
|
|
218
|
+
case "taskId":
|
|
219
|
+
task.taskId = value;
|
|
220
220
|
break;
|
|
221
221
|
case "summary":
|
|
222
222
|
task.summary = value;
|
|
@@ -336,7 +336,7 @@ export async function updateIdentity(
|
|
|
336
336
|
baseDir: string,
|
|
337
337
|
name: string,
|
|
338
338
|
update: Partial<Pick<AgentIdentity, "sessionsCompleted" | "expertiseDomains">> & {
|
|
339
|
-
completedTask?: {
|
|
339
|
+
completedTask?: { taskId: string; summary: string };
|
|
340
340
|
},
|
|
341
341
|
): Promise<AgentIdentity> {
|
|
342
342
|
const identity = await loadIdentity(baseDir, name);
|
|
@@ -364,7 +364,7 @@ export async function updateIdentity(
|
|
|
364
364
|
// Append completed task
|
|
365
365
|
if (update.completedTask !== undefined) {
|
|
366
366
|
identity.recentTasks.push({
|
|
367
|
-
|
|
367
|
+
taskId: update.completedTask.taskId,
|
|
368
368
|
summary: update.completedTask.summary,
|
|
369
369
|
completedAt: new Date().toISOString(),
|
|
370
370
|
});
|