@ilya-lesikov/pi-pi 0.4.0 → 0.6.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 (76) 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 +73 -31
  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 +630 -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 +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1191 -360
  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 +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -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 +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -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 +295 -148
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -35
  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 -29
  52. package/extensions/orchestrator/phases/review-task.ts +3 -3
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2631 -392
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. 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
+ });
@@ -1,38 +1,63 @@
1
- import { appendFileSync, readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
2
- import { join, basename, resolve } from "path";
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
2
+ import { join, basename, resolve, relative, isAbsolute, sep } from "path";
3
3
  import lockfile from "proper-lockfile";
4
+ import { normalizeRepoPath, type RepoInfo } from "./repo-utils.js";
5
+ import { getLogger } from "./log.js";
4
6
 
5
7
  function getLockfileFs(): typeof import("fs") | undefined {
6
8
  try {
7
9
  const fs = require("fs");
8
10
  fs.statSync(process.cwd());
9
11
  return fs;
10
- } catch {
12
+ } catch (err: any) {
13
+ getLogger().debug({ s: "state", err: err?.message }, "getLockfileFs unavailable");
11
14
  return undefined;
12
15
  }
13
16
  }
14
17
  import type { TimeoutConfig } from "./config.js";
15
18
 
16
- export type TaskType = "implement" | "debug" | "brainstorm" | "review";
19
+ export type TaskMode = "guided" | "autonomous";
20
+
21
+ export interface AutonomousPhaseConfig {
22
+ plannerPreset?: string;
23
+ reviewPreset?: string;
24
+ maxReviewPasses: number;
25
+ }
26
+
27
+ export interface AutonomousConfig {
28
+ phases: Record<string, AutonomousPhaseConfig>;
29
+ }
30
+
31
+ export type TaskType = "implement" | "debug" | "brainstorm" | "review" | "quick";
17
32
 
18
33
  export type ImplementPhase = "brainstorm" | "plan" | "implement" | "done";
19
34
  export type DebugPhase = "debug" | "plan" | "implement" | "done";
20
35
  export type BrainstormPhase = "brainstorm" | "plan" | "implement" | "done";
21
36
  export type ReviewPhase = "review" | "plan" | "implement" | "done";
22
- export type Phase = ImplementPhase | DebugPhase | BrainstormPhase | ReviewPhase;
37
+ export type QuickPhase = "quick";
38
+ export type Phase = ImplementPhase | DebugPhase | BrainstormPhase | ReviewPhase | QuickPhase;
23
39
 
24
40
  export interface TaskState {
25
41
  phase: Phase;
42
+ initialPhase?: string;
26
43
  step: string | null;
27
44
  reviewCycle: { kind: string; step: string; pass: number } | null;
28
45
  reviewPass: number;
29
- reviewPassByKind?: Record<string, number>;
46
+ reviewPassByKind?: Record<string, Record<string, number>>;
47
+ reviewApprovedClean?: boolean;
30
48
  modifiedFiles?: string[];
31
- repoCwd?: string;
49
+ committedFiles?: string[];
50
+ repos?: RepoInfo[];
32
51
  from: string | null;
33
52
  description: string;
34
53
  startedAt: string;
35
-
54
+ activePlannerPreset?: string;
55
+ activeReviewPreset?: string;
56
+ mode?: TaskMode;
57
+ effectiveMode?: TaskMode;
58
+ autonomousConfig?: AutonomousConfig;
59
+ plannerFailureAutoRetried?: boolean;
60
+ reviewerFailureAutoRetried?: boolean;
36
61
  }
37
62
 
38
63
  export interface TaskInfo {
@@ -49,7 +74,27 @@ function taskStatePath(taskDir: string): string {
49
74
  return join(taskDir, "state.json");
50
75
  }
51
76
 
52
- export function createTask(cwd: string, type: TaskType, description: string): string {
77
+ export function getFirstPhase(type: TaskType): Exclude<Phase, "done"> {
78
+ if (type === "implement" || type === "brainstorm") return "brainstorm";
79
+ if (type === "debug") return "debug";
80
+ if (type === "review") return "review";
81
+ return "quick";
82
+ }
83
+
84
+ export function getEffectiveMode(state: TaskState): TaskMode | undefined {
85
+ return state.effectiveMode ?? state.mode;
86
+ }
87
+
88
+ // The research first phases (brainstorm/debug/review) are always interactive: the user
89
+ // drives them, so they never inherit the task's autonomous mode even when one is set.
90
+ // plan/implement are autonomous-capable regardless of whether they are the task's initial phase.
91
+ export function getEffectivePhaseMode(state: TaskState): TaskMode {
92
+ if (state.phase === "brainstorm" || state.phase === "debug" || state.phase === "review") return "guided";
93
+ return getEffectiveMode(state) ?? "guided";
94
+ }
95
+
96
+ export function createTask(cwd: string, type: TaskType, description: string, mode?: TaskMode): string {
97
+ const log = getLogger();
53
98
  const id = crypto.randomUUID().slice(0, 12);
54
99
  const safeName = description
55
100
  .toLowerCase()
@@ -62,16 +107,22 @@ export function createTask(cwd: string, type: TaskType, description: string): st
62
107
  mkdirSync(taskDir, { recursive: true });
63
108
 
64
109
  const state: TaskState = {
65
- phase: type === "implement" ? "brainstorm" : type === "debug" ? "debug" : type === "review" ? "review" : "brainstorm",
110
+ phase: getFirstPhase(type),
111
+ initialPhase: getFirstPhase(type),
66
112
  step: "llm_work",
67
113
  reviewCycle: null,
68
114
  reviewPass: 0,
115
+ repos: [{ path: normalizeRepoPath(cwd), isRoot: true }],
69
116
  from: null,
70
117
  description,
71
118
  startedAt: new Date().toISOString(),
119
+ mode,
120
+ plannerFailureAutoRetried: false,
121
+ reviewerFailureAutoRetried: false,
72
122
  };
73
123
 
74
124
  writeFileSync(taskStatePath(taskDir), JSON.stringify(state, null, 2) + "\n", "utf-8");
125
+ log.info({ s: "task", taskDir, type, description, phase: state.phase }, "task created");
75
126
  return taskDir;
76
127
  }
77
128
 
@@ -80,6 +131,23 @@ export function loadTask(taskDir: string): TaskState {
80
131
  const raw = readFileSync(sp, "utf-8");
81
132
  try {
82
133
  const state = JSON.parse(raw) as TaskState;
134
+ if ((state as any).phase === "planning") {
135
+ state.phase = "plan";
136
+ }
137
+ if (!state.initialPhase) {
138
+ state.initialPhase = state.phase;
139
+ }
140
+ if (state.plannerFailureAutoRetried === undefined) {
141
+ state.plannerFailureAutoRetried = false;
142
+ }
143
+ if (state.reviewerFailureAutoRetried === undefined) {
144
+ state.reviewerFailureAutoRetried = false;
145
+ }
146
+ if ((state as any).repoCwd && (!state.repos || state.repos.length === 0)) {
147
+ state.repos = [{ path: (state as any).repoCwd, isRoot: false }];
148
+ }
149
+ if (!state.repos) state.repos = [];
150
+ delete (state as any).repoCwd;
83
151
  return state;
84
152
  } catch (err: any) {
85
153
  throw new Error(`Failed to parse ${sp}: ${err.message}`);
@@ -87,6 +155,7 @@ export function loadTask(taskDir: string): TaskState {
87
155
  }
88
156
 
89
157
  export function saveTask(taskDir: string, state: TaskState): void {
158
+ getLogger().debug({ s: "task", taskDir, phase: state.phase, step: state.step }, "saving task state");
90
159
  writeFileSync(taskStatePath(taskDir), JSON.stringify(state, null, 2) + "\n", "utf-8");
91
160
  }
92
161
 
@@ -94,7 +163,7 @@ export function listTasks(cwd: string, type?: TaskType): TaskInfo[] {
94
163
  const base = stateDir(cwd);
95
164
  if (!existsSync(base)) return [];
96
165
 
97
- const types: TaskType[] = type ? [type] : ["implement", "debug", "brainstorm", "review"];
166
+ const types: TaskType[] = type ? [type] : ["implement", "debug", "brainstorm", "review", "quick"];
98
167
  const results: TaskInfo[] = [];
99
168
 
100
169
  for (const t of types) {
@@ -113,7 +182,7 @@ export function listTasks(cwd: string, type?: TaskType): TaskInfo[] {
113
182
  results.push({ dir, state, type: t });
114
183
  }
115
184
  } catch {
116
- console.error(`[pi-pi] Skipping corrupt task at ${dir}`);
185
+ getLogger().warn({ s: "state", dir }, "skipping corrupt task");
117
186
  }
118
187
  }
119
188
  }
@@ -135,11 +204,11 @@ export async function lockTask(taskDir: string, timeouts: TimeoutConfig): Promis
135
204
  const lockFs = getLockfileFs();
136
205
  const release = await lockfile.lock(sp, {
137
206
  ...(lockFs && { fs: lockFs }),
138
- stale: timeouts.lockStale,
139
- update: timeouts.lockUpdate,
207
+ stale: timeouts.taskLockStale,
208
+ update: timeouts.taskLockRefresh,
140
209
  retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 },
141
210
  onCompromised: (err: Error) => {
142
- console.error(`[pi-pi] Lock compromised for ${sp}: ${err.message}`);
211
+ getLogger().error({ s: "state", path: sp, err: err.message }, "lock compromised");
143
212
  },
144
213
  });
145
214
  return release;
@@ -158,7 +227,7 @@ export function getActiveTask(cwd: string, lockStale?: number): TaskInfo | null
158
227
  unlocked.push(task);
159
228
  }
160
229
  } catch {
161
- console.error(`[pi-pi] Failed to check lock for ${task.dir}`);
230
+ getLogger().warn({ s: "state", dir: task.dir }, "failed to check lock");
162
231
  }
163
232
  }
