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.
Files changed (38) hide show
  1. package/README.md +633 -68
  2. package/extensions/build-session.test.ts +25 -27
  3. package/extensions/build-session.ts +38 -11
  4. package/extensions/command-surface.test.ts +11 -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 +583 -224
  13. package/extensions/integration.test.ts +232 -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 +48 -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
@@ -0,0 +1,232 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, 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 planTaskExtension from "./index.ts";
7
+ import { draftPlanFilePath, draftTaskFilePath, planDir, planFilePath, projectConfigPath, taskFilePath } from "./paths.ts";
8
+ import { initialState, loadState, saveState, structureHash, transition, updateTaskState } from "./state.ts";
9
+ import type { PlanState } from "./types.ts";
10
+
11
+ const TASK = `# Tasks\n\n- [ ] 1. A\n- [ ] 2. B\n\n## Task 1: A\n\n**Description:** A\n**Acceptance criteria:** A\n**Verification:** A\n\n## Task 2: B\n\n**Description:** B\n**Acceptance criteria:** B\n**Verification:** B\n`;
12
+
13
+ async function harness() {
14
+ const root = await mkdtemp(join(tmpdir(), "pi-plan-task-integration-"));
15
+ const cwd = join(root, "project");
16
+ const agentDir = join(root, "agent");
17
+ await mkdir(cwd, { recursive: true });
18
+ const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
19
+ process.env.PI_CODING_AGENT_DIR = agentDir;
20
+ const commands = new Map<string, any>();
21
+ const handlers = new Map<string, any>();
22
+ let tool: any;
23
+ const notifications: string[] = [];
24
+ const sent: string[] = [];
25
+ let newSessionCancelled = true;
26
+ const api: any = {
27
+ registerCommand(name: string, definition: any) { commands.set(name, definition); },
28
+ registerTool(definition: any) { tool = definition; },
29
+ on(name: string, handler: any) { handlers.set(name, handler); },
30
+ getActiveTools() { return []; },
31
+ setActiveTools() {},
32
+ getAllTools() { return [{ name: "ask_user_question" }]; },
33
+ events: { emit() {} },
34
+ sendUserMessage(message: string) { sent.push(message); },
35
+ exec: async () => ({ stdout: "", stderr: "", code: 0 }),
36
+ };
37
+ await planTaskExtension(api);
38
+ const ctx: any = {
39
+ cwd, hasUI: false, mode: "print",
40
+ ui: {
41
+ notify(message: string) { notifications.push(message); },
42
+ setStatus() {}, setWidget() {},
43
+ theme: { fg: (_name: string, text: string) => text },
44
+ confirm: async () => true, input: async () => "feedback", select: async () => undefined,
45
+ },
46
+ sessionManager: { getSessionFile: () => join(root, "session.jsonl") },
47
+ newSession: async (options: any) => {
48
+ if (newSessionCancelled) return { cancelled: true };
49
+ await options?.withSession?.({ sendUserMessage: async (message: string) => { sent.push(message); } });
50
+ return { cancelled: false };
51
+ },
52
+ };
53
+ return {
54
+ root, cwd, ctx, commands, handlers, get tool() { return tool; }, notifications, sent,
55
+ setNewSessionCancelled(value: boolean) { newSessionCancelled = value; },
56
+ async close() { if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousAgentDir; await rm(root, { recursive: true, force: true }); },
57
+ };
58
+ }
59
+
60
+ async function writeWorkflow(cwd: string, status: PlanState["status"], task = TASK): Promise<PlanState> {
61
+ await mkdir(planDir(cwd), { recursive: true });
62
+ const plan = "# Plan";
63
+ await writeFile(planFilePath(cwd), plan, "utf8");
64
+ await writeFile(taskFilePath(cwd), task, "utf8");
65
+ let state = transition(initialState(), "planning");
66
+ state = transition(state, "ready");
67
+ state = { ...state, tasks: { "1": { status: "pending" }, "2": { status: "pending" } } };
68
+ if (status === "approved" || status === "executing") {
69
+ state = { ...transition(state, "approved"), approvedStructureHash: structureHash(plan, task) };
70
+ if (status === "executing") state = { ...transition(state, "executing"), currentTaskId: 1 };
71
+ }
72
+ await saveState(cwd, state);
73
+ return state;
74
+ }
75
+
76
+ describe("extension command integration", () => {
77
+ it("does not let ready-state rework bypass approval", async () => {
78
+ const h = await harness();
79
+ try {
80
+ const checked = TASK.replace("- [ ] 1. A", "- [x] 1. A");
81
+ let state = await writeWorkflow(h.cwd, "ready", checked);
82
+ state = updateTaskState(state, 1, "verified", { verification: "passed" });
83
+ await saveState(h.cwd, state);
84
+ await h.commands.get("tasks").handler("rework 1", h.ctx);
85
+ assert.equal((await loadState(h.cwd))?.status, "ready");
86
+ assert.match(await readFile(taskFilePath(h.cwd), "utf8"), /- \[x\] 1\. A/);
87
+ } finally { await h.close(); }
88
+ });
89
+
90
+ it("revokes approval when plan structure changes", async () => {
91
+ const h = await harness();
92
+ try {
93
+ await writeWorkflow(h.cwd, "approved");
94
+ await writeFile(planFilePath(h.cwd), "# Changed", "utf8");
95
+ await h.commands.get("build").handler("", h.ctx);
96
+ const state = await loadState(h.cwd);
97
+ assert.equal(state?.status, "ready");
98
+ assert.equal(state?.approvedStructureHash, undefined);
99
+ } finally { await h.close(); }
100
+ });
101
+
102
+ it("rejects wrong task ids and applies complete then verify", async () => {
103
+ const h = await harness();
104
+ try {
105
+ await writeWorkflow(h.cwd, "executing");
106
+ await assert.rejects(() => h.tool.execute("call", { action: "complete", id: 2 }, undefined, undefined, h.ctx), /Only current task 1/);
107
+ assert.doesNotMatch(await readFile(taskFilePath(h.cwd), "utf8"), /\[x\]/);
108
+ await h.tool.execute("call", { action: "complete", id: 1 }, undefined, undefined, h.ctx);
109
+ assert.equal((await loadState(h.cwd))?.tasks?.["1"]?.status, "implementation-complete");
110
+ assert.doesNotMatch(await readFile(taskFilePath(h.cwd), "utf8"), /\[x\] 1/);
111
+ await h.tool.execute("call", { action: "verify", id: 1, reason: "tests passed" }, undefined, undefined, h.ctx);
112
+ assert.equal((await loadState(h.cwd))?.tasks?.["1"]?.status, "verified");
113
+ assert.match(await readFile(taskFilePath(h.cwd), "utf8"), /\[x\] 1/);
114
+ } finally { await h.close(); }
115
+ });
116
+
117
+ it("keeps initial new-session cancellation approved", async () => {
118
+ const h = await harness();
119
+ try {
120
+ await writeWorkflow(h.cwd, "approved");
121
+ await h.commands.get("build").handler("new", h.ctx);
122
+ const state = await loadState(h.cwd);
123
+ assert.equal(state?.status, "approved");
124
+ assert.equal(state?.continueMode, undefined);
125
+ } finally { await h.close(); }
126
+ });
127
+
128
+ it("hands off all-new approval and all-fresh through public build commands", async () => {
129
+ const allNew = await harness();
130
+ try {
131
+ await writeWorkflow(allNew.cwd, "approved");
132
+ allNew.setNewSessionCancelled(false);
133
+ await allNew.commands.get("build").handler("all new --approval", allNew.ctx);
134
+ const state = await loadState(allNew.cwd);
135
+ assert.equal(state?.continueMode, "all-new");
136
+ assert.equal(state?.approvalEachTask, true);
137
+ assert.equal(allNew.sent.includes("/build"), true);
138
+ } finally { await allNew.close(); }
139
+ const fresh = await harness();
140
+ try {
141
+ await writeWorkflow(fresh.cwd, "approved");
142
+ fresh.setNewSessionCancelled(false);
143
+ await fresh.commands.get("build").handler("all fresh", fresh.ctx);
144
+ assert.equal(fresh.sent.includes("/build all"), true);
145
+ } finally { await fresh.close(); }
146
+ });
147
+
148
+ it("external mode refuses local build", async () => {
149
+ const h = await harness();
150
+ try {
151
+ await writeWorkflow(h.cwd, "approved");
152
+ await mkdir(join(h.cwd, ".pi"), { recursive: true });
153
+ await writeFile(projectConfigPath(h.cwd), JSON.stringify({ executionMode: "external" }), "utf8");
154
+ await h.commands.get("build").handler("", h.ctx);
155
+ assert.equal(h.notifications.some((message) => message.includes("External execution mode")), true);
156
+ assert.equal((await loadState(h.cwd))?.status, "approved");
157
+ } finally { await h.close(); }
158
+ });
159
+
160
+ it("runs plan draft submission and approval through the three-command surface", async () => {
161
+ const h = await harness();
162
+ try {
163
+ await h.commands.get("plan").handler("Add A", h.ctx);
164
+ assert.equal((await loadState(h.cwd))?.status, "planning");
165
+ await writeFile(draftPlanFilePath(h.cwd), "# Plan", "utf8");
166
+ await writeFile(draftTaskFilePath(h.cwd), TASK, "utf8");
167
+ await h.handlers.get("agent_settled")({}, h.ctx);
168
+ assert.equal((await loadState(h.cwd))?.status, "ready");
169
+ assert.equal(await readFile(planFilePath(h.cwd), "utf8"), "# Plan");
170
+ await h.commands.get("plan").handler("approve current", h.ctx);
171
+ assert.equal((await loadState(h.cwd))?.status, "executing");
172
+ assert.equal(h.sent.some((message) => message.includes("Execute planned task 1")), true);
173
+ } finally { await h.close(); }
174
+ });
175
+
176
+ it("blocks and unblocks only the current task with reasons", async () => {
177
+ const h = await harness();
178
+ try {
179
+ await writeWorkflow(h.cwd, "executing");
180
+ await assert.rejects(() => h.tool.execute("call", { action: "block", id: 1, reason: "" }, undefined, undefined, h.ctx), /non-empty block reason/);
181
+ await h.tool.execute("call", { action: "block", id: 1, reason: "dependency" }, undefined, undefined, h.ctx);
182
+ assert.equal((await loadState(h.cwd))?.tasks?.["1"]?.status, "blocked");
183
+ await h.tool.execute("call", { action: "unblock", id: 1 }, undefined, undefined, h.ctx);
184
+ assert.equal((await loadState(h.cwd))?.tasks?.["1"]?.status, "pending");
185
+ } finally { await h.close(); }
186
+ });
187
+
188
+ it("restores executing framing on session start", async () => {
189
+ const h = await harness();
190
+ try {
191
+ await writeWorkflow(h.cwd, "executing");
192
+ await h.handlers.get("session_start")({}, h.ctx);
193
+ const injection = await h.handlers.get("before_agent_start")({}, h.ctx);
194
+ assert.equal(injection?.message?.details?.phase, "build");
195
+ assert.equal(injection?.message?.details?.taskId, 1);
196
+ } finally { await h.close(); }
197
+ });
198
+
199
+ it("supports revision, rejection, task review, and successful cascading rework", async () => {
200
+ const h = await harness();
201
+ try {
202
+ h.ctx.hasUI = true;
203
+ await writeWorkflow(h.cwd, "approved");
204
+ await h.commands.get("plan").handler("revise", h.ctx);
205
+ assert.equal((await loadState(h.cwd))?.status, "planning");
206
+ assert.equal(h.sent.some((message) => message.includes("Revise the current draft")), true);
207
+ await writeFile(draftPlanFilePath(h.cwd), "# Revised", "utf8");
208
+ await writeFile(draftTaskFilePath(h.cwd), TASK, "utf8");
209
+ await h.handlers.get("agent_settled")({}, h.ctx);
210
+ assert.equal((await loadState(h.cwd))?.status, "ready");
211
+ await h.commands.get("plan").handler("reject more detail", h.ctx);
212
+ assert.equal((await loadState(h.cwd))?.status, "planning");
213
+ assert.equal(h.sent.some((message) => message.includes("review feedback")), true);
214
+ await h.commands.get("tasks").handler("review 1", h.ctx);
215
+ assert.equal(h.notifications.some((message) => message.includes("Task 1: A")), true);
216
+ } finally { await h.close(); }
217
+ const rework = await harness();
218
+ try {
219
+ const checked = TASK.replace("- [ ] 1. A", "- [x] 1. A").replace("- [ ] 2. B", "- [x] 2. B");
220
+ let state = await writeWorkflow(rework.cwd, "executing", checked);
221
+ state = updateTaskState(state, 1, "verified", { verification: "one" });
222
+ state = updateTaskState(state, 2, "verified", { verification: "two" });
223
+ await saveState(rework.cwd, state);
224
+ await rework.commands.get("tasks").handler("rework 1", rework.ctx);
225
+ const next = await loadState(rework.cwd);
226
+ assert.equal(next?.status, "ready");
227
+ assert.equal(next?.approvedStructureHash, undefined);
228
+ assert.doesNotMatch(await readFile(taskFilePath(rework.cwd), "utf8"), /\[x\]/);
229
+ } finally { await rework.close(); }
230
+ });
231
+
232
+ });
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, readdir, 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 { planDir, stateFilePath, taskFilePath } from "./paths.ts";
7
+ import { loadState } from "./state.ts";
8
+
9
+ async function tempProject<T>(run: (cwd: string) => Promise<T>): Promise<T> {
10
+ const cwd = await mkdtemp(join(tmpdir(), "pi-plan-task-migrate-"));
11
+ try { return await run(cwd); } finally { await rm(cwd, { recursive: true, force: true }); }
12
+ }
13
+
14
+ describe("state v1 migration", () => {
15
+ it("backs up v1 and requires old checked work to be verified again", async () => tempProject(async (cwd) => {
16
+ await mkdir(planDir(cwd), { recursive: true });
17
+ await writeFile(taskFilePath(cwd), "# Tasks\n\n- [x] 1. A\n- [x] 2. B\n", "utf8");
18
+ await writeFile(stateFilePath(cwd), JSON.stringify({
19
+ version: 1, status: "completed", executionMode: "automatic", updatedAt: "old",
20
+ tasks: { "1": { status: "implementation-complete" }, "2": { status: "verified", verification: "passed" } },
21
+ }), "utf8");
22
+ const state = await loadState(cwd);
23
+ assert.equal(state?.version, 2);
24
+ assert.equal(state?.status, "ready");
25
+ assert.equal(state?.tasks?.["1"]?.status, "implementation-complete");
26
+ assert.equal(state?.tasks?.["2"]?.status, "verified");
27
+ const task = await readFile(taskFilePath(cwd), "utf8");
28
+ assert.match(task, /- \[ \] 1\. A/);
29
+ assert.match(task, /- \[x\] 2\. B/);
30
+ const history = await readdir(join(planDir(cwd), "history"));
31
+ assert.equal(history.some((name) => name.startsWith("state-v1-")), true);
32
+ assert.equal(history.some((name) => name.startsWith("task-v1-")), true);
33
+ }));
34
+ });
@@ -1,6 +1,7 @@
1
1
  import type { TaskItem } from "./types.ts";
