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.
- package/README.md +535 -68
- package/extensions/build-session.test.ts +25 -27
- package/extensions/build-session.ts +26 -11
- package/extensions/command-surface.test.ts +15 -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 +523 -245
- package/extensions/integration.test.ts +231 -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 +59 -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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { formatProgress } from "./files.ts";
|
|
3
|
+
import type { PlanState, TaskItem } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export function formatTaskList(tasks: TaskItem[]): string {
|
|
6
|
+
if (tasks.length === 0) return "No tasks found in .plan_task/task.md.";
|
|
7
|
+
const lines = tasks.map((task) => `${task.done ? "[x]" : "[ ]"} ${task.id}. ${task.title}`);
|
|
8
|
+
return `Progress ${formatProgress(tasks)}\n${lines.join("\n")}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatWorkflowStatus(state: PlanState | undefined, tasks: TaskItem[]): string {
|
|
12
|
+
return [
|
|
13
|
+
`Status: ${state?.status ?? "idle"}`,
|
|
14
|
+
`Approved hash: ${state?.approvedStructureHash ?? "none"}`,
|
|
15
|
+
`Work id: ${state?.workId ?? "none"}`,
|
|
16
|
+
`Current task: ${state?.currentTaskId ?? "none"}`,
|
|
17
|
+
formatTaskList(tasks),
|
|
18
|
+
].join("\n");
|
|
19
|
+
}
|
|
20
|
+
export class TaskListComponent {
|
|
21
|
+
private readonly tasks: TaskItem[];
|
|
22
|
+
private readonly status: string;
|
|
23
|
+
private readonly theme: { fg: (name: "accent" | "muted" | "dim" | "success" | "text", text: string) => string };
|
|
24
|
+
private readonly onClose: () => void;
|
|
25
|
+
private cachedWidth?: number;
|
|
26
|
+
private cachedLines?: string[];
|
|
27
|
+
|
|
28
|
+
constructor(tasks: TaskItem[], theme: TaskListComponent["theme"], onClose: () => void, status = "idle") {
|
|
29
|
+
this.tasks = tasks;
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.theme = theme;
|
|
32
|
+
this.onClose = onClose;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
handleInput(data: string): void {
|
|
36
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.onClose();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
render(width: number): string[] {
|
|
40
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
41
|
+
const th = this.theme;
|
|
42
|
+
const lines = ["", truncateToWidth(` ${th.fg("accent", "Plan tasks")} ${th.fg("muted", `${formatProgress(this.tasks)} ${this.status}`)}`, width), ""];
|
|
43
|
+
if (this.tasks.length === 0) lines.push(truncateToWidth(` ${th.fg("dim", "No tasks found. Run /plan first.")}`, width));
|
|
44
|
+
else for (const task of this.tasks) {
|
|
45
|
+
const check = task.done ? th.fg("success", "x") : th.fg("dim", " ");
|
|
46
|
+
const title = task.done ? th.fg("dim", task.title) : th.fg("text", task.title);
|
|
47
|
+
lines.push(truncateToWidth(` [${check}] ${th.fg("accent", String(task.id))}. ${title}`, width));
|
|
48
|
+
}
|
|
49
|
+
lines.push("", truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width), "");
|
|
50
|
+
this.cachedWidth = width;
|
|
51
|
+
this.cachedLines = lines;
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
invalidate(): void {
|
|
56
|
+
this.cachedWidth = undefined;
|
|
57
|
+
this.cachedLines = undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const PLAN_TOOL_NAMES = ["read", "grep", "find", "ls", "rg", "plan_task", "ask_user_question"] as const;
|
|
2
|
+
export const PLAN_TOOL_ALLOWLIST = new Set<string>(PLAN_TOOL_NAMES);
|
|
3
|
+
export function filterPlanTools(names: readonly string[]): string[] { return names.filter((name) => PLAN_TOOL_ALLOWLIST.has(name)); }
|
|
4
|
+
export function isAllowedPlanTool(name: string): boolean { return PLAN_TOOL_ALLOWLIST.has(name); }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { addTools, removeAddedTools } from "./tools.ts";
|
|
4
|
+
describe("tool deltas", () => {
|
|
5
|
+
it("only removes tools added by this extension", () => {
|
|
6
|
+
const delta = addTools(["other", "read"], ["read", "write"]);
|
|
7
|
+
assert.deepEqual(delta.added, ["write"]);
|
|
8
|
+
assert.deepEqual(removeAddedTools(["other", "read", "write", "new-runtime"], delta), ["other", "read", "new-runtime"]);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ToolDelta {
|
|
2
|
+
before: string[];
|
|
3
|
+
added: string[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function addTools(current: readonly string[], required: readonly string[]): ToolDelta {
|
|
7
|
+
const before = [...current];
|
|
8
|
+
const existing = new Set(before);
|
|
9
|
+
const added = required.filter((name) => !existing.has(name));
|
|
10
|
+
return { before, added };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function removeAddedTools(current: readonly string[], delta: ToolDelta): string[] {
|
|
14
|
+
const added = new Set(delta.added);
|
|
15
|
+
return current.filter((name) => !added.has(name));
|
|
16
|
+
}
|
package/extensions/types.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
export type PlanStatus = "idle" | "planning" | "ready" | "approved" | "executing" | "blocked" | "completed";
|
|
2
|
+
export type ActiveTaskStatus = "pending" | "implementation-complete";
|
|
3
|
+
export type TaskStatus = ActiveTaskStatus | "verified" | "blocked";
|
|
4
|
+
export type ExecutionMode = "automatic" | "external";
|
|
5
|
+
|
|
1
6
|
export interface PlanTaskConfig {
|
|
2
7
|
planTools: string[];
|
|
8
|
+
executionMode: ExecutionMode;
|
|
3
9
|
}
|
|
4
10
|
|
|
5
11
|
export interface TaskItem {
|
|
@@ -9,18 +15,60 @@ export interface TaskItem {
|
|
|
9
15
|
body: string;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
|
-
export interface TaskFile {
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
export interface TaskFile { raw: string; tasks: TaskItem[]; }
|
|
19
|
+
|
|
20
|
+
export interface TaskRuntimeState {
|
|
21
|
+
status: TaskStatus;
|
|
22
|
+
reason?: string;
|
|
23
|
+
blockedFrom?: ActiveTaskStatus;
|
|
24
|
+
startedAt?: string;
|
|
25
|
+
completedAt?: string;
|
|
26
|
+
verifiedAt?: string;
|
|
27
|
+
verification?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PlanState {
|
|
31
|
+
version: 2;
|
|
32
|
+
status: PlanStatus;
|
|
33
|
+
currentTaskId?: number;
|
|
34
|
+
workId?: string;
|
|
35
|
+
planningRequest?: string;
|
|
36
|
+
executionMode: ExecutionMode;
|
|
37
|
+
continueMode?: "single" | "all-here" | "all-new";
|
|
38
|
+
approvalEachTask?: boolean;
|
|
39
|
+
planningBaselineHash?: string;
|
|
40
|
+
draftStructureHash?: string;
|
|
41
|
+
approvedStructureHash?: string;
|
|
42
|
+
sessionFile?: string;
|
|
43
|
+
failureReason?: string;
|
|
44
|
+
updatedAt: string;
|
|
45
|
+
tasks?: Record<string, TaskRuntimeState>;
|
|
15
46
|
}
|
|
16
47
|
|
|
17
|
-
export
|
|
48
|
+
export interface LegacyPlanState {
|
|
49
|
+
version?: 1;
|
|
50
|
+
status?: PlanStatus;
|
|
51
|
+
currentTaskId?: number;
|
|
52
|
+
workId?: string;
|
|
53
|
+
executionMode?: ExecutionMode;
|
|
54
|
+
continueMode?: "single" | "all-here" | "all-new";
|
|
55
|
+
approvalEachTask?: boolean;
|
|
56
|
+
planHash?: string;
|
|
57
|
+
sessionFile?: string;
|
|
58
|
+
failureReason?: string;
|
|
59
|
+
updatedAt?: string;
|
|
60
|
+
tasks?: Record<string, Partial<TaskRuntimeState> & { status?: string }>;
|
|
61
|
+
}
|
|
18
62
|
|
|
63
|
+
export const DEFAULT_PLAN_TOOLS = ["read", "grep", "find", "ls", "rg"] as const;
|
|
19
64
|
export const DEFAULT_CONFIG: PlanTaskConfig = {
|
|
20
65
|
planTools: [...DEFAULT_PLAN_TOOLS],
|
|
66
|
+
executionMode: "automatic",
|
|
21
67
|
};
|
|
22
|
-
|
|
23
68
|
export const PLAN_DIR_NAME = ".plan_task";
|
|
24
69
|
export const PLAN_FILE_NAME = "plan.md";
|
|
25
70
|
export const TASK_FILE_NAME = "task.md";
|
|
71
|
+
export const STATE_FILE_NAME = "state.json";
|
|
72
|
+
export const HISTORY_DIR_NAME = "history";
|
|
73
|
+
export const DRAFT_DIR_NAME = "draft";
|
|
26
74
|
export const CONFIG_FILE_NAME = "plan_task.json";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseTaskMarkdown } from "./parse.ts";
|
|
4
|
+
import { validatePlan } from "./review.ts";
|
|
5
|
+
|
|
6
|
+
const VALID_TASK = `# Tasks
|
|
7
|
+
|
|
8
|
+
- [ ] 1. A
|
|
9
|
+
|
|
10
|
+
## Task 1: A
|
|
11
|
+
|
|
12
|
+
**Description:** Do A.
|
|
13
|
+
**Acceptance criteria:** A works.
|
|
14
|
+
**Verification:** Run tests.
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
describe("plan validation edge cases", () => {
|
|
18
|
+
it("rejects missing required sections", () => {
|
|
19
|
+
const task = "# Tasks\n\n- [ ] 1. A\n\n## Task 1: A\n";
|
|
20
|
+
const result = validatePlan("# Plan", task, parseTaskMarkdown(task));
|
|
21
|
+
assert.equal(result.valid, false);
|
|
22
|
+
assert.equal(result.errors.length, 3);
|
|
23
|
+
});
|
|
24
|
+
it("rejects empty plans", () => { assert.equal(validatePlan("", "", []).valid, false); });
|
|
25
|
+
it("rejects unnumbered checklists", () => {
|
|
26
|
+
const raw = VALID_TASK.replace("- [ ] 1. A", "- [ ] A");
|
|
27
|
+
assert.equal(validatePlan("# Plan", raw, parseTaskMarkdown(raw)).valid, false);
|
|
28
|
+
});
|
|
29
|
+
it("rejects star bullets and non-positive task ids", () => {
|
|
30
|
+
const star = VALID_TASK.replace("- [ ] 1. A", "* [ ] 1. A");
|
|
31
|
+
assert.equal(validatePlan("# Plan", star, parseTaskMarkdown(star)).valid, false);
|
|
32
|
+
const zero = VALID_TASK.replaceAll("1. A", "0. A").replaceAll("Task 1", "Task 0");
|
|
33
|
+
assert.throws(() => parseTaskMarkdown(zero), /positive integers/);
|
|
34
|
+
});
|
|
35
|
+
it("accepts a strict matching task contract", () => {
|
|
36
|
+
assert.equal(validatePlan("# Plan", VALID_TASK, parseTaskMarkdown(VALID_TASK)).valid, true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { initialState, structureHash, transition, updateTaskState } from "./state.ts";
|
|
4
|
+
import type { PlanState, TaskFile } from "./types.ts";
|
|
5
|
+
import { assertApprovedStructure, completeCurrentTask, reworkFromTask, verifyCurrentTask, canCompleteTask, canReworkTask, canVerifyTask, classifyQueue } from "./workflow-policy.ts";
|
|
6
|
+
|
|
7
|
+
const file: TaskFile = {
|
|
8
|
+
raw: "# Tasks\n\n- [ ] 1. A\n- [ ] 2. B\n",
|
|
9
|
+
tasks: [
|
|
10
|
+
{ id: 1, title: "A", done: false, body: "" },
|
|
11
|
+
{ id: 2, title: "B", done: false, body: "" },
|
|
12
|
+
],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function executing(): PlanState {
|
|
16
|
+
let state = transition(initialState(), "planning");
|
|
17
|
+
state = transition(state, "ready");
|
|
18
|
+
state = { ...transition(state, "approved"), approvedStructureHash: "hash" };
|
|
19
|
+
state = transition(state, "executing");
|
|
20
|
+
return { ...state, currentTaskId: 1, tasks: { "1": { status: "pending" }, "2": { status: "pending" } } };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("workflow policy", () => {
|
|
24
|
+
it("only completes the current executing task", () => {
|
|
25
|
+
assert.equal(canCompleteTask(executing(), file, 1).ok, true);
|
|
26
|
+
assert.equal(canCompleteTask(executing(), file, 2).ok, false);
|
|
27
|
+
assert.equal(canCompleteTask({ ...executing(), status: "ready" }, file, 1).ok, false);
|
|
28
|
+
});
|
|
29
|
+
it("requires implementation completion and evidence before verify", () => {
|
|
30
|
+
assert.equal(canVerifyTask(executing(), file, 1, "tests passed").ok, false);
|
|
31
|
+
const complete = updateTaskState(executing(), 1, "implementation-complete");
|
|
32
|
+
assert.equal(canVerifyTask(complete, file, 1, "").ok, false);
|
|
33
|
+
assert.equal(canVerifyTask(complete, file, 1, "tests passed").ok, true);
|
|
34
|
+
});
|
|
35
|
+
it("distinguishes blocked-only work from completion", () => {
|
|
36
|
+
const blocked = { ...executing(), tasks: { "1": { status: "blocked" as const, reason: "x" }, "2": { status: "blocked" as const, reason: "y" } } };
|
|
37
|
+
assert.deepEqual(classifyQueue(file, blocked), { kind: "all-remaining-blocked", taskIds: [1, 2] });
|
|
38
|
+
assert.deepEqual(classifyQueue({ ...file, tasks: file.tasks.map((task) => ({ ...task, done: true })) }, blocked), { kind: "all-complete" });
|
|
39
|
+
});
|
|
40
|
+
it("requires a verified completed task before rework", () => {
|
|
41
|
+
const doneFile = { ...file, tasks: [{ ...file.tasks[0]!, done: true }, file.tasks[1]!] };
|
|
42
|
+
assert.equal(canReworkTask(executing(), doneFile, 1).ok, false);
|
|
43
|
+
const verified = updateTaskState(executing(), 1, "verified", { verification: "ok" });
|
|
44
|
+
assert.equal(canReworkTask(verified, doneFile, 1).ok, true);
|
|
45
|
+
});
|
|
46
|
+
it("rejects structural changes but permits checkbox progress", () => {
|
|
47
|
+
const task = "# Tasks\n\n- [ ] 1. A\n";
|
|
48
|
+
const state = { ...executing(), approvedStructureHash: structureHash("# Plan", task) };
|
|
49
|
+
assert.doesNotThrow(() => assertApprovedStructure(state, "# Plan", task.replace("[ ]", "[x]"), structureHash));
|
|
50
|
+
assert.throws(() => assertApprovedStructure(state, "# Changed", task, structureHash), /changed after approval/);
|
|
51
|
+
});
|
|
52
|
+
it("applies complete then verify without checking early", () => {
|
|
53
|
+
const completed = completeCurrentTask(executing(), file, 1);
|
|
54
|
+
assert.equal(completed.ok, true);
|
|
55
|
+
if (!completed.ok) return;
|
|
56
|
+
assert.doesNotMatch(completed.value.task, /\[x\] 1/);
|
|
57
|
+
const verified = verifyCurrentTask(completed.value.state, { raw: completed.value.task, tasks: file.tasks }, 1, "tests passed");
|
|
58
|
+
assert.equal(verified.ok, true);
|
|
59
|
+
if (!verified.ok) return;
|
|
60
|
+
assert.match(verified.value.task, /\[x\] 1/);
|
|
61
|
+
assert.equal(verified.value.state.tasks?.["1"]?.status, "verified");
|
|
62
|
+
});
|
|
63
|
+
it("cascades rework and revokes approval", () => {
|
|
64
|
+
const doneFile: TaskFile = { raw: file.raw.replaceAll("[ ]", "[x]"), tasks: file.tasks.map((task) => ({ ...task, done: true })) };
|
|
65
|
+
let state = updateTaskState(executing(), 1, "verified", { verification: "one" });
|
|
66
|
+
state = updateTaskState(state, 2, "verified", { verification: "two" });
|
|
67
|
+
const reworked = reworkFromTask(state, doneFile, 1);
|
|
68
|
+
assert.equal(reworked.ok, true);
|
|
69
|
+
if (!reworked.ok) return;
|
|
70
|
+
assert.equal(reworked.value.state.status, "ready");
|
|
71
|
+
assert.equal(reworked.value.state.approvedStructureHash, undefined);
|
|
72
|
+
assert.equal(reworked.value.state.tasks?.["1"]?.status, "pending");
|
|
73
|
+
assert.equal(reworked.value.state.tasks?.["2"]?.status, "pending");
|
|
74
|
+
assert.doesNotMatch(reworked.value.task, /\[x\]/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { markTaskDoneInMarkdown, markTasksPendingFromMarkdown } from "./parse.ts";
|
|
2
|
+
import { transition, updateTaskState } from "./state.ts";
|
|
3
|
+
import type { PlanState, TaskFile, TaskItem, TaskRuntimeState } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type QueueDecision =
|
|
6
|
+
| { kind: "runnable"; task: TaskItem }
|
|
7
|
+
| { kind: "all-complete" }
|
|
8
|
+
| { kind: "all-remaining-blocked"; taskIds: number[] };
|
|
9
|
+
|
|
10
|
+
export type PolicyResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
11
|
+
|
|
12
|
+
export function assertApprovedStructure(state: PlanState, plan: string, task: string, hash: (plan: string, task: string) => string): void {
|
|
13
|
+
if (!state.approvedStructureHash) throw new Error("Plan is not bound to an approved structure hash.");
|
|
14
|
+
if (hash(plan, task) !== state.approvedStructureHash) throw new Error("Plan files changed after approval; review and approve again.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classifyQueue(file: TaskFile, state: PlanState): QueueDecision {
|
|
18
|
+
const remaining = file.tasks.filter((task) => !task.done);
|
|
19
|
+
if (remaining.length === 0) return { kind: "all-complete" };
|
|
20
|
+
const runnable = remaining.find((task) => state.tasks?.[String(task.id)]?.status !== "blocked");
|
|
21
|
+
if (runnable) return { kind: "runnable", task: runnable };
|
|
22
|
+
return { kind: "all-remaining-blocked", taskIds: remaining.map((task) => task.id) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function canCompleteTask(state: PlanState, file: TaskFile, id: number): PolicyResult<TaskItem> {
|
|
26
|
+
if (state.status !== "executing") return { ok: false, error: `Plan is not executing (status: ${state.status}).` };
|
|
27
|
+
if (state.currentTaskId !== id) return { ok: false, error: `Only current task ${state.currentTaskId ?? "none"} can be completed.` };
|
|
28
|
+
const task = file.tasks.find((item) => item.id === id);
|
|
29
|
+
if (!task) return { ok: false, error: `Task ${id} was not found.` };
|
|
30
|
+
if (task.done) return { ok: false, error: `Task ${id} is already verified.` };
|
|
31
|
+
const runtime = state.tasks?.[String(id)];
|
|
32
|
+
if (runtime && runtime.status !== "pending") return { ok: false, error: `Task ${id} cannot complete from ${runtime.status}.` };
|
|
33
|
+
return { ok: true, value: task };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function canVerifyTask(state: PlanState, file: TaskFile, id: number, reason?: string): PolicyResult<TaskItem> {
|
|
37
|
+
if (!reason?.trim()) return { ok: false, error: "A non-empty verification result is required." };
|
|
38
|
+
if (state.status !== "executing") return { ok: false, error: `Plan is not executing (status: ${state.status}).` };
|
|
39
|
+
if (state.currentTaskId !== id) return { ok: false, error: `Only current task ${state.currentTaskId ?? "none"} can be verified.` };
|
|
40
|
+
const task = file.tasks.find((item) => item.id === id);
|
|
41
|
+
if (!task) return { ok: false, error: `Task ${id} was not found.` };
|
|
42
|
+
if (task.done) return { ok: false, error: `Task ${id} is already verified.` };
|
|
43
|
+
if (state.tasks?.[String(id)]?.status !== "implementation-complete") return { ok: false, error: `Task ${id} must be implementation-complete before verification.` };
|
|
44
|
+
return { ok: true, value: task };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function canBlockTask(state: PlanState, file: TaskFile, id: number, reason?: string): PolicyResult<{ task: TaskItem; blockedFrom: "pending" | "implementation-complete" }> {
|
|
48
|
+
if (!reason?.trim()) return { ok: false, error: "A non-empty block reason is required." };
|
|
49
|
+
if (state.status !== "executing") return { ok: false, error: `Plan is not executing (status: ${state.status}).` };
|
|
50
|
+
if (state.currentTaskId !== id) return { ok: false, error: `Only current task ${state.currentTaskId ?? "none"} can be blocked.` };
|
|
51
|
+
const task = file.tasks.find((item) => item.id === id);
|
|
52
|
+
if (!task || task.done) return { ok: false, error: `Pending task ${id} was not found.` };
|
|
53
|
+
const status = state.tasks?.[String(id)]?.status ?? "pending";
|
|
54
|
+
if (status !== "pending" && status !== "implementation-complete") return { ok: false, error: `Task ${id} cannot be blocked from ${status}.` };
|
|
55
|
+
return { ok: true, value: { task, blockedFrom: status } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function canUnblockTask(state: PlanState, file: TaskFile, id: number): PolicyResult<TaskRuntimeState> {
|
|
59
|
+
if (!file.tasks.some((item) => item.id === id)) return { ok: false, error: `Task ${id} was not found.` };
|
|
60
|
+
const runtime = state.tasks?.[String(id)];
|
|
61
|
+
if (runtime?.status !== "blocked") return { ok: false, error: `Task ${id} is not blocked.` };
|
|
62
|
+
return { ok: true, value: runtime };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function canReworkTask(state: PlanState, file: TaskFile, id: number): PolicyResult<TaskItem> {
|
|
66
|
+
if (!["completed", "executing", "blocked", "approved"].includes(state.status)) return { ok: false, error: `Plan cannot be reworked from ${state.status}.` };
|
|
67
|
+
const task = file.tasks.find((item) => item.id === id);
|
|
68
|
+
if (!task) return { ok: false, error: `Task ${id} was not found.` };
|
|
69
|
+
if (!task.done || state.tasks?.[String(id)]?.status !== "verified") return { ok: false, error: `Task ${id} must be verified before rework.` };
|
|
70
|
+
return { ok: true, value: task };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface WorkflowMutation { state: PlanState; task: string; }
|
|
74
|
+
|
|
75
|
+
export function completeCurrentTask(state: PlanState, file: TaskFile, id: number): PolicyResult<WorkflowMutation> {
|
|
76
|
+
const allowed = canCompleteTask(state, file, id);
|
|
77
|
+
if (!allowed.ok) return allowed;
|
|
78
|
+
return { ok: true, value: { state: updateTaskState(state, id, "implementation-complete"), task: file.raw } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function verifyCurrentTask(state: PlanState, file: TaskFile, id: number, reason?: string): PolicyResult<WorkflowMutation> {
|
|
82
|
+
const allowed = canVerifyTask(state, file, id, reason);
|
|
83
|
+
if (!allowed.ok) return allowed;
|
|
84
|
+
return { ok: true, value: {
|
|
85
|
+
state: updateTaskState(state, id, "verified", { verification: reason!.trim(), reason: undefined, blockedFrom: undefined }),
|
|
86
|
+
task: markTaskDoneInMarkdown(file.raw, id),
|
|
87
|
+
} };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function blockCurrentTask(state: PlanState, file: TaskFile, id: number, reason?: string): PolicyResult<WorkflowMutation> {
|
|
91
|
+
const allowed = canBlockTask(state, file, id, reason);
|
|
92
|
+
if (!allowed.ok) return allowed;
|
|
93
|
+
return { ok: true, value: {
|
|
94
|
+
state: updateTaskState(state, id, "blocked", { reason: reason!.trim(), blockedFrom: allowed.value.blockedFrom }),
|
|
95
|
+
task: file.raw,
|
|
96
|
+
} };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function unblockTask(state: PlanState, file: TaskFile, id: number): PolicyResult<WorkflowMutation> {
|
|
100
|
+
const allowed = canUnblockTask(state, file, id);
|
|
101
|
+
if (!allowed.ok) return allowed;
|
|
102
|
+
const restored = allowed.value.blockedFrom ?? "pending";
|
|
103
|
+
const plan = state.status === "blocked" ? transition(state, "approved") : state;
|
|
104
|
+
return { ok: true, value: { state: updateTaskState(plan, id, restored, { reason: undefined, blockedFrom: undefined }), task: file.raw } };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function reworkFromTask(state: PlanState, file: TaskFile, id: number): PolicyResult<WorkflowMutation> {
|
|
108
|
+
const allowed = canReworkTask(state, file, id);
|
|
109
|
+
if (!allowed.ok) return allowed;
|
|
110
|
+
const tasks = { ...(state.tasks ?? {}) };
|
|
111
|
+
for (const taskId of Object.keys(tasks).map(Number)) if (taskId >= id) tasks[String(taskId)] = { status: "pending" };
|
|
112
|
+
const ready = transition(state, "ready");
|
|
113
|
+
return { ok: true, value: {
|
|
114
|
+
state: { ...ready, tasks, currentTaskId: undefined, approvedStructureHash: undefined, continueMode: undefined, approvalEachTask: false, failureReason: `Task ${id} and dependent tasks reopened; approval required.` },
|
|
115
|
+
task: markTasksPendingFromMarkdown(file.raw, id),
|
|
116
|
+
} };
|
|
117
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, 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 { draftPlanFilePath, draftTaskFilePath, planDir, planFilePath, taskFilePath } from "./paths.ts";
|
|
7
|
+
import { initialState, loadState, structureHash, transition, updateTaskState } from "./state.ts";
|
|
8
|
+
import { commitDraft, initializeDraftWithState, mutateWorkflow, saveStateLocked } from "./workflow-store.ts";
|
|
9
|
+
|
|
10
|
+
const TASK = `# Tasks\n\n- [ ] 1. A\n\n## Task 1: A\n\n**Description:** A\n**Acceptance criteria:** A\n**Verification:** A\n`;
|
|
11
|
+
|
|
12
|
+
async function tempProject<T>(run: (cwd: string) => Promise<T>): Promise<T> {
|
|
13
|
+
const cwd = await mkdtemp(join(tmpdir(), "pi-plan-task-store-"));
|
|
14
|
+
try { return await run(cwd); } finally { await rm(cwd, { recursive: true, force: true }); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("workflow store", () => {
|
|
18
|
+
it("ignores top-level checkbox progress in structure hashes", () => {
|
|
19
|
+
assert.equal(structureHash("# Plan", TASK), structureHash("# Plan", TASK.replace("[ ]", "[x]")));
|
|
20
|
+
assert.notEqual(structureHash("# Plan", TASK), structureHash("# Changed", TASK));
|
|
21
|
+
assert.notEqual(structureHash("# Plan", TASK), structureHash("# Plan", TASK.replace("Task 1: A", "Task 1: B")));
|
|
22
|
+
});
|
|
23
|
+
it("commits only validated draft content to current files", async () => tempProject(async (cwd) => {
|
|
24
|
+
let state = transition(initialState(), "planning");
|
|
25
|
+
await initializeDraftWithState(cwd, state);
|
|
26
|
+
await writeFile(draftPlanFilePath(cwd), "# New plan", "utf8");
|
|
27
|
+
await writeFile(draftTaskFilePath(cwd), TASK, "utf8");
|
|
28
|
+
state = transition(state, "ready");
|
|
29
|
+
const committed = await commitDraft(cwd, state);
|
|
30
|
+
assert.equal(await readFile(planFilePath(cwd), "utf8"), "# New plan");
|
|
31
|
+
assert.equal(await readFile(taskFilePath(cwd), "utf8"), TASK);
|
|
32
|
+
assert.equal(committed.draftStructureHash, structureHash("# New plan", TASK));
|
|
33
|
+
}));
|
|
34
|
+
it("serializes concurrent state updates without losing either task", async () => tempProject(async (cwd) => {
|
|
35
|
+
await writeFile(join(cwd, ".keep"), "", "utf8");
|
|
36
|
+
await import("node:fs/promises").then(({ mkdir }) => mkdir(planDir(cwd), { recursive: true }));
|
|
37
|
+
await writeFile(planFilePath(cwd), "# Plan", "utf8");
|
|
38
|
+
await writeFile(taskFilePath(cwd), TASK.replace("- [ ] 1. A", "- [ ] 1. A\n- [ ] 2. B"), "utf8");
|
|
39
|
+
await saveStateLocked(cwd, initialState());
|
|
40
|
+
await Promise.all([
|
|
41
|
+
mutateWorkflow(cwd, ({ state }) => ({ state: updateTaskState(state, 1, "pending") })),
|
|
42
|
+
mutateWorkflow(cwd, ({ state }) => ({ state: updateTaskState(state, 2, "pending") })),
|
|
43
|
+
]);
|
|
44
|
+
const state = await loadState(cwd);
|
|
45
|
+
assert.equal(state?.tasks?.["1"]?.status, "pending");
|
|
46
|
+
assert.equal(state?.tasks?.["2"]?.status, "pending");
|
|
47
|
+
}));
|
|
48
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { readOptionalFile } from "./files.ts";
|
|
5
|
+
import { savePlanSnapshot } from "./history.ts";
|
|
6
|
+
import { draftDir, draftPlanFilePath, draftTaskFilePath, planDir, planFilePath, stateFilePath, taskFilePath } from "./paths.ts";
|
|
7
|
+
import { loadState, parsePlanState, saveState, structureHash } from "./state.ts";
|
|
8
|
+
import type { PlanState } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const queues = new Map<string, Promise<void>>();
|
|
11
|
+
|
|
12
|
+
async function withProcessQueue<T>(cwd: string, operation: () => Promise<T>): Promise<T> {
|
|
13
|
+
const key = resolve(cwd);
|
|
14
|
+
const previous = queues.get(key) ?? Promise.resolve();
|
|
15
|
+
let release!: () => void;
|
|
16
|
+
const current = new Promise<void>((done) => { release = done; });
|
|
17
|
+
const chain = previous.then(() => current);
|
|
18
|
+
queues.set(key, chain);
|
|
19
|
+
await previous;
|
|
20
|
+
try { return await operation(); } finally {
|
|
21
|
+
release();
|
|
22
|
+
if (queues.get(key) === chain) queues.delete(key);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function acquireDirectoryLock(cwd: string): Promise<() => Promise<void>> {
|
|
27
|
+
const lockPath = resolve(planDir(cwd), ".lock");
|
|
28
|
+
await mkdir(planDir(cwd), { recursive: true });
|
|
29
|
+
for (let attempt = 0; ; attempt++) {
|
|
30
|
+
try { await mkdir(lockPath); break; } catch (error) {
|
|
31
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
32
|
+
try {
|
|
33
|
+
if (Date.now() - (await stat(lockPath)).mtimeMs > 10 * 60 * 1000) {
|
|
34
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
} catch { /* disappeared; retry */ }
|
|
38
|
+
if (attempt >= 200) throw new Error(`Timed out waiting for workflow lock: ${lockPath}`);
|
|
39
|
+
await new Promise((done) => setTimeout(done, 25));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return async () => rm(lockPath, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function withWorkflowLock<T>(cwd: string, operation: () => Promise<T>): Promise<T> {
|
|
46
|
+
return withProcessQueue(cwd, async () => {
|
|
47
|
+
const release = await acquireDirectoryLock(cwd);
|
|
48
|
+
try { return await operation(); } finally { await release(); }
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface FileChange { path: string; content: string; }
|
|
53
|
+
|
|
54
|
+
export async function writeFilesAtomically(changes: readonly FileChange[]): Promise<void> {
|
|
55
|
+
const transactionId = `${process.pid}.${randomUUID()}`;
|
|
56
|
+
const prepared: Array<FileChange & { temporary: string; backup: string; previous?: string }> = [];
|
|
57
|
+
try {
|
|
58
|
+
for (const change of changes) {
|
|
59
|
+
await mkdir(dirname(change.path), { recursive: true });
|
|
60
|
+
const previous = await readOptionalFile(change.path);
|
|
61
|
+
const temporary = `${change.path}.${transactionId}.tmp`;
|
|
62
|
+
const backup = `${change.path}.${transactionId}.bak`;
|
|
63
|
+
prepared.push({ ...change, temporary, backup, previous });
|
|
64
|
+
await writeFile(temporary, change.content, "utf8");
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
await Promise.all(prepared.map((item) => rm(item.temporary, { force: true }).catch(() => undefined)));
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
let applied = 0;
|
|
71
|
+
try {
|
|
72
|
+
for (const item of prepared) {
|
|
73
|
+
if (item.previous !== undefined) await rename(item.path, item.backup);
|
|
74
|
+
await rename(item.temporary, item.path);
|
|
75
|
+
applied += 1;
|
|
76
|
+
}
|
|
77
|
+
await Promise.all(prepared.map((item) => rm(item.backup, { force: true }).catch(() => undefined)));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
for (const item of [...prepared.slice(0, applied)].reverse()) {
|
|
80
|
+
await rm(item.path, { force: true }).catch(() => undefined);
|
|
81
|
+
if (item.previous !== undefined) await rename(item.backup, item.path).catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
for (const item of prepared.slice(applied)) {
|
|
84
|
+
await rm(item.temporary, { force: true }).catch(() => undefined);
|
|
85
|
+
if (item.previous !== undefined) await rename(item.backup, item.path).catch(() => undefined);
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function readCurrentArtifacts(cwd: string): Promise<{ plan: string; task: string }> {
|
|
92
|
+
const plan = await readOptionalFile(planFilePath(cwd));
|
|
93
|
+
const task = await readOptionalFile(taskFilePath(cwd));
|
|
94
|
+
if (plan === undefined || task === undefined) throw new Error("Current plan.md and task.md are required.");
|
|
95
|
+
return { plan, task };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
export async function initializeDraftWithState(cwd: string, state: PlanState, source?: { plan: string; task: string }): Promise<PlanState> {
|
|
101
|
+
return withWorkflowLock(cwd, async () => {
|
|
102
|
+
await rm(draftDir(cwd), { recursive: true, force: true });
|
|
103
|
+
await mkdir(draftDir(cwd), { recursive: true });
|
|
104
|
+
const validated = parsePlanState({ ...state, updatedAt: new Date().toISOString() });
|
|
105
|
+
await writeFilesAtomically([
|
|
106
|
+
{ path: draftPlanFilePath(cwd), content: source?.plan ?? "" },
|
|
107
|
+
{ path: draftTaskFilePath(cwd), content: source?.task ?? "" },
|
|
108
|
+
{ path: stateFilePath(cwd), content: `${JSON.stringify(validated, null, 2)}\n` },
|
|
109
|
+
]);
|
|
110
|
+
return validated;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function readDraftArtifacts(cwd: string): Promise<{ plan: string; task: string }> {
|
|
115
|
+
const plan = await readOptionalFile(draftPlanFilePath(cwd));
|
|
116
|
+
const task = await readOptionalFile(draftTaskFilePath(cwd));
|
|
117
|
+
if (plan === undefined || task === undefined) throw new Error("Draft plan.md and task.md are required.");
|
|
118
|
+
return { plan, task };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function commitDraft(cwd: string, nextState: PlanState, draftOverride?: { plan: string; task: string }): Promise<PlanState> {
|
|
122
|
+
return withWorkflowLock(cwd, async () => {
|
|
123
|
+
const draft = draftOverride ?? await readDraftArtifacts(cwd);
|
|
124
|
+
const oldPlan = (await readOptionalFile(planFilePath(cwd))) ?? "";
|
|
125
|
+
const oldTask = (await readOptionalFile(taskFilePath(cwd))) ?? "";
|
|
126
|
+
if (oldPlan || oldTask) await savePlanSnapshot(cwd, oldPlan, oldTask);
|
|
127
|
+
const committed = parsePlanState({ ...nextState, draftStructureHash: structureHash(draft.plan, draft.task), updatedAt: new Date().toISOString() });
|
|
128
|
+
await writeFilesAtomically([
|
|
129
|
+
{ path: planFilePath(cwd), content: draft.plan },
|
|
130
|
+
{ path: taskFilePath(cwd), content: draft.task },
|
|
131
|
+
{ path: stateFilePath(cwd), content: `${JSON.stringify({ ...committed, updatedAt: new Date().toISOString() }, null, 2)}\n` },
|
|
132
|
+
]);
|
|
133
|
+
await rm(draftDir(cwd), { recursive: true, force: true });
|
|
134
|
+
return committed;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function mutateWorkflow(
|
|
139
|
+
cwd: string,
|
|
140
|
+
mutation: (current: { state: PlanState; plan: string; task: string }) => Promise<{ state: PlanState; task?: string; plan?: string }> | { state: PlanState; task?: string; plan?: string },
|
|
141
|
+
): Promise<{ state: PlanState; plan: string; task: string }> {
|
|
142
|
+
return withWorkflowLock(cwd, async () => {
|
|
143
|
+
const state = await loadState(cwd);
|
|
144
|
+
if (!state) throw new Error("No workflow state found.");
|
|
145
|
+
const { plan, task } = await readCurrentArtifacts(cwd);
|
|
146
|
+
const next = await mutation({ state, plan, task });
|
|
147
|
+
const validatedState = parsePlanState({ ...next.state, updatedAt: new Date().toISOString() });
|
|
148
|
+
const nextPlan = next.plan ?? plan;
|
|
149
|
+
const nextTask = next.task ?? task;
|
|
150
|
+
await writeFilesAtomically([
|
|
151
|
+
...(nextPlan !== plan ? [{ path: planFilePath(cwd), content: nextPlan }] : []),
|
|
152
|
+
...(nextTask !== task ? [{ path: taskFilePath(cwd), content: nextTask }] : []),
|
|
153
|
+
{ path: stateFilePath(cwd), content: `${JSON.stringify(validatedState, null, 2)}\n` },
|
|
154
|
+
]);
|
|
155
|
+
return { state: validatedState, plan: nextPlan, task: nextTask };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
export async function loadStateLocked(cwd: string): Promise<PlanState | undefined> {
|
|
159
|
+
return withWorkflowLock(cwd, () => loadState(cwd));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
export async function saveStateLocked(cwd: string, state: PlanState): Promise<void> {
|
|
164
|
+
await withWorkflowLock(cwd, () => saveState(cwd, state));
|
|
165
|
+
}
|