@os-eco/overstory-cli 0.10.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -2
  2. package/agents/builder.md +10 -1
  3. package/agents/lead.md +106 -5
  4. package/package.json +1 -1
  5. package/src/agents/headless-mail-injector.ts +8 -0
  6. package/src/agents/mail-poll-detect.test.ts +153 -0
  7. package/src/agents/mail-poll-detect.ts +73 -0
  8. package/src/agents/overlay.test.ts +56 -0
  9. package/src/agents/overlay.ts +33 -0
  10. package/src/agents/scope-detect.test.ts +190 -0
  11. package/src/agents/scope-detect.ts +146 -0
  12. package/src/agents/turn-runner.test.ts +862 -0
  13. package/src/agents/turn-runner.ts +225 -8
  14. package/src/commands/agents.ts +9 -0
  15. package/src/commands/coordinator.test.ts +127 -0
  16. package/src/commands/coordinator.ts +71 -4
  17. package/src/commands/dashboard.ts +1 -1
  18. package/src/commands/log.test.ts +131 -0
  19. package/src/commands/log.ts +37 -2
  20. package/src/commands/merge.test.ts +118 -0
  21. package/src/commands/merge.ts +51 -8
  22. package/src/commands/sling.test.ts +104 -0
  23. package/src/commands/sling.ts +95 -8
  24. package/src/commands/stop.test.ts +81 -0
  25. package/src/index.ts +5 -1
  26. package/src/insights/quality-gates.test.ts +141 -0
  27. package/src/insights/quality-gates.ts +156 -0
  28. package/src/logging/theme.ts +4 -0
  29. package/src/merge/predict.test.ts +387 -0
  30. package/src/merge/predict.ts +249 -0
  31. package/src/merge/resolver.ts +1 -1
  32. package/src/mulch/client.ts +3 -3
  33. package/src/sessions/store.test.ts +267 -5
  34. package/src/sessions/store.ts +105 -7
  35. package/src/types.ts +51 -1
  36. package/src/watchdog/daemon.test.ts +124 -2
  37. package/src/watchdog/daemon.ts +27 -12
  38. package/src/watchdog/health.test.ts +133 -8
  39. package/src/watchdog/health.ts +37 -5
  40. package/src/worktree/manager.test.ts +218 -1
  41. package/src/worktree/manager.ts +55 -0
  42. package/src/worktree/tmux.test.ts +25 -0
  43. package/src/worktree/tmux.ts +17 -0
  44. package/templates/overlay.md.tmpl +2 -0
