pi-plan-task 1.1.0 → 4.0.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 (38) hide show
  1. package/README.md +535 -68
  2. package/extensions/build-session.test.ts +25 -27
  3. package/extensions/build-session.ts +26 -11
  4. package/extensions/command-surface.test.ts +15 -0
  5. package/extensions/config.ts +12 -40
  6. package/extensions/files.test.ts +21 -1
  7. package/extensions/files.ts +11 -27
  8. package/extensions/framing.test.ts +1 -0
  9. package/extensions/framing.ts +5 -2
  10. package/extensions/history.test.ts +24 -0
  11. package/extensions/history.ts +42 -0
  12. package/extensions/index.ts +523 -245
  13. package/extensions/integration.test.ts +231 -0
  14. package/extensions/migration.test.ts +34 -0
  15. package/extensions/parse.ts +80 -4
  16. package/extensions/paths.ts +26 -32
  17. package/extensions/planning-and-task-breakdown.md +10 -8
  18. package/extensions/planning-method.test.ts +15 -2
  19. package/extensions/planning-method.ts +4 -2
  20. package/extensions/policy.test.ts +11 -0
  21. package/extensions/prompts.test.ts +54 -9
  22. package/extensions/prompts.ts +100 -32
  23. package/extensions/recovery.test.ts +15 -0
  24. package/extensions/review.test.ts +19 -0
  25. package/extensions/review.ts +53 -0
  26. package/extensions/state.test.ts +25 -0
  27. package/extensions/state.ts +198 -0
  28. package/extensions/task-ui.ts +59 -0
  29. package/extensions/tool-policy.ts +4 -0
  30. package/extensions/tools.test.ts +10 -0
  31. package/extensions/tools.ts +16 -0
  32. package/extensions/types.ts +53 -5
  33. package/extensions/validation.test.ts +38 -0
  34. package/extensions/workflow-policy.test.ts +76 -0
  35. package/extensions/workflow-policy.ts +117 -0
  36. package/extensions/workflow-store.test.ts +48 -0
  37. package/extensions/workflow-store.ts +165 -0
  38. 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 { parseBuildPlacement, parseGoalMode } from "./build-session.ts";
3
+ import { parseBuildOptions } from "./build-session.ts";
4
4
 
