@ilya-lesikov/pi-pi 0.5.0 → 0.7.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 (77) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +631 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +7 -3
  28. package/extensions/orchestrator/doctor.test.ts +561 -0
  29. package/extensions/orchestrator/doctor.ts +702 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1220 -343
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +511 -0
  35. package/extensions/orchestrator/flant-infra.ts +390 -49
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +3065 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +302 -0
  41. package/extensions/orchestrator/model-registry.ts +298 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +298 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.test.ts +54 -0
  54. package/extensions/orchestrator/phases/review.ts +89 -48
  55. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  56. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  57. package/extensions/orchestrator/phases/verdict.ts +82 -0
  58. package/extensions/orchestrator/plannotator.test.ts +85 -0
  59. package/extensions/orchestrator/plannotator.ts +24 -6
  60. package/extensions/orchestrator/pp-menu.test.ts +207 -0
  61. package/extensions/orchestrator/pp-menu.ts +2741 -373
  62. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  63. package/extensions/orchestrator/repo-utils.ts +67 -0
  64. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  65. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  66. package/extensions/orchestrator/state.test.ts +76 -6
  67. package/extensions/orchestrator/state.ts +128 -44
  68. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  69. package/extensions/orchestrator/test-helpers.ts +229 -0
  70. package/extensions/orchestrator/tracer.test.ts +132 -0
  71. package/extensions/orchestrator/tracer.ts +127 -0
  72. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  73. package/extensions/orchestrator/transition-controller.ts +259 -0
  74. package/extensions/orchestrator/usage-tracker.test.ts +563 -0
  75. package/extensions/orchestrator/usage-tracker.ts +96 -12
  76. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  77. package/package.json +2 -1