2
2
 
3
- const NUMBERED_ITEM = /^[-*]\s+\[([ xX])\]\s+(\d+)\.\s+(.+)$/;
3
+ export const NUMBERED_ITEM = /^-\s+\[([ xX])\]\s+(\d+)\.\s+(.+)$/;
4
+
4
5
  const PLAIN_ITEM = /^[-*]\s+\[([ xX])\]\s+(.+)$/;
5
6
  const TASK_HEADING = /^##\s+Task\s+(\d+)\b/i;
6
7
 
@@ -39,7 +40,7 @@ export function parseTaskMarkdown(raw: string): TaskItem[] {
39
40
  const match = line.match(NUMBERED_ITEM);
40
41
  if (!match) continue;
41
42
  const id = Number(match[2]);
42
- if (!Number.isFinite(id)) continue;
43
+ if (!Number.isInteger(id) || id <= 0) throw new Error("task.md task IDs must be positive integers");
43
44
  numbered.push({
44
45
  id,
45
46
  title: match[3].trim(),
@@ -47,7 +48,13 @@ export function parseTaskMarkdown(raw: string): TaskItem[] {
47
48
  body: extractBody(raw, id),
48
49
  });
49
50
  }
50
- if (numbered.length > 0) return numbered;
51
+ if (numbered.length > 0) {
52
+ const ids = new Set(numbered.map((task) => task.id));
53
+ if (ids.size !== numbered.length) throw new Error("task.md contains duplicate task IDs");
54
+ const headingIds = [...raw.matchAll(/^##\s+Task\s+(\d+)\b/gim)].map((match) => Number(match[1]));
55
+ if (new Set(headingIds).size !== headingIds.length) throw new Error("task.md contains duplicate task headings");
56
+ return numbered;
57
+ }
51
58
 
52
59
  const plain: TaskItem[] = [];
53
60
  for (const line of checklist.split(/\r?\n/)) {
@@ -63,12 +70,24 @@ export function parseTaskMarkdown(raw: string): TaskItem[] {
63
70
  }
64
71
  return plain;
65
72
  }
73
+ export function hasPendingTasks(raw: string, includeUnnumbered = false): boolean {
74
+ const lines = raw.split(/\r?\n/);
75
+ const firstTaskHeading = lines.findIndex((line) => TASK_HEADING.test(line));
76
+ const checklist = lines.slice(0, firstTaskHeading < 0 ? undefined : firstTaskHeading);
77
+ return checklist.some((line) => {
78
+ if (NUMBERED_ITEM.test(line)) return line.includes("[ ]");
79
+ return includeUnnumbered && /^[-*]\s+\[ \]\s+.+$/.test(line);
80
+ });
81
+ }
66
82
 
67
83
  export function markTaskDoneInMarkdown(raw: string, id: number): string {
68
84
  const lines = raw.split(/\r?\n/);
85
+ const checklistEnd = lines.findIndex((line) => TASK_HEADING.test(line));
86
+ const end = checklistEnd < 0 ? lines.length : checklistEnd;
69
87
  let remaining = id;
70
88
  let changed = false;
71
- const next = lines.map((line) => {
89
+ const next = lines.map((line, index) => {
90
+ if (index >= end) return line;
72
91
  const numbered = line.match(NUMBERED_ITEM);
73
92
  if (numbered && Number(numbered[2]) === id && numbered[1] !== "x" && numbered[1] !== "X") {
74
93
  changed = true;
@@ -86,3 +105,60 @@ export function markTaskDoneInMarkdown(raw: string, id: number): string {
86
105
  });
87
106
  return changed ? next.join("\n") : raw;
88
107
  }
108
+
109
+ export function markTaskPendingInMarkdown(raw: string, id: number): string {
110
+ const lines = raw.split(/\r?\n/);
111
+ const checklistEnd = lines.findIndex((line) => TASK_HEADING.test(line));
112
+ const end = checklistEnd < 0 ? lines.length : checklistEnd;
113
+ let plainIndex = 0;
114
+ let changed = false;
115
+ const next = lines.map((line, index) => {
116
+ if (index >= end) return line;
117
+ const numbered = line.match(NUMBERED_ITEM);
118
+ if (numbered && Number(numbered[2]) === id && /[xX]/.test(numbered[1])) { changed = true; return line.replace(/\[[xX]\]/, "[ ]"); }
119
+ if (!numbered && PLAIN_ITEM.test(line)) { plainIndex += 1; if (plainIndex === id && /\[[xX]\]/.test(line)) { changed = true; return line.replace(/\[[xX]\]/, "[ ]"); } }
120
+ return line;
121
+ });
122
+ return changed ? next.join("\n") : raw;
123
+ }
124
+
125
+ export function normalizeTaskStructure(raw: string): string {
126
+ const lines = raw.split(/\r?\n/);
127
+ const firstHeading = lines.findIndex((line) => TASK_HEADING.test(line));
128
+ const end = firstHeading < 0 ? lines.length : firstHeading;
129
+ return lines.map((line, index) => {
130
+ if (index >= end) return line;
131
+ return NUMBERED_ITEM.test(line) ? line.replace(/\[[ xX]\]/, "[ ]") : line;
132
+ }).join("\n");
133
+ }
134
+
135
+ export function markTasksPendingFromMarkdown(raw: string, fromId: number): string {
136
+ const lines = raw.split(/\r?\n/);
137
+ const checklistEnd = lines.findIndex((line) => TASK_HEADING.test(line));
138
+ const end = checklistEnd < 0 ? lines.length : checklistEnd;
139
+ let plainIndex = 0;
140
+ let changed = false;
141
+ const next = lines.map((line, index) => {
142
+ if (index >= end) return line;
143
+ const numbered = line.match(NUMBERED_ITEM);
144
+ if (numbered && Number(numbered[2]) >= fromId && /[xX]/.test(numbered[1])) {
145
+ changed = true;
146
+ return line.replace(/\[[xX]\]/, "[ ]");
147
+ }
148
+ if (!numbered && PLAIN_ITEM.test(line)) {
149
+ plainIndex += 1;
150
+ if (plainIndex >= fromId && /\[[xX]\]/.test(line)) {
151
+ changed = true;
152
+ return line.replace(/\[[xX]\]/, "[ ]");
153
+ }
154
+ }
155
+ return line;
156
+ });
157
+ return changed ? next.join("\n") : raw;
158
+ }
159
+
160
+ export function hasStrictNumberedChecklist(raw: string): boolean {
161
+ const { checklist } = splitChecklist(raw);
162
+ const taskLines = checklist.split(/\r?\n/).filter((line) => /^[-*]\s+\[[ xX]\]\s+/.test(line));
163
+ return taskLines.length > 0 && taskLines.every((line) => NUMBERED_ITEM.test(line));
164
+ }
@@ -1,39 +1,33 @@
1
1
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { lstatSync } from "node:fs";
2
3
  import { resolve } from "node:path";
3
- import { CONFIG_FILE_NAME, PLAN_DIR_NAME, PLAN_FILE_NAME, TASK_FILE_NAME } from "./types.ts";
4
-
5
- export function planDir(cwd: string): string {
6
- return resolve(cwd, PLAN_DIR_NAME);
7
- }
8
-
9
- export function planFilePath(cwd: string): string {
10
- return resolve(cwd, PLAN_DIR_NAME, PLAN_FILE_NAME);
11
- }
12
-
13
- export function taskFilePath(cwd: string): string {
14
- return resolve(cwd, PLAN_DIR_NAME, TASK_FILE_NAME);
15
- }
16
-
17
- export function globalConfigPath(): string {
18
- return resolve(getAgentDir(), CONFIG_FILE_NAME);
19
- }
20
-
21
- export function projectConfigPath(cwd: string): string {
22
- return resolve(cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME);
23
- }
24
-
25
- export function samePath(a: string, b: string): boolean {
26
- const left = resolve(a);
27
- const right = resolve(b);
28
- return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
4
+ import { CONFIG_FILE_NAME, DRAFT_DIR_NAME, HISTORY_DIR_NAME, PLAN_DIR_NAME, PLAN_FILE_NAME, STATE_FILE_NAME, TASK_FILE_NAME } from "./types.ts";
5
+
6
+ export function planDir(cwd: string): string { return resolve(cwd, PLAN_DIR_NAME); }
7
+ export function planFilePath(cwd: string): string { return resolve(cwd, PLAN_DIR_NAME, PLAN_FILE_NAME); }
8
+ export function taskFilePath(cwd: string): string { return resolve(cwd, PLAN_DIR_NAME, TASK_FILE_NAME); }
9
+ export function stateFilePath(cwd: string): string { return resolve(cwd, PLAN_DIR_NAME, STATE_FILE_NAME); }
10
+ export function historyDir(cwd: string): string { return resolve(cwd, PLAN_DIR_NAME, HISTORY_DIR_NAME); }
11
+ export function draftDir(cwd: string): string { return resolve(cwd, PLAN_DIR_NAME, DRAFT_DIR_NAME); }
12
+ export function draftPlanFilePath(cwd: string): string { return resolve(draftDir(cwd), PLAN_FILE_NAME); }
13
+ export function draftTaskFilePath(cwd: string): string { return resolve(draftDir(cwd), TASK_FILE_NAME); }
14
+ export function globalConfigPath(): string { return resolve(getAgentDir(), CONFIG_FILE_NAME); }
15
+ export function projectConfigPath(cwd: string): string { return resolve(cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME); }
16
+ export function samePath(a: string, b: string): boolean { const left = resolve(a), right = resolve(b); return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right; }
17
+ export function stripAtPrefix(filePath: string): string { return filePath.startsWith("@") ? filePath.slice(1) : filePath; }
18
+
19
+ function isSafeArtifact(cwd: string, absolute: string): boolean {
20
+ try {
21
+ if (lstatSync(planDir(cwd)).isSymbolicLink()) return false;
22
+ if (lstatSync(draftDir(cwd)).isSymbolicLink()) return false;
23
+ return !lstatSync(absolute).isSymbolicLink();
24
+ } catch (error) {
25
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
26
+ }
29
27
  }
30
28
 
31
29
  export function isPlanArtifactPath(cwd: string, filePath: string): boolean {
32
30
  const absolute = resolve(cwd, stripAtPrefix(filePath));
33
- return samePath(absolute, planFilePath(cwd)) || samePath(absolute, taskFilePath(cwd));
31
+ if (!samePath(absolute, draftPlanFilePath(cwd)) && !samePath(absolute, draftTaskFilePath(cwd))) return false;
32
+ return isSafeArtifact(cwd, absolute);
34
33
  }
35
-
36
- export function stripAtPrefix(filePath: string): string {
37
- return filePath.startsWith("@") ? filePath.slice(1) : filePath;
38
- }
39
-
@@ -24,8 +24,9 @@ Before writing any code, operate in read-only mode:
24
24
  - Identify existing patterns and conventions
25
25
  - Map dependencies between components
26
26
  - Note risks and unknowns
27
+ - Resolve consequential user-answerable decisions before finalizing the plan; leave only non-blocking follow-ups in Open Questions
27
28
 
28
- **Do NOT write code during planning.** The output is a plan document saved to `.plan_task/plan.md` and a task list saved to `.plan_task/task.md`, not implementation.
29
+ **Do NOT write code during planning.** The output is a plan document saved to `.plan_task/draft/plan.md` and a task list saved to `.plan_task/draft/task.md`, not implementation.
29
30
 
30
31
  ### Step 2: Identify the Dependency Graph
31
32
 
@@ -137,16 +138,16 @@ If a task is L or larger, it should be broken into smaller tasks. An agent perfo
137
138
 
138
139
  ## Output Files
139
140
 
140
- - **Plan document:** Save the implementation plan to `.plan_task/plan.md`. This is always a markdown file — design decisions, risks, and open questions don't map cleanly onto individual tracker issues.
141
- - **Task list:** Record each task in `.plan_task/task.md`.
141
+ - **Plan document:** Save the implementation plan to `.plan_task/draft/plan.md`. This is always a markdown file — design decisions, risks, and open questions don't map cleanly onto individual tracker issues.
142
+ - **Task list:** Record each task in `.plan_task/draft/task.md`.
142
143
 
143
144
  Create the `.plan_task/` directory if it does not exist.
144
145
 
145
- The checklist at the top of `.plan_task/task.md` is the source of truth for `/build`. Keep items in the form `- [ ] N. Title` so later sessions can resume.
146
+ The checklist at the top of `.plan_task/draft/task.md` becomes the source of truth for `/build` after validation and submission. Keep items in the exact form `- [ ] N. Title` so later sessions can resume.
146
147
 
147
148
  After the checklist, write one `## Task [N]:` section per item using the Step 4 structure. Put `## Checkpoint:` sections after the tasks they cover, not in the top checklist.
148
149
 
149
- Example `.plan_task/task.md`:
150
+ Example `.plan_task/draft/task.md`:
150
151
 
151
152
  ```markdown
152
153
  - [ ] 1. User can create an account
@@ -240,7 +241,7 @@ Example `.plan_task/task.md`:
240
241
  | [Risk] | [High/Med/Low] | [Strategy] |
241
242
 
242
243
  ## Open Questions
243
- - [Question needing human input]
244
+ - [Optional non-blocking follow-up; resolve blocking decisions before writing the final plan]
244
245
  ```
245
246
 
246
247
  ## Parallelization Opportunities
@@ -263,7 +264,7 @@ When multiple agents or sessions are available:
263
264
  ## Red Flags
264
265
 
265
266
  - Starting implementation without a written task list
266
- - Writing tasks somewhere other than `.plan_task/task.md`
267
+ - Writing tasks somewhere other than `.plan_task/draft/task.md`
267
268
  - Tasks that say "implement the feature" without acceptance criteria
268
269
  - No verification steps in the plan
269
270
  - All tasks are XL-sized
@@ -277,7 +278,7 @@ Before starting implementation, confirm:
277
278
  - [ ] Every task has acceptance criteria
278
279
  - [ ] Every task has a verification step
279
280
  - [ ] Task dependencies are identified and ordered correctly
280
- - [ ] Tasks are recorded in `.plan_task/task.md`
281
+ - [ ] Tasks are recorded in `.plan_task/draft/task.md`
281
282
  - [ ] No task touches more than ~5 files
282
283
  - [ ] Checkpoints exist between major phases
283
284
  - [ ] The human has reviewed and approved the plan
@@ -285,3 +286,4 @@ Before starting implementation, confirm:
285
286
  ## See Also
286
287
 
287
288
  Acceptance criteria are per-task and answer "did we build the right thing?". They sit on top of any project-wide Definition of Done, the standing bar every task clears before it counts as done.
289
+
@@ -14,7 +14,20 @@ describe("loadPlanningMethod", () => {
14
14
  assert.doesNotMatch(method, /^---/);
15
15
  assert.doesNotMatch(method, /This skill/);
16
16
  assert.doesNotMatch(method, /tasks\/todo\.md/);
17
- assert.match(method, /\.plan_task\/plan\.md/);
18
- assert.match(method, /\.plan_task\/task\.md/);
17
+ assert.match(method, /\.plan_task\/draft\/plan\.md/);
18
+ assert.match(method, /\.plan_task\/draft\/task\.md/);
19
+ assert.doesNotMatch(method, /`\.plan_task\/plan\.md`/);
20
+ assert.doesNotMatch(method, /`\.plan_task\/task\.md`/);
21
+ assert.match(method, /- \[ \] N\. Title/);
22
+ assert.match(method, /\*\*Description:\*\*/);
23
+ assert.match(method, /\*\*Acceptance criteria:\*\*/);
24
+ assert.match(method, /\*\*Verification:\*\*/);
25
+ assert.match(method, /\*\*Dependencies:\*\*/);
26
+ assert.match(method, /\*\*Files likely touched:\*\*/);
27
+ assert.match(method, /\*\*Estimated scope:\*\*/);
28
+ assert.match(method, /vertical slice/i);
29
+ assert.match(method, /Checkpoints exist between major phases/);
30
+ assert.match(method, /leave only non-blocking follow-ups in Open Questions/);
31
+ assert.doesNotMatch(method, /\[Question needing human input\]/);
19
32
  });
20
33
  });
@@ -9,8 +9,10 @@ import { fileURLToPath } from "node:url";
9
9
  * root so later updates can be copied section-by-section from that file.
10
10
  *
11
11
  * Plan-task-only adaptations (do not copy these from the source blindly):
12
- * - Output paths are `.plan_task/plan.md` and `.plan_task/task.md`
13
- * - Checklist items must be `- [ ] N. Title`
12
+ * - Planning output paths are `.plan_task/draft/plan.md` and `.plan_task/draft/task.md`
13
+ * - Submitted plan/task files are never written directly in Plan mode
14
+ * - Checklist items must be `- [ ] N. Title` with unique positive IDs
15
+ * - Blocking questions are resolved before finalization; only non-blocking Open Questions remain
14
16
  * - No external tracker, no skill frontmatter, no definition-of-done.md link
15
17
  */
16
18
  export const PLANNING_METHOD_FILE = resolve(
@@ -0,0 +1,11 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isAllowedPlanTool } from "./tool-policy.ts";
4
+ describe("plan policy", () => {
5
+ it("allows only read tools and coordination tools", () => {
6
+ assert.equal(isAllowedPlanTool("read"), true);
7
+ assert.equal(isAllowedPlanTool("bash"), false);
8
+ assert.equal(isAllowedPlanTool("write"), false);
9
+ assert.equal(isAllowedPlanTool("unknown"), false);
10
+ });
11
+ });