@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.
- 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 +631 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +7 -3
- package/extensions/orchestrator/doctor.test.ts +561 -0
- package/extensions/orchestrator/doctor.ts +702 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1220 -343
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +511 -0
- package/extensions/orchestrator/flant-infra.ts +390 -49
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +3065 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +302 -0
- package/extensions/orchestrator/model-registry.ts +298 -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 +298 -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.test.ts +54 -0
- package/extensions/orchestrator/phases/review.ts +89 -48
- 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 +207 -0
- package/extensions/orchestrator/pp-menu.ts +2741 -373
- 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 +128 -44
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +229 -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 +563 -0
- package/extensions/orchestrator/usage-tracker.ts +96 -12
- 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
|
+
});
|