@ilya-lesikov/pi-pi 0.5.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.
- package/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +68 -28
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +630 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +2 -1
- package/extensions/orchestrator/doctor.test.ts +559 -0
- package/extensions/orchestrator/doctor.ts +664 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1177 -341
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +224 -0
- package/extensions/orchestrator/flant-infra.ts +110 -41
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +2866 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +238 -0
- package/extensions/orchestrator/model-registry.ts +282 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +287 -140
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -36
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -31
- package/extensions/orchestrator/phases/review-task.ts +3 -7
- package/extensions/orchestrator/phases/review.ts +38 -39
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +134 -0
- package/extensions/orchestrator/pp-menu.ts +2557 -347
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +89 -26
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +217 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +435 -0
- package/extensions/orchestrator/usage-tracker.ts +23 -2
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- 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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
139
|
-
update: timeouts.
|
|
207
|
+
stale: timeouts.taskLockStale,
|
|
208
|
+
update: timeouts.taskLockRefresh,
|
|
140
209
|
retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 },
|
|
141
210
|
onCompromised: (err: Error) => {
|
|
142
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
}
|