pi-plan-task 1.1.0 → 3.0.1
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/README.md +633 -68
- package/extensions/build-session.test.ts +25 -27
- package/extensions/build-session.ts +38 -11
- package/extensions/command-surface.test.ts +11 -0
- package/extensions/config.ts +12 -40
- package/extensions/files.test.ts +21 -1
- package/extensions/files.ts +11 -27
- package/extensions/framing.test.ts +1 -0
- package/extensions/framing.ts +5 -2
- package/extensions/history.test.ts +24 -0
- package/extensions/history.ts +42 -0
- package/extensions/index.ts +583 -224
- package/extensions/integration.test.ts +232 -0
- package/extensions/migration.test.ts +34 -0
- package/extensions/parse.ts +80 -4
- package/extensions/paths.ts +26 -32
- package/extensions/planning-and-task-breakdown.md +10 -8
- package/extensions/planning-method.test.ts +15 -2
- package/extensions/planning-method.ts +4 -2
- package/extensions/policy.test.ts +11 -0
- package/extensions/prompts.test.ts +54 -9
- package/extensions/prompts.ts +100 -32
- package/extensions/recovery.test.ts +15 -0
- package/extensions/review.test.ts +19 -0
- package/extensions/review.ts +53 -0
- package/extensions/state.test.ts +25 -0
- package/extensions/state.ts +198 -0
- package/extensions/task-ui.ts +48 -0
- package/extensions/tool-policy.ts +4 -0
- package/extensions/tools.test.ts +10 -0
- package/extensions/tools.ts +16 -0
- package/extensions/types.ts +53 -5
- package/extensions/validation.test.ts +38 -0
- package/extensions/workflow-policy.test.ts +76 -0
- package/extensions/workflow-policy.ts +117 -0
- package/extensions/workflow-store.test.ts +48 -0
- package/extensions/workflow-store.ts +165 -0
- package/package.json +29 -5
|
@@ -1,35 +1,33 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import { buildHandoffCommand, parseBuildOptions } from "./build-session.ts";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
assert.equal(parseBuildPlacement(" --here extra"), "here");
|
|
12
|
-
});
|
|
5
|
+
function options(input: string) {
|
|
6
|
+
const result = parseBuildOptions(input);
|
|
7
|
+
assert.equal(result.ok, true);
|
|
8
|
+
if (!result.ok) throw new Error("Expected build options to parse");
|
|
9
|
+
return result.options;
|
|
10
|
+
}
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
assert.
|
|
17
|
-
assert.equal(parseBuildPlacement("--new"), "new");
|
|
12
|
+
describe("parseBuildOptions", () => {
|
|
13
|
+
it("executes one task here by default", () => {
|
|
14
|
+
assert.deepEqual(options(""), { scope: "one", placement: "here", approval: false, continuation: false });
|
|
18
15
|
});
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
assert.equal(parseGoalMode(""), "all-here");
|
|
24
|
-
assert.equal(parseGoalMode(" "), "all-here");
|
|
25
|
-
assert.equal(parseGoalMode("unknown"), "all-here");
|
|
16
|
+
it("parses placement and all-task execution", () => {
|
|
17
|
+
assert.equal(options("new").placement, "new");
|
|
18
|
+
assert.equal(options("--fresh").placement, "fresh");
|
|
19
|
+
assert.deepEqual(options("all new --approval"), { scope: "all", placement: "new", approval: true, continuation: false });
|
|
26
20
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
assert.equal(
|
|
30
|
-
assert.equal(
|
|
31
|
-
assert.equal(
|
|
32
|
-
assert.equal(
|
|
33
|
-
|
|
21
|
+
it("rejects unknown, duplicate, and conflicting options", () => {
|
|
22
|
+
assert.deepEqual(parseBuildOptions("banana"), { ok: false, error: "Unknown build option: banana" });
|
|
23
|
+
assert.equal(parseBuildOptions("new fresh").ok, false);
|
|
24
|
+
assert.equal(parseBuildOptions("all --all").ok, false);
|
|
25
|
+
assert.equal(parseBuildOptions("continue").ok, false);
|
|
26
|
+
assert.equal(parseBuildOptions("--approval").ok, false);
|
|
27
|
+
});
|
|
28
|
+
it("builds public handoff commands", () => {
|
|
29
|
+
assert.equal(buildHandoffCommand(options("new")), "/build");
|
|
30
|
+
assert.equal(buildHandoffCommand(options("all new --approval")), "/build");
|
|
31
|
+
assert.equal(buildHandoffCommand(options("all fresh --approval")), "/build all --approval");
|
|
34
32
|
});
|
|
35
33
|
});
|
|
@@ -1,15 +1,42 @@
|
|
|
1
|
-
export type BuildPlacement = "here" | "new";
|
|
2
|
-
export type
|
|
1
|
+
export type BuildPlacement = "here" | "new" | "fresh";
|
|
2
|
+
export type BuildScope = "one" | "all";
|
|
3
3
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
export interface BuildOptions {
|
|
5
|
+
scope: BuildScope;
|
|
6
|
+
placement: BuildPlacement;
|
|
7
|
+
approval: boolean;
|
|
8
|
+
continuation: boolean;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
export type BuildParseResult = { ok: true; options: BuildOptions } | { ok: false; error: string };
|
|
12
|
+
|
|
13
|
+
const ALLOWED = new Set(["all", "--all", "new", "--new", "fresh", "--fresh", "approval", "--approval"]);
|
|
14
|
+
|
|
15
|
+
export function parseBuildOptions(args: string): BuildParseResult {
|
|
16
|
+
const tokens = args.trim().split(/\s+/).map((token) => token.toLowerCase()).filter(Boolean);
|
|
17
|
+
const unknown = tokens.find((token) => !ALLOWED.has(token));
|
|
18
|
+
if (unknown) return { ok: false, error: `Unknown build option: ${unknown}` };
|
|
19
|
+
const hasNew = tokens.includes("new") || tokens.includes("--new");
|
|
20
|
+
const hasFresh = tokens.includes("fresh") || tokens.includes("--fresh");
|
|
21
|
+
if (hasNew && hasFresh) return { ok: false, error: "new and fresh cannot be combined." };
|
|
22
|
+
const unique = new Set(tokens.map((token) => token.replace(/^--/, "")));
|
|
23
|
+
if (unique.size !== tokens.length) return { ok: false, error: "Duplicate build options are not allowed." };
|
|
24
|
+
const scope: BuildScope = tokens.includes("all") || tokens.includes("--all") ? "all" : "one";
|
|
25
|
+
const approval = tokens.includes("approval") || tokens.includes("--approval");
|
|
26
|
+
if (approval && scope !== "all") return { ok: false, error: "approval requires all-task execution." };
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
options: {
|
|
30
|
+
scope,
|
|
31
|
+
placement: hasFresh ? "fresh" : hasNew ? "new" : "here",
|
|
32
|
+
approval,
|
|
33
|
+
continuation: false,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildHandoffCommand(options: Pick<BuildOptions, "scope" | "placement" | "approval">): string {
|
|
39
|
+
if (options.scope === "one" || options.placement === "new") return "/build";
|
|
40
|
+
const approvalFlag = options.approval ? " --approval" : "";
|
|
41
|
+
return `/build all${approvalFlag}`;
|
|
15
42
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
|
|
5
|
+
describe("public command surface", () => {
|
|
6
|
+
it("registers only plan, build, and tasks", async () => {
|
|
7
|
+
const source = await readFile(new URL("./index.ts", import.meta.url), "utf8");
|
|
8
|
+
const commands = [...source.matchAll(/registerCommand\("([^"]+)"/g)].map((match) => match[1]);
|
|
9
|
+
assert.deepEqual(commands, ["plan", "build", "tasks"]);
|
|
10
|
+
});
|
|
11
|
+
});
|
package/extensions/config.ts
CHANGED
|
@@ -1,49 +1,21 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { globalConfigPath, projectConfigPath } from "./paths.ts";
|
|
4
|
-
import { DEFAULT_CONFIG, type PlanTaskConfig } from "./types.ts";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
|
|
4
|
+
import { DEFAULT_CONFIG, type ExecutionMode, type PlanTaskConfig } from "./types.ts";
|
|
5
|
+
import { filterPlanTools, isAllowedPlanTool, PLAN_TOOL_ALLOWLIST } from "./tool-policy.ts";
|
|
6
|
+
export { filterPlanTools, isAllowedPlanTool, PLAN_TOOL_ALLOWLIST };
|
|
7
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
10
8
|
function parseConfig(raw: string): Partial<PlanTaskConfig> {
|
|
11
|
-
const parsed: unknown = JSON.parse(raw);
|
|
12
|
-
if (!isRecord(parsed)) return {};
|
|
9
|
+
const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed)) return {};
|
|
13
10
|
const next: Partial<PlanTaskConfig> = {};
|
|
14
|
-
if (Array.isArray(parsed.planTools))
|
|
15
|
-
|
|
16
|
-
(item): item is string => typeof item === "string" && item.trim().length > 0,
|
|
17
|
-
);
|
|
18
|
-
}
|
|
11
|
+
if (Array.isArray(parsed.planTools)) next.planTools = filterPlanTools(parsed.planTools.filter((x): x is string => typeof x === "string" && x.trim().length > 0));
|
|
12
|
+
if (parsed.executionMode === "automatic" || parsed.executionMode === "external") next.executionMode = parsed.executionMode as ExecutionMode;
|
|
19
13
|
return next;
|
|
20
14
|
}
|
|
21
|
-
|
|
22
|
-
async function
|
|
23
|
-
try {
|
|
24
|
-
return parseConfig(await readFile(path, "utf8"));
|
|
25
|
-
} catch {
|
|
26
|
-
return {};
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function ensureDefaultGlobalConfig(): Promise<void> {
|
|
31
|
-
const path = globalConfigPath();
|
|
32
|
-
try {
|
|
33
|
-
await readFile(path, "utf8");
|
|
34
|
-
} catch {
|
|
35
|
-
await mkdir(dirname(path), { recursive: true });
|
|
36
|
-
await writeFile(path, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf8");
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
15
|
+
async function readConfigFile(path: string): Promise<Partial<PlanTaskConfig>> { try { return parseConfig(await readFile(path, "utf8")); } catch { return {}; } }
|
|
16
|
+
export async function ensureDefaultGlobalConfig(): Promise<void> { const path = globalConfigPath(); try { await readFile(path, "utf8"); } catch { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf8"); } }
|
|
40
17
|
export async function loadConfig(cwd: string): Promise<PlanTaskConfig> {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
return {
|
|
44
|
-
planTools:
|
|
45
|
-
projectConfig.planTools ??
|
|
46
|
-
globalConfig.planTools ??
|
|
47
|
-
DEFAULT_CONFIG.planTools,
|
|
48
|
-
};
|
|
18
|
+
const global = await readConfigFile(globalConfigPath()), project = await readConfigFile(projectConfigPath(cwd));
|
|
19
|
+
const tools = project.planTools ?? global.planTools; const filtered = tools ? filterPlanTools(tools) : [];
|
|
20
|
+
return { ...DEFAULT_CONFIG, ...global, ...project, planTools: filtered.length ? filtered : [...DEFAULT_CONFIG.planTools] };
|
|
49
21
|
}
|
package/extensions/files.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import { markTaskDoneInMarkdown, parseTaskMarkdown } from "./parse.ts";
|
|
3
|
+
import { hasPendingTasks, markTaskDoneInMarkdown, markTaskPendingInMarkdown, parseTaskMarkdown } from "./parse.ts";
|
|
4
4
|
|
|
5
5
|
const SAMPLE = `# Tasks
|
|
6
6
|
|
|
@@ -45,6 +45,22 @@ describe("parseTaskMarkdown", () => {
|
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
|
|
49
|
+
describe("task completion boundaries", () => {
|
|
50
|
+
it("checks only the top-level task checklist", () => {
|
|
51
|
+
const next = markTaskDoneInMarkdown(SAMPLE, 1);
|
|
52
|
+
assert.match(next, /- \[x\] 1\. Add login API/);
|
|
53
|
+
assert.match(next, /- \[ \] Returns 200/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not treat acceptance criteria as pending top-level tasks", () => {
|
|
57
|
+
const complete = SAMPLE
|
|
58
|
+
.replace("- [ ] 1. Add login API", "- [x] 1. Add login API")
|
|
59
|
+
.replace("- [ ] 2. Add login UI", "- [x] 2. Add login UI");
|
|
60
|
+
assert.equal(hasPendingTasks(complete), false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
48
64
|
describe("markTaskDoneInMarkdown", () => {
|
|
49
65
|
it("checks the matching numbered item only", () => {
|
|
50
66
|
const next = markTaskDoneInMarkdown(SAMPLE, 2);
|
|
@@ -56,4 +72,8 @@ describe("markTaskDoneInMarkdown", () => {
|
|
|
56
72
|
it("is a no-op when the item is already done", () => {
|
|
57
73
|
assert.equal(markTaskDoneInMarkdown(SAMPLE, 3), SAMPLE);
|
|
58
74
|
});
|
|
75
|
+
it("reopens a completed numbered item", () => {
|
|
76
|
+
const done = markTaskDoneInMarkdown(SAMPLE, 1);
|
|
77
|
+
assert.match(markTaskPendingInMarkdown(done, 1), /- \[ \] 1\. Add login API/);
|
|
78
|
+
});
|
|
59
79
|
});
|
package/extensions/files.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { mkdir, readFile
|
|
2
|
-
import { planDir,
|
|
3
|
-
import {
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { planDir, taskFilePath } from "./paths.ts";
|
|
3
|
+
import { parseTaskMarkdown } from "./parse.ts";
|
|
4
4
|
import type { TaskFile, TaskItem } from "./types.ts";
|
|
5
5
|
|
|
6
|
-
export { markTaskDoneInMarkdown, parseTaskMarkdown } from "./parse.ts";
|
|
6
|
+
export { markTaskDoneInMarkdown, markTaskPendingInMarkdown, markTasksPendingFromMarkdown, parseTaskMarkdown, hasPendingTasks } from "./parse.ts";
|
|
7
7
|
|
|
8
8
|
export async function ensurePlanDir(cwd: string): Promise<void> {
|
|
9
9
|
await mkdir(planDir(cwd), { recursive: true });
|
|
@@ -12,36 +12,20 @@ export async function ensurePlanDir(cwd: string): Promise<void> {
|
|
|
12
12
|
export async function readOptionalFile(path: string): Promise<string | undefined> {
|
|
13
13
|
try {
|
|
14
14
|
return await readFile(path, "utf8");
|
|
15
|
-
} catch {
|
|
16
|
-
return undefined;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
17
|
+
throw error;
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export async function loadTaskFile(cwd: string): Promise<TaskFile | undefined> {
|
|
21
22
|
const raw = await readOptionalFile(taskFilePath(cwd));
|
|
22
23
|
if (raw === undefined) return undefined;
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const current = await loadTaskFile(cwd);
|
|
28
|
-
if (!current) return undefined;
|
|
29
|
-
const nextRaw = markTaskDoneInMarkdown(current.raw, id);
|
|
30
|
-
if (nextRaw !== current.raw) {
|
|
31
|
-
await writeFile(taskFilePath(cwd), nextRaw, "utf8");
|
|
32
|
-
}
|
|
33
|
-
const planRaw = await readOptionalFile(planFilePath(cwd));
|
|
34
|
-
if (planRaw !== undefined) {
|
|
35
|
-
const nextPlan = markTaskDoneInMarkdown(planRaw, id);
|
|
36
|
-
if (nextPlan !== planRaw) {
|
|
37
|
-
await writeFile(planFilePath(cwd), nextPlan, "utf8");
|
|
38
|
-
}
|
|
24
|
+
try {
|
|
25
|
+
return { raw, tasks: parseTaskMarkdown(raw) };
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new Error(`Invalid ${taskFilePath(cwd)}: ${String(error)}`, { cause: error });
|
|
39
28
|
}
|
|
40
|
-
return { raw: nextRaw, tasks: parseTaskMarkdown(nextRaw) };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function nextPendingTask(tasks: TaskItem[]): TaskItem | undefined {
|
|
44
|
-
return tasks.find((task) => !task.done);
|
|
45
29
|
}
|
|
46
30
|
|
|
47
31
|
export function formatProgress(tasks: TaskItem[]): string {
|
|
@@ -77,6 +77,7 @@ describe("nextInjection", () => {
|
|
|
77
77
|
assert.equal(nextInjection("build", framed, 1, "1:1:0"), undefined);
|
|
78
78
|
assert.equal(nextInjection("build", framed, 1, "1:1:1"), "build-status");
|
|
79
79
|
assert.equal(nextInjection("build", framed, 2, "2:1:1"), "build-framing");
|
|
80
|
+
assert.equal(nextInjection("build", framed, 1, "1:1:0", "changed-plan"), "build-framing");
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
it("injects nothing while idle", () => {
|
package/extensions/framing.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface FramingState {
|
|
|
15
15
|
planFramingDelivered: boolean;
|
|
16
16
|
framedTaskId?: number;
|
|
17
17
|
lastStatusKey?: string;
|
|
18
|
+
planKey?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const INITIAL_FRAMING_STATE: FramingState = {
|
|
@@ -55,10 +56,11 @@ export function nextInjection(
|
|
|
55
56
|
state: FramingState,
|
|
56
57
|
currentTaskId?: number,
|
|
57
58
|
statusKey?: string,
|
|
59
|
+
planKey?: string,
|
|
58
60
|
): InjectionKind | undefined {
|
|
59
61
|
if (mode === "plan") return state.planFramingDelivered ? undefined : "plan-framing";
|
|
60
62
|
if (mode !== "build" || currentTaskId === undefined) return undefined;
|
|
61
|
-
if (state.framedTaskId !== currentTaskId) return "build-framing";
|
|
63
|
+
if (state.framedTaskId !== currentTaskId || state.planKey !== planKey) return "build-framing";
|
|
62
64
|
if (statusKey !== undefined && statusKey !== state.lastStatusKey) return "build-status";
|
|
63
65
|
return undefined;
|
|
64
66
|
}
|
|
@@ -68,12 +70,13 @@ export function rememberInjection(
|
|
|
68
70
|
kind: InjectionKind,
|
|
69
71
|
taskId?: number,
|
|
70
72
|
statusKey?: string,
|
|
73
|
+
planKey?: string,
|
|
71
74
|
): FramingState {
|
|
72
75
|
if (kind === "plan-framing") {
|
|
73
76
|
return { ...state, planFramingDelivered: true };
|
|
74
77
|
}
|
|
75
78
|
if (kind === "build-framing") {
|
|
76
|
-
return { ...state, framedTaskId: taskId, lastStatusKey: statusKey };
|
|
79
|
+
return { ...state, framedTaskId: taskId, lastStatusKey: statusKey, planKey };
|
|
77
80
|
}
|
|
78
81
|
return { ...state, lastStatusKey: statusKey };
|
|
79
82
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { listPlanSnapshots } from "./history.ts";
|
|
7
|
+
import { historyDir } from "./paths.ts";
|
|
8
|
+
|
|
9
|
+
async function tempProject<T>(run: (cwd: string) => Promise<T>): Promise<T> {
|
|
10
|
+
const cwd = await mkdtemp(join(tmpdir(), "pi-plan-task-history-"));
|
|
11
|
+
try { return await run(cwd); } finally { await rm(cwd, { recursive: true, force: true }); }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("plan history", () => {
|
|
15
|
+
it("sorts numerically and skips damaged snapshots", async () => tempProject(async (cwd) => {
|
|
16
|
+
const dir = historyDir(cwd);
|
|
17
|
+
await mkdir(dir, { recursive: true });
|
|
18
|
+
await writeFile(join(dir, "snapshot-1000.json"), JSON.stringify({ version: 1000, createdAt: "later", plan: "p1000", task: "t" }), "utf8");
|
|
19
|
+
await writeFile(join(dir, "snapshot-999.json"), JSON.stringify({ version: 999, createdAt: "earlier", plan: "p999", task: "t" }), "utf8");
|
|
20
|
+
await writeFile(join(dir, "snapshot-1001.json"), "not-json", "utf8");
|
|
21
|
+
const snapshots = await listPlanSnapshots(cwd);
|
|
22
|
+
assert.deepEqual(snapshots.map((item) => item.version), [999, 1000]);
|
|
23
|
+
}));
|
|
24
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { historyDir } from "./paths.ts";
|
|
4
|
+
|
|
5
|
+
export interface PlanSnapshot { version: number; createdAt: string; plan: string; task: string; }
|
|
6
|
+
const SNAPSHOT_NAME = /^snapshot-(\d+)\.json$/;
|
|
7
|
+
|
|
8
|
+
export async function savePlanSnapshot(cwd: string, plan: string, task: string): Promise<PlanSnapshot> {
|
|
9
|
+
const dir = historyDir(cwd);
|
|
10
|
+
await mkdir(dir, { recursive: true });
|
|
11
|
+
const versions = (await readdir(dir)).flatMap((name) => { const match = name.match(SNAPSHOT_NAME); return match ? [Number(match[1])] : []; });
|
|
12
|
+
const version = Math.max(0, ...versions) + 1;
|
|
13
|
+
const snapshot: PlanSnapshot = { version, createdAt: new Date().toISOString(), plan, task };
|
|
14
|
+
const path = join(dir, `snapshot-${String(version).padStart(3, "0")}.json`);
|
|
15
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
16
|
+
await writeFile(temporary, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
|
|
17
|
+
await rename(temporary, path);
|
|
18
|
+
return snapshot;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function listPlanSnapshots(cwd: string): Promise<PlanSnapshot[]> {
|
|
22
|
+
const dir = historyDir(cwd);
|
|
23
|
+
try {
|
|
24
|
+
const files = (await readdir(dir))
|
|
25
|
+
.flatMap((name) => { const match = name.match(SNAPSHOT_NAME); return match ? [{ name, version: Number(match[1]) }] : []; })
|
|
26
|
+
.sort((a, b) => a.version - b.version);
|
|
27
|
+
const snapshots: PlanSnapshot[] = [];
|
|
28
|
+
const errors: string[] = [];
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
try {
|
|
31
|
+
const value = JSON.parse(await readFile(join(dir, file.name), "utf8")) as PlanSnapshot;
|
|
32
|
+
if (value.version !== file.version || typeof value.createdAt !== "string" || typeof value.plan !== "string" || typeof value.task !== "string") throw new Error("invalid snapshot shape");
|
|
33
|
+
snapshots.push(value);
|
|
34
|
+
} catch (error) { errors.push(`${file.name}: ${String(error)}`); }
|
|
35
|
+
}
|
|
36
|
+
if (errors.length) process.emitWarning(`Skipped invalid plan snapshots: ${errors.join("; ")}`);
|
|
37
|
+
return snapshots;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|