@@ -0,0 +1,190 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ detectScopeViolation,
4
+ findScopeViolations,
5
+ hasExpansionReason,
6
+ IMPLEMENTATION_CAPABILITIES,
7
+ parseExpansionReasonsFromGitLog,
8
+ } from "./scope-detect.ts";
9
+
10
+ describe("findScopeViolations", () => {
11
+ test("literal match: in-scope file is not a violation", () => {
12
+ const result = findScopeViolations(["src/foo.ts"], ["src/foo.ts"]);
13
+ expect(result).toEqual([]);
14
+ });
15
+
16
+ test("glob match: src/foo/**/*.ts allows nested file", () => {
17
+ const result = findScopeViolations(
18
+ ["src/foo/bar/baz.ts", "src/foo/qux.ts"],
19
+ ["src/foo/**/*.ts"],
20
+ );
21
+ expect(result).toEqual([]);
22
+ });
23
+
24
+ test("out-of-scope file is reported", () => {
25
+ const result = findScopeViolations(["src/other.ts"], ["src/foo.ts"]);
26
+ expect(result).toEqual(["src/other.ts"]);
27
+ });
28
+
29
+ test("empty fileScope is treated as unrestricted", () => {
30
+ const result = findScopeViolations(["any/file.ts", "another.ts"], []);
31
+ expect(result).toEqual([]);
32
+ });
33
+
34
+ test("partial violations: returns only the out-of-scope subset", () => {
35
+ const result = findScopeViolations(
36
+ ["src/foo.ts", "src/other.ts", "src/bar.ts"],
37
+ ["src/foo.ts", "src/bar.ts"],
38
+ );
39
+ expect(result).toEqual(["src/other.ts"]);
40
+ });
41
+
42
+ test("glob match: literal path equals scope entry without a glob char", () => {
43
+ const result = findScopeViolations(["a.ts"], ["a.ts"]);
44
+ expect(result).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe("hasExpansionReason", () => {
49
+ test("expansion_reason: with value → true", () => {
50
+ expect(hasExpansionReason("expansion_reason: foo")).toBe(true);
51
+ });
52
+
53
+ test("Expansion_Reason: case-insensitive → true", () => {
54
+ expect(hasExpansionReason("Expansion_Reason: bar")).toBe(true);
55
+ });
56
+
57
+ test("EXPANSION_REASON: multi-word value → true", () => {
58
+ expect(hasExpansionReason("EXPANSION_REASON: baz quux")).toBe(true);
59
+ });
60
+
61
+ test("expansion_reason: empty value → false", () => {
62
+ expect(hasExpansionReason("expansion_reason:")).toBe(false);
63
+ });
64
+
65
+ test("expansion_reason: only whitespace → false", () => {
66
+ expect(hasExpansionReason("expansion_reason: ")).toBe(false);
67
+ });
68
+
69
+ test("expansion-reason: hyphen separator → false", () => {
70
+ expect(hasExpansionReason("expansion-reason: foo")).toBe(false);
71
+ });
72
+
73
+ test("no marker → false", () => {
74
+ expect(hasExpansionReason("no reason here")).toBe(false);
75
+ });
76
+
77
+ test("marker embedded in commit body with prefix → true", () => {
78
+ const body = "Refactor the thing\n\nexpansion_reason: had to update shared types\n";
79
+ expect(hasExpansionReason(body)).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe("parseExpansionReasonsFromGitLog", () => {
84
+ test("returns each value across multiple commit bodies", () => {
85
+ const log = [
86
+ "feat: a thing",
87
+ "",
88
+ "expansion_reason: needed shared type",
89
+ "",
90
+ "fix: another thing",
91
+ "",
92
+ "expansion_reason: had to update barrel export",
93
+ ].join("\n");
94
+ const result = parseExpansionReasonsFromGitLog(log);
95
+ expect(result).toEqual(["needed shared type", "had to update barrel export"]);
96
+ });
97
+
98
+ test("trims whitespace around values", () => {
99
+ const log = "expansion_reason: surrounded by spaces \n";
100
+ const result = parseExpansionReasonsFromGitLog(log);
101
+ expect(result).toEqual(["surrounded by spaces"]);
102
+ });
103
+
104
+ test("ignores commits without the marker", () => {
105
+ const log = "feat: regular commit\n\nfix: another\n";
106
+ expect(parseExpansionReasonsFromGitLog(log)).toEqual([]);
107
+ });
108
+
109
+ test("empty log returns empty array", () => {
110
+ expect(parseExpansionReasonsFromGitLog("")).toEqual([]);
111
+ });
112
+
113
+ test("case-insensitive match", () => {
114
+ const log = "Expansion_Reason: capitalized variant\n";
115
+ expect(parseExpansionReasonsFromGitLog(log)).toEqual(["capitalized variant"]);
116
+ });
117
+ });
118
+
119
+ describe("detectScopeViolation", () => {
120
+ test("returns expected violations and expansion reasons via stub", () => {
121
+ const stub = (args: string[]): string => {
122
+ if (args[0] === "diff") return "src/foo.ts\nsrc/other.ts\n";
123
+ if (args[0] === "log") return "feat: change\n\nexpansion_reason: cross-cutting\n";
124
+ return "";
125
+ };
126
+ const result = detectScopeViolation({
127
+ worktreePath: "/tmp/wt",
128
+ baseRef: "main",
129
+ fileScope: ["src/foo.ts"],
130
+ gitRunner: stub,
131
+ });
132
+ expect(result.violations).toEqual(["src/other.ts"]);
133
+ expect(result.expansionReasons).toEqual(["cross-cutting"]);
134
+ });
135
+
136
+ test("returns empty when stub throws", () => {
137
+ const stub = (): string => {
138
+ throw new Error("boom");
139
+ };
140
+ const result = detectScopeViolation({
141
+ worktreePath: "/tmp/wt",
142
+ baseRef: "main",
143
+ fileScope: ["src/foo.ts"],
144
+ gitRunner: stub,
145
+ });
146
+ expect(result.violations).toEqual([]);
147
+ expect(result.expansionReasons).toEqual([]);
148
+ });
149
+
150
+ test("empty fileScope yields no violations regardless of diff", () => {
151
+ const stub = (args: string[]): string => {
152
+ if (args[0] === "diff") return "src/anything.ts\n";
153
+ return "";
154
+ };
155
+ const result = detectScopeViolation({
156
+ worktreePath: "/tmp/wt",
157
+ baseRef: "main",
158
+ fileScope: [],
159
+ gitRunner: stub,
160
+ });
161
+ expect(result.violations).toEqual([]);
162
+ });
163
+
164
+ test("blank diff lines are filtered", () => {
165
+ const stub = (args: string[]): string => {
166
+ if (args[0] === "diff") return "\nsrc/foo.ts\n\n\nsrc/bar.ts\n";
167
+ return "";
168
+ };
169
+ const result = detectScopeViolation({
170
+ worktreePath: "/tmp/wt",
171
+ baseRef: "main",
172
+ fileScope: ["src/foo.ts"],
173
+ gitRunner: stub,
174
+ });
175
+ expect(result.violations).toEqual(["src/bar.ts"]);
176
+ });
177
+ });
178
+
179
+ describe("IMPLEMENTATION_CAPABILITIES", () => {
180
+ test("includes builder and merger", () => {
181
+ expect(IMPLEMENTATION_CAPABILITIES.has("builder")).toBe(true);
182
+ expect(IMPLEMENTATION_CAPABILITIES.has("merger")).toBe(true);
183
+ });
184
+
185
+ test("excludes read-only roles", () => {
186
+ expect(IMPLEMENTATION_CAPABILITIES.has("scout")).toBe(false);
187
+ expect(IMPLEMENTATION_CAPABILITIES.has("reviewer")).toBe(false);
188
+ expect(IMPLEMENTATION_CAPABILITIES.has("lead")).toBe(false);
189
+ });
190
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Scope-violation detection for builder/merger turns (overstory-9f4d).
3
+ *
4
+ * Surfaces a soft, advisory signal when an agent's modified files exceed its
5
+ * declared FILE_SCOPE without an `expansion_reason:` justification. The
6
+ * turn-runner consumes this on terminal-mail observation; the lead reads the
7
+ * `events.db` record during merge verification.
8
+ *
9
+ * This is observability only — never a hard block. All errors are swallowed.
10
+ */
11
+
12
+ /**
13
+ * Capabilities allowed to modify files. Mirrors the set in
14
+ * `src/agents/hooks-deployer.ts`. Read-only roles (scout, reviewer) do not
15
+ * commit work, so scope detection is a no-op for them. Lead is excluded for
16
+ * the same reason — leads delegate, they don't touch files directly.
17
+ */
18
+ export const IMPLEMENTATION_CAPABILITIES: ReadonlySet<string> = new Set(["builder", "merger"]);
19
+
20
+ /**
21
+ * Synchronous git runner contract. Accepts argv (without the `git` prefix) and
22
+ * a working directory; returns combined stdout. Tests inject a stub; production
23
+ * calls `Bun.spawnSync(["git", ...args], { cwd })`.
24
+ */
25
+ export type GitRunner = (args: string[], cwd: string) => string;
26
+
27
+ const defaultGitRunner: GitRunner = (args, cwd) => {
28
+ const proc = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
29
+ if (proc.exitCode !== 0) {
30
+ return "";
31
+ }
32
+ return proc.stdout.toString();
33
+ };
34
+
35
+ /**
36
+ * Return the subset of `modifiedFiles` not covered by any FILE_SCOPE entry.
37
+ *
38
+ * - Empty `fileScope` is treated as unrestricted: returns `[]`.
39
+ * - A file is in scope when any scope entry matches it literally OR when
40
+ * `new Bun.Glob(entry).match(file)` returns true.
41
+ */
42
+ export function findScopeViolations(modifiedFiles: string[], fileScope: string[]): string[] {
43
+ if (fileScope.length === 0) return [];
44
+
45
+ const globs: Array<{ entry: string; glob: Bun.Glob }> = [];
46
+ for (const entry of fileScope) {
47
+ try {
48
+ globs.push({ entry, glob: new Bun.Glob(entry) });
49
+ } catch {
50
+ // malformed glob — fall back to literal-only match for this entry
51
+ globs.push({ entry, glob: new Bun.Glob(entry.replace(/[*?[\]{}]/g, "\\$&")) });
52
+ }
53
+ }
54
+
55
+ const violations: string[] = [];
56
+ for (const file of modifiedFiles) {
57
+ let matched = false;
58
+ for (const { entry, glob } of globs) {
59
+ if (entry === file) {
60
+ matched = true;
61
+ break;
62
+ }
63
+ try {
64
+ if (glob.match(file)) {
65
+ matched = true;
66
+ break;
67
+ }
68
+ } catch {
69
+ // ignore malformed pattern; continue
70
+ }
71
+ }
72
+ if (!matched) violations.push(file);
73
+ }
74
+ return violations;
75
+ }
76
+
77
+ /**
78
+ * Case-insensitive check for `expansion_reason:` followed by at least one
79
+ * non-whitespace character before the next newline.
80
+ *
81
+ * - Matches `expansion_reason: foo`, `Expansion_Reason: bar`, `EXPANSION_REASON: baz quux`.
82
+ * - Rejects `expansion_reason:` (empty value) and `expansion-reason: foo` (different separator).
83
+ */
84
+ export function hasExpansionReason(message: string): boolean {
85
+ return /expansion_reason:[^\S\n]*\S+/i.test(message);
86
+ }
87
+
88
+ /**
89
+ * Parse `expansion_reason:` values from the output of `git log --format=%B <range>`.
90
+ * Returns each value (trimmed) in encounter order. Commits without the marker
91
+ * are ignored.
92
+ */
93
+ export function parseExpansionReasonsFromGitLog(log: string): string[] {
94
+ const reasons: string[] = [];
95
+ const re = /expansion_reason:[^\S\n]*([^\n]*\S)/gi;
96
+ let m: RegExpExecArray | null = re.exec(log);
97
+ while (m !== null) {
98
+ const value = m[1]?.trim();
99
+ if (value && value.length > 0) reasons.push(value);
100
+ m = re.exec(log);
101
+ }
102
+ return reasons;
103
+ }
104
+
105
+ export interface DetectScopeViolationOpts {
106
+ worktreePath: string;
107
+ baseRef: string;
108
+ fileScope: string[];
109
+ /** Test injection — replaces `Bun.spawnSync` for git calls. */
110
+ gitRunner?: GitRunner;
111
+ }
112
+
113
+ export interface ScopeViolationResult {
114
+ violations: string[];
115
+ expansionReasons: string[];
116
+ }
117
+
118
+ /**
119
+ * Detect scope violations for a worktree at HEAD relative to `baseRef`.
120
+ *
121
+ * - Runs `git diff --name-only baseRef...HEAD` to enumerate modified files.
122
+ * - Runs `git log --format=%B baseRef..HEAD` and parses `expansion_reason:`
123
+ * markers from commit bodies.
124
+ *
125
+ * Always returns. Any failure (git error, parse failure) yields
126
+ * `{ violations: [], expansionReasons: [] }` — this is an advisory signal and
127
+ * must never break the runner.
128
+ */
129
+ export function detectScopeViolation(opts: DetectScopeViolationOpts): ScopeViolationResult {
130
+ const runner = opts.gitRunner ?? defaultGitRunner;
131
+ try {
132
+ const diffOut = runner(["diff", "--name-only", `${opts.baseRef}...HEAD`], opts.worktreePath);
133
+ const modifiedFiles = diffOut
134
+ .split("\n")
135
+ .map((line) => line.trim())
136
+ .filter((line) => line.length > 0);
137
+
138
+ const logOut = runner(["log", "--format=%B", `${opts.baseRef}..HEAD`], opts.worktreePath);
139
+ const expansionReasons = parseExpansionReasonsFromGitLog(logOut);
140
+
141
+ const violations = findScopeViolations(modifiedFiles, opts.fileScope);
142
+ return { violations, expansionReasons };
143
+ } catch {
144
+ return { violations: [], expansionReasons: [] };
145
+ }
146
+ }