164
233
 
@@ -173,8 +242,9 @@ export function validateFromPath(cwd: string, fromPath: string): { ok: true; dir
173
242
 
174
243
  const stateRoot = resolve(stateDir(cwd));
175
244
  const resolved = resolve(stateRoot, fromPath);
245
+ const rel = relative(stateRoot, resolved);
176
246
 
177
- if (resolved !== stateRoot && !resolved.startsWith(stateRoot + "/")) {
247
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
178
248
  return { ok: false, reason: "Path escapes .pp/state/ directory" };
179
249
  }
180
250
 
@@ -195,7 +265,7 @@ export function taskName(taskDir: string): string {
195
265
  const state = loadTask(taskDir);
196
266
  let desc = state.description ?? "";
197
267
 
198
- if (["implement", "debug", "brainstorm", "review"].includes(desc)) {
268
+ if (["implement", "debug", "brainstorm", "review", "quick"].includes(desc)) {
199
269
  const urPath = join(taskDir, "USER_REQUEST.md");
200
270
  if (existsSync(urPath)) {
201
271
  const content = readFileSync(urPath, "utf-8");
@@ -211,7 +281,7 @@ export function taskName(taskDir: string): string {
211
281
  return desc;
212
282
  }
213
283
  } catch {
214
- console.error(`[pi-pi] Failed to read task name from ${taskDir}`);
284
+ getLogger().warn({ s: "state", taskDir }, "failed to read task name");
215
285
  }
216
286
  const dir = basename(taskDir);
217
287
  const match = dir.match(/^\d+_(.+)$/);
@@ -227,10 +297,3 @@ export function taskAge(state: TaskState): string {
227
297
  if (hours < 24) return `${hours}h`;
228
298
  return `${Math.floor(hours / 24)}d`;
229
299
  }
230
-
231
- export function appendTaskLog(taskDir: string, fileName: string, entry: Record<string, unknown>): string {
232
- mkdirSync(taskDir, { recursive: true });
233
- const filePath = join(taskDir, fileName);
234
- appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
235
- return filePath;
236
- }