5
- describe("parseBuildPlacement", () => {
6
- it("stays in this session by default", () => {
7
- assert.equal(parseBuildPlacement(""), "here");
8
- assert.equal(parseBuildPlacement(" "), "here");
9
- assert.equal(parseBuildPlacement("unknown"), "here");
10
- assert.equal(parseBuildPlacement("here"), "here");
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
- it("accepts new aliases", () => {
15
- assert.equal(parseBuildPlacement("new"), "new");
16
- assert.equal(parseBuildPlacement("NEW"), "new");
17
- assert.equal(parseBuildPlacement("--new"), "new");
12
+ describe("parseBuildOptions", () => {
13
+ it("executes one task here by default", () => {
14
+ assert.deepEqual(options(""), { scope: "one" });
18
15
  });
19
- });
20
-
21
- describe("parseGoalMode", () => {
22
- it("runs remaining tasks in this session by default", () => {
23
- assert.equal(parseGoalMode(""), "all-here");
24
- assert.equal(parseGoalMode(" "), "all-here");
25
- assert.equal(parseGoalMode("unknown"), "all-here");
16
+ it("parses all-task execution", () => {
17
+ assert.deepEqual(options("all"), { scope: "all" });
18
+ assert.deepEqual(options("--all"), { scope: "all" });
26
19
  });
27
-
28
- it("accepts new and continue aliases", () => {
29
- assert.equal(parseGoalMode("new"), "all-new");
30
- assert.equal(parseGoalMode(" --new extra"), "all-new");
31
- assert.equal(parseGoalMode("NEW"), "all-new");
32
- assert.equal(parseGoalMode("continue"), "chain-here");
33
- assert.equal(parseGoalMode("--continue"), "chain-here");
20
+ it("rejects removed placement and approval flags", () => {
21
+ for (const input of ["new", "fresh", "all new", "--approval"]) {
22
+ const result = parseBuildOptions(input);
23
+ assert.equal(result.ok, false);
24
+ if (result.ok) throw new Error("Expected removed build option to fail");
25
+ assert.match(result.error, /^Removed\./);
26
+ }
27
+ });
28
+ it("rejects unknown and duplicate options", () => {
29
+ assert.deepEqual(parseBuildOptions("banana"), { ok: false, error: "Unknown build option: banana" });
30
+ assert.equal(parseBuildOptions("all --all").ok, false);
31
+ assert.equal(parseBuildOptions("continue").ok, false);
34
32
  });
35
33
  });
@@ -1,15 +1,30 @@
1
- export type BuildPlacement = "here" | "new";
2
- export type GoalMode = "all-here" | "all-new" | "chain-here";
1
+ export type BuildScope = "one" | "all";
3
2
 
4
- export function parseBuildPlacement(args: string): BuildPlacement {
5
- const token = args.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
6
- if (token === "new" || token === "--new") return "new";
7
- return "here";
3
+ export interface BuildOptions {
4
+ scope: BuildScope;
8
5
  }
9
6
 
10
- export function parseGoalMode(args: string): GoalMode {
11
- const token = args.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
12
- if (token === "new" || token === "--new") return "all-new";
13
- if (token === "continue" || token === "--continue") return "chain-here";
14
- return "all-here";
7
+ export type BuildParseResult = { ok: true; options: BuildOptions } | { ok: false; error: string };
8
+
9
+ const REMOVED = new Map<string, string>([
10
+ ["new", "Removed. After /plan approve or a finished task, choose a new session. /build runs the next task here."],
11
+ ["--new", "Removed. After /plan approve or a finished task, choose a new session. /build runs the next task here."],
12
+ ["fresh", "Removed. After /plan approve, choose fresh to start a clean session."],
13
+ ["--fresh", "Removed. After /plan approve, choose fresh to start a clean session."],
14
+ ["approval", "Removed. Use /build for one task or /build all for the rest."],
15
+ ["--approval", "Removed. Use /build for one task or /build all for the rest."],
16
+ ]);
17
+
18
+ export function parseBuildOptions(args: string): BuildParseResult {
19
+ const tokens = args.trim().split(/\s+/).map((token) => token.toLowerCase()).filter(Boolean);
20
+ if (tokens.length === 0) return { ok: true, options: { scope: "one" } };
21
+ for (const token of tokens) {
22
+ const removed = REMOVED.get(token);
23
+ if (removed) return { ok: false, error: removed };
24
+ }
25
+ const normalized = tokens.map((token) => token.replace(/^--/, ""));
26
+ const unknown = normalized.find((token) => token !== "all");
27
+ if (unknown) return { ok: false, error: `Unknown build option: ${tokens[normalized.indexOf(unknown)]}` };
28
+ if (new Set(normalized).size !== tokens.length) return { ok: false, error: "Duplicate build options are not allowed." };
29
+ return { ok: true, options: { scope: "all" } };
15
30
  }
@@ -0,0 +1,15 @@
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
+ assert.match(source, /planSubcommands = \["approve", "reject", "request"\]/);
11
+ assert.match(source, /value: "all"/);
12
+ assert.match(source, /value: "rework"/);
13
+ assert.doesNotMatch(source, /planSubcommands = \[[^\]]*review/);
14
+ });
15
+ });
@@ -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
- function isRecord(value: unknown): value is Record<string, unknown> {
7
- return typeof value === "object" && value !== null && !Array.isArray(value);
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
- next.planTools = parsed.planTools.filter(
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 readConfigFile(path: string): Promise<Partial<PlanTaskConfig>> {
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 globalConfig = await readConfigFile(globalConfigPath());
42
- const projectConfig = await readConfigFile(projectConfigPath(cwd));
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
  }
@@ -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
  });
@@ -1,9 +1,9 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { planDir, planFilePath, taskFilePath } from "./paths.ts";
3
- import { markTaskDoneInMarkdown, parseTaskMarkdown } from "./parse.ts";
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
- return { raw, tasks: parseTaskMarkdown(raw) };
24
- }
25
-
26
- export async function markTaskComplete(cwd: string, id: number): Promise<TaskFile | undefined> {
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", () => {
@@ -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
+ }