pi-plan-task 1.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 +100 -0
- package/extensions/ask-question.test.ts +91 -0
- package/extensions/ask-question.ts +145 -0
- package/extensions/bash-guard.test.ts +17 -0
- package/extensions/bash-guard.ts +94 -0
- package/extensions/config.ts +49 -0
- package/extensions/files.test.ts +59 -0
- package/extensions/files.ts +50 -0
- package/extensions/framing.test.ts +85 -0
- package/extensions/framing.ts +79 -0
- package/extensions/index.ts +549 -0
- package/extensions/parse.ts +88 -0
- package/extensions/paths.ts +39 -0
- package/extensions/plan-input.test.ts +142 -0
- package/extensions/plan-input.ts +162 -0
- package/extensions/planning-and-task-breakdown.md +287 -0
- package/extensions/planning-method.test.ts +20 -0
- package/extensions/planning-method.ts +38 -0
- package/extensions/prompts.test.ts +70 -0
- package/extensions/prompts.ts +87 -0
- package/extensions/types.ts +26 -0
- package/package.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# pi-plan-task
|
|
2
|
+
|
|
3
|
+
One Pi package: `/plan`, `/build`, `/goal`, and `/tasks`. Progress lives on disk, so a new session or a Pi restart can continue from the next unfinished task.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
| Command | What it does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `/plan [file|goal]` | Read-only planning. Writes `.plan_task/plan.md` and `.plan_task/task.md`. |
|
|
10
|
+
| `/build` | Execute the next unfinished task, then ask whether to continue here or in a new session. |
|
|
11
|
+
| `/goal` | Execute remaining tasks until `task.md` is complete. No session prompts. |
|
|
12
|
+
| `/tasks` | Show the current task list and progress. |
|
|
13
|
+
|
|
14
|
+
Restarting Pi does not auto-start work. Run `/build` again.
|
|
15
|
+
|
|
16
|
+
`/plan` accepts a spec file, a prompt, or a file plus extra notes:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
/plan
|
|
20
|
+
/plan Add login with OAuth
|
|
21
|
+
/plan docs/auth-spec.md
|
|
22
|
+
/plan docs/auth-spec.md focus on the callback flow
|
|
23
|
+
/plan @docs/auth-spec.md
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
If the first argument is an existing file — or looks like a path such as `./spec.md`, `notes.txt`, or `@spec.md` — that file is the spec. Anything after it is extra planning guidance. Missing explicit paths are rejected instead of being treated as a prompt.
|
|
27
|
+
|
|
28
|
+
## Planning method
|
|
29
|
+
|
|
30
|
+
`/plan` uses `extensions/planning-and-task-breakdown.md` as the planning prompt.
|
|
31
|
+
|
|
32
|
+
That file keeps the same section headings as `planning-and-task-breakdown.md`, so later methodology edits can be copied section-by-section from that source. Plan-task only changes output paths and the checklist form `/build` needs:
|
|
33
|
+
|
|
34
|
+
- `.plan_task/plan.md` and `.plan_task/task.md`
|
|
35
|
+
- checklist lines in the form `- [ ] N. Title`
|
|
36
|
+
|
|
37
|
+
This package does not install or load a skill. It also does not modify Pi's system prompt.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## Prompt injection
|
|
41
|
+
|
|
42
|
+
Phase instructions are conversation messages, not system-prompt patches:
|
|
43
|
+
|
|
44
|
+
- `/plan` and `/build` send a short visible user message to start the turn.
|
|
45
|
+
- The planning method or current-task instructions are injected once, as a hidden message, when that phase or task starts.
|
|
46
|
+
- Later turns in the same phase inject nothing unless the checklist actually changed; then a small build-status message is appended.
|
|
47
|
+
- `context` keeps only the newest framing for the current phase. Stale plan/build messages are dropped from the model context. While idle, every injected message is filtered out.
|
|
48
|
+
|
|
49
|
+
Session history still stores the injected messages. Filtering is non-destructive.
|
|
50
|
+
|
|
51
|
+
## Ask user
|
|
52
|
+
|
|
53
|
+
`ask_user_question` is available in every mode, not just `/plan`. Use it for consequential choices the repo cannot answer:
|
|
54
|
+
|
|
55
|
+
- 2-4 selectable options
|
|
56
|
+
- optional recommended default, shown with ★
|
|
57
|
+
- free-form Other path (on by default; set `allowOther: false` to hide it)
|
|
58
|
+
|
|
59
|
+
In the TUI, pick an option or choose **Other / type my answer**. Without a UI, the tool asks the agent to pose the question in chat.
|
|
60
|
+
|
|
61
|
+
## Files
|
|
62
|
+
|
|
63
|
+
Created in the current project:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
.plan_task/plan.md
|
|
67
|
+
.plan_task/task.md
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`task.md` must start with numbered checklist lines:
|
|
71
|
+
|
|
72
|
+
```markdown
|
|
73
|
+
- [ ] 1. Add login API
|
|
74
|
+
- [ ] 2. Add login UI
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`/build` resumes from the first unchecked item.
|
|
78
|
+
|
|
79
|
+
## Config
|
|
80
|
+
|
|
81
|
+
Allowed `/plan` tools:
|
|
82
|
+
|
|
83
|
+
- Global: `~/.pi/agent/plan_task.json`
|
|
84
|
+
- Project override: `.pi/plan_task.json`
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"planTools": ["read", "bash", "grep", "find", "ls", "pwsh", "rg"]
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
During `/plan`, `write` and `edit` stay available but can only touch the two plan files. Bash is limited to read-only commands.
|
|
93
|
+
|
|
94
|
+
## Install
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pi install D:/work/tools/pi-plan-task
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Or add the path to `packages` in `~/.pi/agent/settings.json`, then restart Pi or run `/reload`.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
OTHER_LABEL,
|
|
5
|
+
questionDisplayLabels,
|
|
6
|
+
validateQuestionParams,
|
|
7
|
+
} from "./ask-question.ts";
|
|
8
|
+
|
|
9
|
+
const options = [
|
|
10
|
+
{ label: "SQLite", description: "Local file" },
|
|
11
|
+
{ label: "Postgres", description: "Shared DB" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("validateQuestionParams", () => {
|
|
15
|
+
it("accepts 2-4 unique labels and a matching recommended option", () => {
|
|
16
|
+
const result = validateQuestionParams({
|
|
17
|
+
question: "Which database?",
|
|
18
|
+
options,
|
|
19
|
+
recommended: "postgres",
|
|
20
|
+
});
|
|
21
|
+
assert.equal(result.recommendedIndex, 1);
|
|
22
|
+
assert.equal(result.options.length, 2);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects blank, duplicate, Other-conflicting, and unmatched recommended labels", () => {
|
|
26
|
+
assert.throws(() => validateQuestionParams({ question: "Q", options: [{ label: "A" }, { label: " " }] }), /non-blank/);
|
|
27
|
+
assert.throws(
|
|
28
|
+
() => validateQuestionParams({ question: "Q", options: [{ label: "A" }, { label: "A" }] }),
|
|
29
|
+
/unique/,
|
|
30
|
+
);
|
|
31
|
+
assert.throws(
|
|
32
|
+
() => validateQuestionParams({ question: "Q", options: [{ label: "A" }, { label: "Other path" }] }),
|
|
33
|
+
/Other/,
|
|
34
|
+
);
|
|
35
|
+
assert.throws(
|
|
36
|
+
() => validateQuestionParams({ question: "Q", options, recommended: "Redis" }),
|
|
37
|
+
/recommended/,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("questionDisplayLabels", () => {
|
|
43
|
+
it("marks the recommended option with a star", () => {
|
|
44
|
+
assert.deepEqual(questionDisplayLabels(options, 1), [
|
|
45
|
+
"SQLite — Local file",
|
|
46
|
+
"★ Postgres — Shared DB",
|
|
47
|
+
]);
|
|
48
|
+
assert.equal(OTHER_LABEL, "Other / type my answer");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("executeAskQuestion", () => {
|
|
53
|
+
it("asks in chat when no UI is available", async () => {
|
|
54
|
+
const { executeAskQuestion } = await import("./ask-question.ts");
|
|
55
|
+
const result = await executeAskQuestion(
|
|
56
|
+
{ question: "Which database?", options },
|
|
57
|
+
{ hasUI: false } as never,
|
|
58
|
+
);
|
|
59
|
+
assert.match(result.content[0]?.text ?? "", /UI is not available/);
|
|
60
|
+
assert.equal(result.details.answer, null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("selects the starred option and accepts a free-form Other answer", async () => {
|
|
64
|
+
const { executeAskQuestion, OTHER_LABEL } = await import("./ask-question.ts");
|
|
65
|
+
const selected = await executeAskQuestion(
|
|
66
|
+
{ question: "Which database?", options, recommended: "Postgres" },
|
|
67
|
+
{
|
|
68
|
+
hasUI: true,
|
|
69
|
+
ui: {
|
|
70
|
+
select: async (_question: string, labels: string[]) => labels.find((label) => label.startsWith("★")),
|
|
71
|
+
editor: async () => undefined,
|
|
72
|
+
},
|
|
73
|
+
} as never,
|
|
74
|
+
);
|
|
75
|
+
assert.equal(selected.details.answer, "Postgres");
|
|
76
|
+
assert.equal(selected.details.wasCustom, false);
|
|
77
|
+
|
|
78
|
+
const custom = await executeAskQuestion(
|
|
79
|
+
{ question: "Which database?", options },
|
|
80
|
+
{
|
|
81
|
+
hasUI: true,
|
|
82
|
+
ui: {
|
|
83
|
+
select: async () => OTHER_LABEL,
|
|
84
|
+
editor: async () => " Redis ",
|
|
85
|
+
},
|
|
86
|
+
} as never,
|
|
87
|
+
);
|
|
88
|
+
assert.equal(custom.details.answer, "Redis");
|
|
89
|
+
assert.equal(custom.details.wasCustom, true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const ASK_USER_QUESTION_TOOL = "ask_user_question";
|
|
4
|
+
export const OTHER_LABEL = "Other / type my answer";
|
|
5
|
+
|
|
6
|
+
export interface QuestionOption {
|
|
7
|
+
label: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface QuestionParams {
|
|
12
|
+
question: string;
|
|
13
|
+
options: QuestionOption[];
|
|
14
|
+
recommended?: string;
|
|
15
|
+
allowOther?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ASK_QUESTION_GUIDELINES = [
|
|
19
|
+
"Use only when repo research leaves a consequential ambiguity.",
|
|
20
|
+
"Prefer 2-4 concrete options. Use short labels.",
|
|
21
|
+
"Don't ask what's discoverable from repo.",
|
|
22
|
+
"Respect user's stated preference.",
|
|
23
|
+
"Provide a recommended option when one choice is clearly preferable.",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
export function validateQuestionParams(params: QuestionParams): {
|
|
28
|
+
options: QuestionOption[];
|
|
29
|
+
recommendedIndex: number | null;
|
|
30
|
+
} {
|
|
31
|
+
const question = params.question?.trim() ?? "";
|
|
32
|
+
if (!question) throw new Error("question must be non-blank.");
|
|
33
|
+
const options = params.options ?? [];
|
|
34
|
+
if (options.length < 2 || options.length > 4) {
|
|
35
|
+
throw new Error("Provide 2-4 options.");
|
|
36
|
+
}
|
|
37
|
+
const labels = options.map((option) => option.label.trim());
|
|
38
|
+
if (labels.some((label) => !label)) {
|
|
39
|
+
throw new Error("Each option must have a non-blank label.");
|
|
40
|
+
}
|
|
41
|
+
if (new Set(labels).size !== labels.length) {
|
|
42
|
+
throw new Error("Option labels must be unique.");
|
|
43
|
+
}
|
|
44
|
+
if (labels.some((label) => label.toLowerCase() === "other" || label.toLowerCase().startsWith("other "))) {
|
|
45
|
+
throw new Error('Option labels cannot conflict with the "Other" label.');
|
|
46
|
+
}
|
|
47
|
+
let recommendedIndex: number | null = null;
|
|
48
|
+
if (params.recommended) {
|
|
49
|
+
const recommended = params.recommended.trim();
|
|
50
|
+
const matchIdx = labels.findIndex((label) => label.toLowerCase() === recommended.toLowerCase());
|
|
51
|
+
if (matchIdx === -1) {
|
|
52
|
+
throw new Error("recommended must match one of the option labels.");
|
|
53
|
+
}
|
|
54
|
+
recommendedIndex = matchIdx;
|
|
55
|
+
}
|
|
56
|
+
return { options, recommendedIndex };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function questionDisplayLabels(
|
|
60
|
+
options: QuestionOption[],
|
|
61
|
+
recommendedIndex: number | null,
|
|
62
|
+
): string[] {
|
|
63
|
+
return options.map((option, index) => {
|
|
64
|
+
const star = recommendedIndex !== null && index === recommendedIndex && options.length > 1 ? "★ " : "";
|
|
65
|
+
return option.description ? `${star}${option.label} — ${option.description}` : `${star}${option.label}`;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function textResult(
|
|
70
|
+
text: string,
|
|
71
|
+
details: Record<string, unknown>,
|
|
72
|
+
): { content: [{ type: "text"; text: string }]; details: Record<string, unknown> } {
|
|
73
|
+
return { content: [{ type: "text", text }], details };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function executeAskQuestion(
|
|
77
|
+
params: unknown,
|
|
78
|
+
ctx: ExtensionContext,
|
|
79
|
+
): Promise<{ content: [{ type: "text"; text: string }]; details: Record<string, unknown> }> {
|
|
80
|
+
const typed = params as QuestionParams;
|
|
81
|
+
let options: QuestionOption[];
|
|
82
|
+
let recommendedIndex: number | null;
|
|
83
|
+
try {
|
|
84
|
+
({ options, recommendedIndex } = validateQuestionParams(typed));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return textResult(error instanceof Error ? error.message : String(error), {
|
|
87
|
+
question: typed.question,
|
|
88
|
+
answer: null,
|
|
89
|
+
cancelled: false,
|
|
90
|
+
wasCustom: false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (!ctx.hasUI) {
|
|
94
|
+
return textResult("UI is not available. Ask this question directly in chat and wait for the user's answer.", {
|
|
95
|
+
question: typed.question,
|
|
96
|
+
options,
|
|
97
|
+
answer: null,
|
|
98
|
+
wasCustom: false,
|
|
99
|
+
cancelled: false,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const allowOther = typed.allowOther !== false;
|
|
103
|
+
const displayLabels = questionDisplayLabels(options, recommendedIndex);
|
|
104
|
+
const choice = await ctx.ui.select(typed.question, allowOther ? [...displayLabels, OTHER_LABEL] : displayLabels);
|
|
105
|
+
if (!choice) {
|
|
106
|
+
return textResult("User cancelled the question.", {
|
|
107
|
+
question: typed.question,
|
|
108
|
+
options,
|
|
109
|
+
answer: null,
|
|
110
|
+
cancelled: true,
|
|
111
|
+
wasCustom: false,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (choice === OTHER_LABEL) {
|
|
115
|
+
const answer = (await ctx.ui.editor("Your answer", ""))?.trim();
|
|
116
|
+
if (!answer) {
|
|
117
|
+
return textResult("User cancelled the question.", {
|
|
118
|
+
question: typed.question,
|
|
119
|
+
options,
|
|
120
|
+
answer: null,
|
|
121
|
+
cancelled: true,
|
|
122
|
+
wasCustom: false,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return textResult(`User wrote: ${answer}`, {
|
|
126
|
+
question: typed.question,
|
|
127
|
+
options,
|
|
128
|
+
answer,
|
|
129
|
+
wasCustom: true,
|
|
130
|
+
cancelled: false,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const selectedIndex = displayLabels.indexOf(choice);
|
|
134
|
+
const selected = options[selectedIndex];
|
|
135
|
+
const answer = selected?.label ?? choice;
|
|
136
|
+
return textResult(`User selected: ${answer}`, {
|
|
137
|
+
question: typed.question,
|
|
138
|
+
options,
|
|
139
|
+
answer,
|
|
140
|
+
selectedIndex,
|
|
141
|
+
recommendedIndex,
|
|
142
|
+
wasCustom: false,
|
|
143
|
+
cancelled: false,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isSafePlanCommand } from "./bash-guard.ts";
|
|
4
|
+
|
|
5
|
+
describe("isSafePlanCommand", () => {
|
|
6
|
+
it("allows read-only inspection", () => {
|
|
7
|
+
assert.equal(isSafePlanCommand("ls src"), true);
|
|
8
|
+
assert.equal(isSafePlanCommand("git status"), true);
|
|
9
|
+
assert.equal(isSafePlanCommand("rg TODO"), true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("blocks writes and destructive git", () => {
|
|
13
|
+
assert.equal(isSafePlanCommand("rm -rf src"), false);
|
|
14
|
+
assert.equal(isSafePlanCommand("git commit -m wip"), false);
|
|
15
|
+
assert.equal(isSafePlanCommand("echo hi > file.txt"), false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
2
|
+
/\brm\b/i,
|
|
3
|
+
/\brmdir\b/i,
|
|
4
|
+
/\bmv\b/i,
|
|
5
|
+
/\bcp\b/i,
|
|
6
|
+
/\bmkdir\b/i,
|
|
7
|
+
/\btouch\b/i,
|
|
8
|
+
/\bchmod\b/i,
|
|
9
|
+
/\bchown\b/i,
|
|
10
|
+
/\bchgrp\b/i,
|
|
11
|
+
/\bln\b/i,
|
|
12
|
+
/\btee\b/i,
|
|
13
|
+
/\btruncate\b/i,
|
|
14
|
+
/\bdd\b/i,
|
|
15
|
+
/\bshred\b/i,
|
|
16
|
+
/(^|[^<])>(?!>)/,
|
|
17
|
+
/>>/,
|
|
18
|
+
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
19
|
+
/\byarn\s+(add|remove|install|publish)/i,
|
|
20
|
+
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
21
|
+
/\bpip\s+(install|uninstall)/i,
|
|
22
|
+
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
23
|
+
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
24
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
25
|
+
/\bsudo\b/i,
|
|
26
|
+
/\bsu\b/i,
|
|
27
|
+
/\bkill\b/i,
|
|
28
|
+
/\bpkill\b/i,
|
|
29
|
+
/\bkillall\b/i,
|
|
30
|
+
/\breboot\b/i,
|
|
31
|
+
/\bshutdown\b/i,
|
|
32
|
+
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
33
|
+
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
34
|
+
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const SAFE_PATTERNS = [
|
|
38
|
+
/^\s*cat\b/,
|
|
39
|
+
/^\s*head\b/,
|
|
40
|
+
/^\s*tail\b/,
|
|
41
|
+
/^\s*less\b/,
|
|
42
|
+
/^\s*more\b/,
|
|
43
|
+
/^\s*grep\b/,
|
|
44
|
+
/^\s*find\b/,
|
|
45
|
+
/^\s*ls\b/,
|
|
46
|
+
/^\s*pwd\b/,
|
|
47
|
+
/^\s*echo\b/,
|
|
48
|
+
/^\s*printf\b/,
|
|
49
|
+
/^\s*wc\b/,
|
|
50
|
+
/^\s*sort\b/,
|
|
51
|
+
/^\s*uniq\b/,
|
|
52
|
+
/^\s*diff\b/,
|
|
53
|
+
/^\s*file\b/,
|
|
54
|
+
/^\s*stat\b/,
|
|
55
|
+
/^\s*du\b/,
|
|
56
|
+
/^\s*df\b/,
|
|
57
|
+
/^\s*tree\b/,
|
|
58
|
+
/^\s*which\b/,
|
|
59
|
+
/^\s*whereis\b/,
|
|
60
|
+
/^\s*type\b/,
|
|
61
|
+
/^\s*env\b/,
|
|
62
|
+
/^\s*printenv\b/,
|
|
63
|
+
/^\s*uname\b/,
|
|
64
|
+
/^\s*whoami\b/,
|
|
65
|
+
/^\s*id\b/,
|
|
66
|
+
/^\s*date\b/,
|
|
67
|
+
/^\s*cal\b/,
|
|
68
|
+
/^\s*uptime\b/,
|
|
69
|
+
/^\s*ps\b/,
|
|
70
|
+
/^\s*top\b/,
|
|
71
|
+
/^\s*htop\b/,
|
|
72
|
+
/^\s*free\b/,
|
|
73
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
|
74
|
+
/^\s*git\s+ls-/i,
|
|
75
|
+
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
|
76
|
+
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
77
|
+
/^\s*node\s+--version/i,
|
|
78
|
+
/^\s*python\s+--version/i,
|
|
79
|
+
/^\s*curl\s/i,
|
|
80
|
+
/^\s*wget\s+-O\s*-/i,
|
|
81
|
+
/^\s*jq\b/,
|
|
82
|
+
/^\s*sed\s+-n/i,
|
|
83
|
+
/^\s*awk\b/,
|
|
84
|
+
/^\s*rg\b/,
|
|
85
|
+
/^\s*fd\b/,
|
|
86
|
+
/^\s*bat\b/,
|
|
87
|
+
/^\s*eza\b/,
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export function isSafePlanCommand(command: string): boolean {
|
|
91
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command));
|
|
92
|
+
const isSafe = SAFE_PATTERNS.some((pattern) => pattern.test(command));
|
|
93
|
+
return !isDestructive && isSafe;
|
|
94
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
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
|
+
|
|
10
|
+
function parseConfig(raw: string): Partial<PlanTaskConfig> {
|
|
11
|
+
const parsed: unknown = JSON.parse(raw);
|
|
12
|
+
if (!isRecord(parsed)) return {};
|
|
13
|
+
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
|
+
}
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
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
|
+
|
|
40
|
+
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
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { markTaskDoneInMarkdown, parseTaskMarkdown } from "./parse.ts";
|
|
4
|
+
|
|
5
|
+
const SAMPLE = `# Tasks
|
|
6
|
+
|
|
7
|
+
- [ ] 1. Add login API
|
|
8
|
+
- [ ] 2. Add login UI
|
|
9
|
+
- [x] 3. Already done
|
|
10
|
+
|
|
11
|
+
## Task 1: Add login API
|
|
12
|
+
|
|
13
|
+
**Description:** Create the endpoint.
|
|
14
|
+
**Acceptance criteria:**
|
|
15
|
+
- [ ] Returns 200
|
|
16
|
+
- [ ] Validates input
|
|
17
|
+
|
|
18
|
+
## Task 2: Add login UI
|
|
19
|
+
|
|
20
|
+
**Description:** Build the form.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
describe("parseTaskMarkdown", () => {
|
|
24
|
+
it("reads numbered checklist items and ignores nested boxes", () => {
|
|
25
|
+
const tasks = parseTaskMarkdown(SAMPLE);
|
|
26
|
+
assert.equal(tasks.length, 3);
|
|
27
|
+
assert.deepEqual(
|
|
28
|
+
tasks.map((task) => ({ id: task.id, title: task.title, done: task.done })),
|
|
29
|
+
[
|
|
30
|
+
{ id: 1, title: "Add login API", done: false },
|
|
31
|
+
{ id: 2, title: "Add login UI", done: false },
|
|
32
|
+
{ id: 3, title: "Already done", done: true },
|
|
33
|
+
],
|
|
34
|
+
);
|
|
35
|
+
assert.match(tasks[0]?.body ?? "", /Create the endpoint/);
|
|
36
|
+
assert.match(tasks[1]?.body ?? "", /Build the form/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("falls back to unnumbered checklist items", () => {
|
|
40
|
+
const tasks = parseTaskMarkdown(`# Tasks\n\n- [ ] First\n- [x] Second\n`);
|
|
41
|
+
assert.equal(tasks.length, 2);
|
|
42
|
+
assert.equal(tasks[0]?.id, 1);
|
|
43
|
+
assert.equal(tasks[0]?.title, "First");
|
|
44
|
+
assert.equal(tasks[1]?.done, true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("markTaskDoneInMarkdown", () => {
|
|
49
|
+
it("checks the matching numbered item only", () => {
|
|
50
|
+
const next = markTaskDoneInMarkdown(SAMPLE, 2);
|
|
51
|
+
assert.match(next, /- \[x\] 2\. Add login UI/);
|
|
52
|
+
assert.match(next, /- \[ \] 1\. Add login API/);
|
|
53
|
+
assert.match(next, /- \[ \] Returns 200/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("is a no-op when the item is already done", () => {
|
|
57
|
+
assert.equal(markTaskDoneInMarkdown(SAMPLE, 3), SAMPLE);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { planDir, planFilePath, taskFilePath } from "./paths.ts";
|
|
3
|
+
import { markTaskDoneInMarkdown, parseTaskMarkdown } from "./parse.ts";
|
|
4
|
+
import type { TaskFile, TaskItem } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export { markTaskDoneInMarkdown, parseTaskMarkdown } from "./parse.ts";
|
|
7
|
+
|
|
8
|
+
export async function ensurePlanDir(cwd: string): Promise<void> {
|
|
9
|
+
await mkdir(planDir(cwd), { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readOptionalFile(path: string): Promise<string | undefined> {
|
|
13
|
+
try {
|
|
14
|
+
return await readFile(path, "utf8");
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function loadTaskFile(cwd: string): Promise<TaskFile | undefined> {
|
|
21
|
+
const raw = await readOptionalFile(taskFilePath(cwd));
|
|
22
|
+
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
|
+
}
|
|
39
|
+
}
|
|
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
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatProgress(tasks: TaskItem[]): string {
|
|
48
|
+
const done = tasks.filter((task) => task.done).length;
|
|
49
|
+
return `${done}/${tasks.length}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
BUILD_FRAMING_TYPE,
|
|
5
|
+
BUILD_STATUS_TYPE,
|
|
6
|
+
filterFramingMessages,
|
|
7
|
+
INITIAL_FRAMING_STATE,
|
|
8
|
+
nextInjection,
|
|
9
|
+
PLAN_FRAMING_TYPE,
|
|
10
|
+
rememberInjection,
|
|
11
|
+
} from "./framing.ts";
|
|
12
|
+
|
|
13
|
+
function custom(customType: string, content: string) {
|
|
14
|
+
return { role: "custom" as const, customType, content };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("filterFramingMessages", () => {
|
|
18
|
+
const messages = [
|
|
19
|
+
{ role: "user", content: "goal" },
|
|
20
|
+
custom(PLAN_FRAMING_TYPE, "plan-old"),
|
|
21
|
+
{ role: "assistant", content: "ok" },
|
|
22
|
+
custom(PLAN_FRAMING_TYPE, "plan-new"),
|
|
23
|
+
custom(BUILD_FRAMING_TYPE, "build-old"),
|
|
24
|
+
custom(BUILD_STATUS_TYPE, "status-old"),
|
|
25
|
+
custom(BUILD_FRAMING_TYPE, "build-new"),
|
|
26
|
+
custom(BUILD_STATUS_TYPE, "status-new"),
|
|
27
|
+
{ role: "user", content: "continue" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
it("drops every injected message while idle", () => {
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
filterFramingMessages(messages, "idle"),
|
|
33
|
+
[
|
|
34
|
+
{ role: "user", content: "goal" },
|
|
35
|
+
{ role: "assistant", content: "ok" },
|
|
36
|
+
{ role: "user", content: "continue" },
|
|
37
|
+
],
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps only the newest plan framing", () => {
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
filterFramingMessages(messages, "plan"),
|
|
44
|
+
[
|
|
45
|
+
{ role: "user", content: "goal" },
|
|
46
|
+
{ role: "assistant", content: "ok" },
|
|
47
|
+
custom(PLAN_FRAMING_TYPE, "plan-new"),
|
|
48
|
+
{ role: "user", content: "continue" },
|
|
49
|
+
],
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps only the newest build framing and status", () => {
|
|
54
|
+
assert.deepEqual(
|
|
55
|
+
filterFramingMessages(messages, "build"),
|
|
56
|
+
[
|
|
57
|
+
{ role: "user", content: "goal" },
|
|
58
|
+
{ role: "assistant", content: "ok" },
|
|
59
|
+
custom(BUILD_FRAMING_TYPE, "build-new"),
|
|
60
|
+
custom(BUILD_STATUS_TYPE, "status-new"),
|
|
61
|
+
{ role: "user", content: "continue" },
|
|
62
|
+
],
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("nextInjection", () => {
|
|
68
|
+
it("injects plan framing once per phase entry", () => {
|
|
69
|
+
assert.equal(nextInjection("plan", INITIAL_FRAMING_STATE), "plan-framing");
|
|
70
|
+
const delivered = rememberInjection(INITIAL_FRAMING_STATE, "plan-framing");
|
|
71
|
+
assert.equal(nextInjection("plan", delivered), undefined);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("injects build framing per task, then status only when the checklist changes", () => {
|
|
75
|
+
assert.equal(nextInjection("build", INITIAL_FRAMING_STATE, 1, "1:1:0"), "build-framing");
|
|
76
|
+
const framed = rememberInjection(INITIAL_FRAMING_STATE, "build-framing", 1, "1:1:0");
|
|
77
|
+
assert.equal(nextInjection("build", framed, 1, "1:1:0"), undefined);
|
|
78
|
+
assert.equal(nextInjection("build", framed, 1, "1:1:1"), "build-status");
|
|
79
|
+
assert.equal(nextInjection("build", framed, 2, "2:1:1"), "build-framing");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("injects nothing while idle", () => {
|
|
83
|
+
assert.equal(nextInjection("idle", INITIAL_FRAMING_STATE), undefined);
|
|
84
|
+
});
|
|
85
|
+
});
|