@@ -0,0 +1,151 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ findRootRepo,
7
+ getBaseBranchForRepo,
8
+ groupFilesByRepo,
9
+ isPathInRepo,
10
+ normalizeRepoPath,
11
+ resolveRepoForFile,
12
+ type RepoInfo,
13
+ } from "./repo-utils.js";
14
+
15
+ const tempDirs: string[] = [];
16
+
17
+ function makeTempDir(): string {
18
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-repo-utils-"));
19
+ tempDirs.push(dir);
20
+ return dir;
21
+ }
22
+
23
+ afterEach(() => {
24
+ for (const dir of tempDirs.splice(0)) {
25
+ rmSync(dir, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ describe("isPathInRepo", () => {
30
+ it("rejects boundary prefix paths", () => {
31
+ const base = makeTempDir();
32
+ const repoPath = join(base, "repo");
33
+ const siblingPath = join(base, "repo-sibling", "src", "file.ts");
34
+ mkdirSync(join(repoPath, "src"), { recursive: true });
35
+ mkdirSync(join(base, "repo-sibling", "src"), { recursive: true });
36
+ writeFileSync(join(repoPath, "src", "index.ts"), "export {};\n", "utf-8");
37
+ writeFileSync(siblingPath, "export {};\n", "utf-8");
38
+
39
+ expect(isPathInRepo(siblingPath, repoPath)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("resolveRepoForFile", () => {
44
+ it("resolves files under symlinked path to canonical repo", () => {
45
+ const base = makeTempDir();
46
+ const realRepo = join(base, "real-repo");
47
+ const linkRepo = join(base, "linked-repo");
48
+ mkdirSync(join(realRepo, "src"), { recursive: true });
49
+ writeFileSync(join(realRepo, "src", "app.ts"), "export const app = true;\n", "utf-8");
50
+ symlinkSync(realRepo, linkRepo, "dir");
51
+
52
+ const repos: RepoInfo[] = [{ path: normalizeRepoPath(realRepo), isRoot: false }];
53
+ const fileViaLink = join(linkRepo, "src", "app.ts");
54
+
55
+ expect(resolveRepoForFile(repos, fileViaLink)?.path).toBe(normalizeRepoPath(realRepo));
56
+ });
57
+
58
+ it("picks longest matching repo path", () => {
59
+ const base = makeTempDir();
60
+ const parentRepo = join(base, "repo");
61
+ const nestedRepo = join(parentRepo, "nested");
62
+ const nestedFile = join(nestedRepo, "src", "main.ts");
63
+ mkdirSync(join(parentRepo, "src"), { recursive: true });
64
+ mkdirSync(join(nestedRepo, "src"), { recursive: true });
65
+ writeFileSync(join(parentRepo, "src", "root.ts"), "export const root = true;\n", "utf-8");
66
+ writeFileSync(nestedFile, "export const nested = true;\n", "utf-8");
67
+
68
+ const repos: RepoInfo[] = [
69
+ { path: normalizeRepoPath(parentRepo), isRoot: true },
70
+ { path: normalizeRepoPath(nestedRepo), isRoot: false },
71
+ ];
72
+
73
+ expect(resolveRepoForFile(repos, nestedFile)?.path).toBe(normalizeRepoPath(nestedRepo));
74
+ });
75
+ });
76
+
77
+ describe("groupFilesByRepo", () => {
78
+ it("groups files by matching repository paths", () => {
79
+ const base = makeTempDir();
80
+ const rootRepo = join(base, "root");
81
+ const extraRepo = join(base, "extra");
82
+ mkdirSync(join(rootRepo, "src"), { recursive: true });
83
+ mkdirSync(join(extraRepo, "lib"), { recursive: true });
84
+ const rootFile = join(rootRepo, "src", "a.ts");
85
+ const extraFile = join(extraRepo, "lib", "b.ts");
86
+ writeFileSync(rootFile, "export const a = 1;\n", "utf-8");
87
+ writeFileSync(extraFile, "export const b = 2;\n", "utf-8");
88
+
89
+ const repos: RepoInfo[] = [
90
+ { path: normalizeRepoPath(rootRepo), isRoot: true },
91
+ { path: normalizeRepoPath(extraRepo), isRoot: false },
92
+ ];
93
+
94
+ const grouped = groupFilesByRepo(repos, [rootFile, extraFile]);
95
+
96
+ expect(grouped.get(normalizeRepoPath(rootRepo))).toEqual([rootFile]);
97
+ expect(grouped.get(normalizeRepoPath(extraRepo))).toEqual([extraFile]);
98
+ });
99
+
100
+ it("puts unmatched files under empty-key bucket", () => {
101
+ const base = makeTempDir();
102
+ const repo = join(base, "repo");
103
+ const outside = join(base, "outside.ts");
104
+ mkdirSync(join(repo, "src"), { recursive: true });
105
+ writeFileSync(join(repo, "src", "a.ts"), "export const a = 1;\n", "utf-8");
106
+ writeFileSync(outside, "export const outside = 1;\n", "utf-8");
107
+
108
+ const repos: RepoInfo[] = [{ path: normalizeRepoPath(repo), isRoot: true }];
109
+
110
+ const grouped = groupFilesByRepo(repos, [outside]);
111
+
112
+ expect(grouped.get("")).toEqual([outside]);
113
+ });
114
+ });
115
+
116
+ describe("findRootRepo", () => {
117
+ it("finds root repository entry", () => {
118
+ const repos: RepoInfo[] = [
119
+ { path: "/tmp/a", isRoot: false },
120
+ { path: "/tmp/b", isRoot: true },
121
+ ];
122
+
123
+ expect(findRootRepo(repos)).toEqual({ path: "/tmp/b", isRoot: true });
124
+ });
125
+
126
+ it("returns undefined when no root repo exists", () => {
127
+ const repos: RepoInfo[] = [{ path: "/tmp/a", isRoot: false }];
128
+
129
+ expect(findRootRepo(repos)).toBeUndefined();
130
+ });
131
+ });
132
+
133
+ describe("getBaseBranchForRepo", () => {
134
+ it("returns base branch for matching repository path", () => {
135
+ const base = makeTempDir();
136
+ const repoPath = join(base, "repo");
137
+ mkdirSync(repoPath, { recursive: true });
138
+ const repos: RepoInfo[] = [{ path: normalizeRepoPath(repoPath), isRoot: true, baseBranch: "origin/main" }];
139
+
140
+ expect(getBaseBranchForRepo(repos, repoPath)).toBe("origin/main");
141
+ });
142
+
143
+ it("returns undefined for unknown repository path", () => {
144
+ const base = makeTempDir();
145
+ const repoPath = join(base, "repo");
146
+ mkdirSync(repoPath, { recursive: true });
147
+ const repos: RepoInfo[] = [{ path: normalizeRepoPath(repoPath), isRoot: true, baseBranch: "origin/main" }];
148
+
149
+ expect(getBaseBranchForRepo(repos, join(base, "missing"))).toBeUndefined();
150
+ });
151
+ });
@@ -0,0 +1,67 @@
1
+ import { realpathSync, existsSync } from "fs";
2
+ import { resolve, sep } from "path";
3
+
4
+ export interface RepoInfo {
5
+ path: string;
6
+ baseBranch?: string;
7
+ isRoot: boolean;
8
+ }
9
+
10
+ export function normalizeRepoPath(p: string): string {
11
+ const abs = resolve(p);
12
+ try {
13
+ return existsSync(abs) ? realpathSync(abs) : abs;
14
+ } catch {
15
+ return abs;
16
+ }
17
+ }
18
+
19
+ export function isPathInRepo(filePath: string, repoPath: string): boolean {
20
+ let normFile = resolve(filePath);
21
+ try {
22
+ if (existsSync(normFile)) {
23
+ normFile = realpathSync(normFile);
24
+ }
25
+ } catch {}
26
+ const normalizedRepoPath = normalizeRepoPath(repoPath);
27
+ const normRepo = normalizedRepoPath.endsWith(sep) ? normalizedRepoPath : normalizedRepoPath + sep;
28
+ return normFile === normalizedRepoPath || normFile.startsWith(normRepo);
29
+ }
30
+
31
+ export function resolveRepoForFile(repos: RepoInfo[], filePath: string): RepoInfo | null {
32
+ const normFile = resolve(filePath);
33
+ let best: RepoInfo | null = null;
34
+ let bestLen = 0;
35
+ for (const repo of repos) {
36
+ if (isPathInRepo(normFile, repo.path) && repo.path.length > bestLen) {
37
+ best = repo;
38
+ bestLen = repo.path.length;
39
+ }
40
+ }
41
+ return best;
42
+ }
43
+
44
+ export function groupFilesByRepo(repos: RepoInfo[], files: string[]): Map<string, string[]> {
45
+ const groups = new Map<string, string[]>();
46
+ for (const file of files) {
47
+ const repo = resolveRepoForFile(repos, file);
48
+ const key = repo?.path ?? "";
49
+ const list = groups.get(key);
50
+ if (list) list.push(file);
51
+ else groups.set(key, [file]);
52
+ }
53
+ return groups;
54
+ }
55
+
56
+ export function getBaseBranchForRepo(repos: RepoInfo[], repoPath: string): string | undefined {
57
+ const norm = normalizeRepoPath(repoPath);
58
+ return repos.find((r) => r.path === norm)?.baseBranch;
59
+ }
60
+
61
+ export function getAllRepos(repos: RepoInfo[]): RepoInfo[] {
62
+ return repos;
63
+ }
64
+
65
+ export function findRootRepo(repos: RepoInfo[]): RepoInfo | undefined {
66
+ return repos.find((r) => r.isRoot);
67
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { handleSpawnResult } from "./spawn-cleanup.js";
3
+ import type { Orchestrator } from "./orchestrator.js";
4
+
5
+ function makeOrchestrator(): Orchestrator {
6
+ return {
7
+ pendingSubagentSpawns: 3,
8
+ failedPlannerVariants: [],
9
+ failedReviewerVariants: [],
10
+ } as unknown as Orchestrator;
11
+ }
12
+
13
+ describe("handleSpawnResult", () => {
14
+ it("resets pending and records failed planner variants on success", async () => {
15
+ const orch = makeOrchestrator();
16
+ const onSettled = vi.fn();
17
+ handleSpawnResult(orch, Promise.resolve({ spawned: 2, agentIds: ["a"], failedVariants: ["bad"] }), {
18
+ kind: "planner",
19
+ logScope: "planner",
20
+ logMessage: "x",
21
+ onSettled,
22
+ });
23
+ await Promise.resolve();
24
+ await Promise.resolve();
25
+ expect(orch.pendingSubagentSpawns).toBe(0);
26
+ expect(orch.failedPlannerVariants).toEqual(["bad"]);
27
+ expect(onSettled).toHaveBeenCalledOnce();
28
+ });
29
+
30
+ it("resets pending and still runs onSettled on rejection (fail-safe)", async () => {
31
+ const orch = makeOrchestrator();
32
+ const onSettled = vi.fn();
33
+ handleSpawnResult(orch, Promise.reject(new Error("boom")), {
34
+ kind: "reviewer",
35
+ logScope: "review",
36
+ logMessage: "x",
37
+ onSettled,
38
+ });
39
+ await Promise.resolve();
40
+ await Promise.resolve();
41
+ expect(orch.pendingSubagentSpawns).toBe(0);
42
+ expect(onSettled).toHaveBeenCalledOnce();
43
+ });
44
+
45
+ it("targets failedReviewerVariants for the reviewer kind", async () => {
46
+ const orch = makeOrchestrator();
47
+ handleSpawnResult(orch, Promise.resolve({ spawned: 0, agentIds: [], failedVariants: ["r"] }), {
48
+ kind: "reviewer",
49
+ logScope: "review",
50
+ logMessage: "x",
51
+ });
52
+ await Promise.resolve();
53
+ await Promise.resolve();
54
+ expect(orch.failedReviewerVariants).toEqual(["r"]);
55
+ expect(orch.pendingSubagentSpawns).toBe(0);
56
+ });
57
+ });
@@ -0,0 +1,35 @@
1
+ import type { Orchestrator } from "./orchestrator.js";
2
+ import { getLogger } from "./log.js";
3
+
4
+ type SpawnResult = { spawned: number; agentIds?: string[]; failedVariants: string[] };
5
+
6
+ type SpawnCleanupOptions = {
7
+ kind: "planner" | "reviewer";
8
+ logScope: string;
9
+ logMessage: string;
10
+ logExtra?: Record<string, unknown>;
11
+ onSettled?: (result?: SpawnResult) => void;
12
+ };
13
+
14
+ // Centralizes the cleanup that runs after a planner/reviewer spawn promise
15
+ // settles. The spawn functions block until every spawned agent completes, so
16
+ // resetting pendingSubagentSpawns to 0 is the fail-safe for the spawned===0
17
+ // case and the .catch path where no subagents:completed event fires.
18
+ export function handleSpawnResult(
19
+ orchestrator: Orchestrator,
20
+ promise: Promise<SpawnResult>,
21
+ opts: SpawnCleanupOptions,
22
+ ): void {
23
+ const field = opts.kind === "planner" ? "failedPlannerVariants" : "failedReviewerVariants";
24
+ promise
25
+ .then((result) => {
26
+ orchestrator[field] = result.failedVariants;
27
+ orchestrator.pendingSubagentSpawns = 0;
28
+ opts.onSettled?.(result);
29
+ })
30
+ .catch((err: any) => {
31
+ orchestrator.pendingSubagentSpawns = 0;
32
+ opts.onSettled?.();
33
+ getLogger().error({ s: opts.logScope, ...opts.logExtra, err: err.message }, opts.logMessage);
34
+ });
35
+ }
@@ -1,9 +1,21 @@
1
1
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
2
2
  import { tmpdir } from "os";
3
- import { join } from "path";
3
+ import { join, resolve } from "path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import lockfile from "proper-lockfile";
6
- import { createTask, getActiveTask, listTasks, loadTask, saveTask, taskAge, taskName, validateFromPath, type TaskState } from "./state.js";
6
+ import {
7
+ createTask,
8
+ getActiveTask,
9
+ getEffectiveMode,
10
+ getFirstPhase,
11
+ listTasks,
12
+ loadTask,
13
+ saveTask,
14
+ taskAge,
15
+ taskName,
16
+ validateFromPath,
17
+ type TaskState,
18
+ } from "./state.js";
7
19
 
8
20
  const tempDirs: string[] = [];
9
21
 
@@ -30,6 +42,7 @@ describe("createTask", () => {
30
42
  expect(taskDir).toContain(join(cwd, ".pp", "state", "implement"));
31
43
  expect(state.phase).toBe("brainstorm");
32
44
  expect(state.from).toBeNull();
45
+ expect(state.repos).toEqual([{ path: resolve(cwd), isRoot: true }]);
33
46
  expect(state.description).toBe("Build New Feature");
34
47
  expect(new Date(state.startedAt).toString()).not.toBe("Invalid Date");
35
48
 
@@ -56,6 +69,21 @@ describe("createTask", () => {
56
69
  expect(state.reviewCycle).toBeNull();
57
70
  expect(state.reviewPass).toBe(0);
58
71
  });
72
+
73
+ it("uses quick as initial phase for quick tasks", () => {
74
+ const cwd = makeCwd();
75
+ const taskDir = createTask(cwd, "quick", "Quick fix");
76
+ const state = loadTask(taskDir);
77
+ expect(state.phase).toBe("quick");
78
+ expect(state.step).toBe("llm_work");
79
+ });
80
+
81
+ it("stores task mode when provided", () => {
82
+ const cwd = makeCwd();
83
+ const taskDir = createTask(cwd, "implement", "Build New Feature", "autonomous");
84
+ const state = loadTask(taskDir);
85
+ expect(state.mode).toBe("autonomous");
86
+ });
59
87
  });
60
88
 
61
89
  describe("loadTask", () => {
@@ -81,12 +109,16 @@ describe("saveTask", () => {
81
109
  const taskDir = createTask(cwd, "implement", "Initial");
82
110
  const state: TaskState = {
83
111
  phase: "implement",
112
+ initialPhase: "implement",
84
113
  step: "user_gate",
85
114
  reviewCycle: { kind: "auto", step: "await_reviewers", pass: 2 },
86
115
  reviewPass: 1,
116
+ repos: [],
87
117
  from: "implement/some-task",
88
118
  description: "Updated",
89
119
  startedAt: "2026-04-20T00:00:00.000Z",
120
+ plannerFailureAutoRetried: false,
121
+ reviewerFailureAutoRetried: false,
90
122
  };
91
123
 
92
124
  saveTask(taskDir, state);
@@ -125,12 +157,10 @@ describe("listTasks", () => {
125
157
  const corrupt = createTask(cwd, "implement", "Broken task");
126
158
  writeFileSync(join(corrupt, "state.json"), "{ nope", "utf-8");
127
159
 
128
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
129
160
  const tasks = listTasks(cwd, "implement");
130
161
 
131
162
  expect(tasks).toHaveLength(1);
132
163
  expect(tasks[0].state.description).toBe("Healthy task");
133
- expect(errorSpy).toHaveBeenCalled();
134
164
  });
135
165
  });
136
166
 
@@ -202,9 +232,7 @@ describe("taskName", () => {
202
232
  const taskDir = join(cwd, ".pp", "state", "implement", "123456789012_my-fallback-name");
203
233
  mkdirSync(taskDir, { recursive: true });
204
234
 
205
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
206
235
  expect(taskName(taskDir)).toBe("my fallback name");
207
- expect(errorSpy).toHaveBeenCalled();
208
236
  });
209
237
  });
210
238
 
@@ -341,3 +369,45 @@ describe("getActiveTask", () => {
341
369
  expect(checkSpy).toHaveBeenCalledWith(join(taskDir, "state.json"), expect.objectContaining({ stale: 1234 }));
342
370
  });
343
371
  });
372
+
373
+ describe("getFirstPhase", () => {
374
+ it("returns expected first phase for each task type", () => {
375
+ expect(getFirstPhase("implement")).toBe("brainstorm");
376
+ expect(getFirstPhase("brainstorm")).toBe("brainstorm");
377
+ expect(getFirstPhase("debug")).toBe("debug");
378
+ expect(getFirstPhase("review")).toBe("review");
379
+ expect(getFirstPhase("quick")).toBe("quick");
380
+ });
381
+ });
382
+
383
+ describe("getEffectiveMode", () => {
384
+ function baseState(): TaskState {
385
+ return {
386
+ phase: "brainstorm",
387
+ step: "llm_work",
388
+ reviewCycle: null,
389
+ reviewPass: 0,
390
+ from: null,
391
+ description: "x",
392
+ startedAt: new Date().toISOString(),
393
+ };
394
+ }
395
+
396
+ it("returns effectiveMode when it is set", () => {
397
+ const state: TaskState = { ...baseState(), mode: "guided", effectiveMode: "autonomous" };
398
+
399
+ expect(getEffectiveMode(state)).toBe("autonomous");
400
+ });
401
+
402
+ it("falls back to mode when effectiveMode is undefined", () => {
403
+ const state: TaskState = { ...baseState(), mode: "guided" };
404
+
405
+ expect(getEffectiveMode(state)).toBe("guided");
406
+ });
407
+
408
+ it("returns undefined when both effectiveMode and mode are undefined", () => {
409
+ const state: TaskState = { ...baseState() };
410
+
411
+ expect(getEffectiveMode(state)).toBeUndefined();
412
+ });
413
+ });