pi-comprehensive-tasks 0.1.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/LICENSE +21 -0
- package/README.md +133 -0
- package/extensions/index.ts +306 -0
- package/extensions/render.ts +96 -0
- package/extensions/seed.ts +216 -0
- package/extensions/task-file.ts +204 -0
- package/package.json +55 -0
- package/skills/comprehensive-tasks/SKILL.md +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rizquuula
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# pi-comprehensive-tasks
|
|
2
|
+
|
|
3
|
+
A task tree in `TODO.md` for the [pi coding agent](https://pi.dev), seeded from a
|
|
4
|
+
[pi-comprehensive-planning](https://github.com/rizquuula/pi-comprehensive-planning) plan.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pi install npm:pi-comprehensive-tasks
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The plan is intent, written once and reviewed. The task list is live state, rewritten as
|
|
11
|
+
the work actually goes. Keeping those two in one file is how plans rot, so this package
|
|
12
|
+
keeps them apart.
|
|
13
|
+
|
|
14
|
+
```markdown
|
|
15
|
+
# Tasks
|
|
16
|
+
|
|
17
|
+
- [ ] Slice A: the file format <!-- plan:§9#A -->
|
|
18
|
+
- [x] parse the tree <!-- plan:§8#1 -->
|
|
19
|
+
- [ ] toggle a child <!-- plan:§8#2 -->
|
|
20
|
+
- [ ] Unassigned
|
|
21
|
+
- [ ] wiring only <!-- plan:§8#9 -->
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# from npm
|
|
28
|
+
pi install npm:pi-comprehensive-tasks
|
|
29
|
+
|
|
30
|
+
# from GitHub
|
|
31
|
+
pi install git:github.com/rizquuula/pi-comprehensive-tasks
|
|
32
|
+
|
|
33
|
+
# try it without installing anything
|
|
34
|
+
pi -e npm:pi-comprehensive-tasks
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Add `-l` to install into the current project instead of globally. Requires pi v0.85 or
|
|
38
|
+
newer. Nothing to compile — pi loads the TypeScript directly.
|
|
39
|
+
|
|
40
|
+
## Seeding from a plan
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
/tasks seed
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Reads `PLAN.md` and builds a two-level tree: each **§9 slice** becomes a parent, each
|
|
47
|
+
**§8 cycle** becomes a child of the slice that owns a file the cycle names.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
- [ ] A: the file format ← §9 slice
|
|
51
|
+
- [ ] `task-file.test.ts` … ← §8 cycle, filed here because slice A owns the file
|
|
52
|
+
- [ ] Unassigned
|
|
53
|
+
- [ ] wiring only ← §8 cycle whose files no slice claims
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Seeding is **idempotent**. Each item is tagged with its origin, so running it twice adds
|
|
57
|
+
nothing the second time and never touches work you have already done.
|
|
58
|
+
|
|
59
|
+
Two things worth knowing:
|
|
60
|
+
|
|
61
|
+
- **File paths must be in backticks** in §8 and §9. That is what the plan template does,
|
|
62
|
+
and it is how cycles are matched to slices. If they are missing, seeding says so rather
|
|
63
|
+
than silently doing nothing.
|
|
64
|
+
- **The match is a heuristic.** A cycle is filed under the first slice that owns a file it
|
|
65
|
+
names. Anything unmatched lands under `Unassigned`, which is visible on purpose.
|
|
66
|
+
|
|
67
|
+
No plan? The package works standalone. `/tasks add <text>` and the tools below do not
|
|
68
|
+
need a `PLAN.md` anywhere.
|
|
69
|
+
|
|
70
|
+
## The extension
|
|
71
|
+
|
|
72
|
+
**Three tools** the agent uses as it works:
|
|
73
|
+
|
|
74
|
+
| Tool | What it does |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `task_list` | The numbered tree plus progress. Numbers are stable positions in that list. |
|
|
77
|
+
| `task_add` | Adds a task, optionally under a parent and tagged with a plan ref. Adding a ref twice is a no-op. |
|
|
78
|
+
| `task_complete` | Ticks a task, or reopens it with `done: false`. |
|
|
79
|
+
|
|
80
|
+
**`/tasks`** for you:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
/tasks pick a task to toggle, repeatedly, until Esc
|
|
84
|
+
/tasks seed build the tree from PLAN.md
|
|
85
|
+
/tasks add <text> add a top-level task
|
|
86
|
+
/tasks done <ref|num> tick one
|
|
87
|
+
/tasks undone <ref|num> reopen one
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**A widget** under the editor: `TODO.md · 3/7 done`, with the tree beneath it. A branch
|
|
91
|
+
whose cycles are all finished collapses to one dimmed line carrying its count.
|
|
92
|
+
|
|
93
|
+
The glyph is always the task's own state, never a summary of its children. A slice whose
|
|
94
|
+
cycles are all done but which is itself unticked stays `[ ]` — that is the one thing left
|
|
95
|
+
to do, and reporting it as finished would be a lie.
|
|
96
|
+
|
|
97
|
+
## Working with pi-comprehensive-planning
|
|
98
|
+
|
|
99
|
+
They are independent packages. Neither imports the other, and each installs and versions
|
|
100
|
+
on its own.
|
|
101
|
+
|
|
102
|
+
| | |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `PLAN.md` | Intent. Written once by `/plan-comprehensively`, checked by `plan_validate`. |
|
|
105
|
+
| `TODO.md` | State. Written continuously by this package. |
|
|
106
|
+
|
|
107
|
+
Planning shows a `PLAN.md · 7 cycles` widget; this one shows `TODO.md · 3/7 done`. They
|
|
108
|
+
answer different questions, so both stay. Disable either with `pi config` if the pair
|
|
109
|
+
feels noisy.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
No build step. A clone runs as-is:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pi -e ./ --no-skills # load this package in isolation
|
|
117
|
+
npm test # node --test, no dependencies installed
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The tests cover the three modules with rules worth pinning down: `task-file` (parse,
|
|
121
|
+
toggle round-trip, add), `seed` (tree shape, `Unassigned` fallback, idempotency), and
|
|
122
|
+
`render` (indentation, dimming, the collapse rule).
|
|
123
|
+
|
|
124
|
+
## Security
|
|
125
|
+
|
|
126
|
+
The extension reads and writes two files in the working directory, registers three tools,
|
|
127
|
+
one command, and one widget, and handles one event. It runs no subprocesses, opens no
|
|
128
|
+
sockets, and makes no network calls. Read the source before installing it — it is about
|
|
129
|
+
600 lines.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-comprehensive-tasks — the extension half.
|
|
3
|
+
*
|
|
4
|
+
* TODO.md is the store. This wires three tools, a /tasks command, and a widget that
|
|
5
|
+
* collapses branches as they finish. It reads PLAN.md exactly once, at seed time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import {
|
|
13
|
+
addTask,
|
|
14
|
+
createTaskFile,
|
|
15
|
+
findTask,
|
|
16
|
+
flatten,
|
|
17
|
+
parseTaskFile,
|
|
18
|
+
progress,
|
|
19
|
+
serializeTaskFile,
|
|
20
|
+
setDone,
|
|
21
|
+
type TaskFile,
|
|
22
|
+
} from "./task-file.ts";
|
|
23
|
+
import { applySeed, seedFromPlan, seedWarning } from "./seed.ts";
|
|
24
|
+
import { renderWidget } from "./render.ts";
|
|
25
|
+
|
|
26
|
+
const TASKS_FILENAME = "TODO.md";
|
|
27
|
+
const PLAN_FILENAME = "PLAN.md";
|
|
28
|
+
const WIDGET_ID = "tasks";
|
|
29
|
+
|
|
30
|
+
function tasksPath(ctx: ExtensionContext): string {
|
|
31
|
+
return join(ctx.cwd, TASKS_FILENAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read TODO.md, or an empty in-memory file when it does not exist yet. */
|
|
35
|
+
function load(ctx: ExtensionContext): TaskFile {
|
|
36
|
+
const path = tasksPath(ctx);
|
|
37
|
+
if (!existsSync(path)) return createTaskFile(path);
|
|
38
|
+
return parseTaskFile(path, readFileSync(path, "utf-8"));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function save(ctx: ExtensionContext, file: TaskFile): void {
|
|
42
|
+
const text = serializeTaskFile(file);
|
|
43
|
+
// Always end the file with a newline, even when the last line was just inserted.
|
|
44
|
+
writeFileSync(tasksPath(ctx), text.endsWith("\n") ? text : `${text}\n`, "utf-8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function refresh(ctx: ExtensionContext): void {
|
|
48
|
+
if (!ctx.hasUI) return;
|
|
49
|
+
const file = load(ctx);
|
|
50
|
+
const lines = renderWidget(file, {
|
|
51
|
+
dim: (text) => ctx.ui.theme.fg("dim", text),
|
|
52
|
+
accent: (text) => ctx.ui.theme.fg("accent", text),
|
|
53
|
+
width: 72,
|
|
54
|
+
});
|
|
55
|
+
ctx.ui.setWidget(WIDGET_ID, lines.length > 0 ? lines : undefined, { placement: "belowEditor" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The numbered tree, in the same pre-order `findTask` resolves numbers against. */
|
|
59
|
+
function numberedList(file: TaskFile): string {
|
|
60
|
+
const all = flatten(file);
|
|
61
|
+
if (all.length === 0) return `No tasks yet. Seed from a plan with /tasks seed, or add one.`;
|
|
62
|
+
return all
|
|
63
|
+
.map((task, index) => {
|
|
64
|
+
const indent = " ".repeat(task.depth);
|
|
65
|
+
const ref = task.planRef ? ` (${task.planRef})` : "";
|
|
66
|
+
return `${index + 1}. ${indent}${task.done ? "[x]" : "[ ]"} ${task.text}${ref}`;
|
|
67
|
+
})
|
|
68
|
+
.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function summary(file: TaskFile): string {
|
|
72
|
+
const { done, total } = progress(file);
|
|
73
|
+
return total === 0 ? `${TASKS_FILENAME} · empty` : `${TASKS_FILENAME} · ${done}/${total} done`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readPlan(ctx: ExtensionContext): string | null {
|
|
77
|
+
const path = join(ctx.cwd, PLAN_FILENAME);
|
|
78
|
+
return existsSync(path) ? readFileSync(path, "utf-8") : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default function (pi: ExtensionAPI) {
|
|
82
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
83
|
+
refresh(ctx);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Keep the widget in step when the file is edited by hand or by another tool.
|
|
87
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
88
|
+
const path = (event.input as { path?: string }).path;
|
|
89
|
+
if (path && path.endsWith(TASKS_FILENAME)) refresh(ctx);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ------------------------------------------------------------------ tools
|
|
93
|
+
|
|
94
|
+
pi.registerTool({
|
|
95
|
+
name: "task_list",
|
|
96
|
+
label: "List Tasks",
|
|
97
|
+
description: `List every task in ${TASKS_FILENAME} as a numbered tree, with progress. Numbers are stable positions in that list and can be passed to task_complete.`,
|
|
98
|
+
promptSnippet: `List the task tree from ${TASKS_FILENAME}`,
|
|
99
|
+
promptGuidelines: [
|
|
100
|
+
"Use task_list to read the current task state instead of guessing from memory.",
|
|
101
|
+
"Use task_list before task_complete when you need the number of a task.",
|
|
102
|
+
],
|
|
103
|
+
parameters: Type.Object({}),
|
|
104
|
+
|
|
105
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
106
|
+
const file = load(ctx);
|
|
107
|
+
return {
|
|
108
|
+
content: [{ type: "text" as const, text: `${summary(file)}\n\n${numberedList(file)}` }],
|
|
109
|
+
details: { done: progress(file).done, total: progress(file).total },
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.registerTool({
|
|
115
|
+
name: "task_add",
|
|
116
|
+
label: "Add Task",
|
|
117
|
+
description: `Add a task to ${TASKS_FILENAME}. Omit parent to add at the top level. Adding a planRef that is already present is a no-op, so this is safe to call twice.`,
|
|
118
|
+
promptSnippet: `Add a task to ${TASKS_FILENAME}`,
|
|
119
|
+
promptGuidelines: [
|
|
120
|
+
"Use task_add when work appears that the plan did not cover, rather than keeping it in your head.",
|
|
121
|
+
"Use task_add with a parent to file a task under the slice it belongs to.",
|
|
122
|
+
],
|
|
123
|
+
parameters: Type.Object({
|
|
124
|
+
text: Type.String({ description: "What the task is. One line." }),
|
|
125
|
+
parent: Type.Optional(
|
|
126
|
+
Type.String({ description: "Plan ref or number of the parent task, for example §9#A. Omit for top level." }),
|
|
127
|
+
),
|
|
128
|
+
planRef: Type.Optional(
|
|
129
|
+
Type.String({ description: "Plan origin to tag the task with, for example §8#5. Makes a repeat call a no-op." }),
|
|
130
|
+
),
|
|
131
|
+
}),
|
|
132
|
+
|
|
133
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
134
|
+
const file = load(ctx);
|
|
135
|
+
const result = addTask(file, {
|
|
136
|
+
text: params.text,
|
|
137
|
+
planRef: params.planRef ?? null,
|
|
138
|
+
parentRef: params.parent ?? null,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!result.added) {
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: "text" as const, text: `Not added: ${result.reason}.` }],
|
|
144
|
+
details: { added: false, reason: result.reason },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
save(ctx, file);
|
|
149
|
+
refresh(ctx);
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text" as const, text: `Added. ${summary(file)}\n\n${numberedList(file)}` }],
|
|
152
|
+
details: { added: true },
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.registerTool({
|
|
158
|
+
name: "task_complete",
|
|
159
|
+
label: "Complete Task",
|
|
160
|
+
description: `Mark a task in ${TASKS_FILENAME} done, or reopen it with done false. Identify it by plan ref (§8#3) or by its number from task_list.`,
|
|
161
|
+
promptSnippet: `Mark a task done, or reopen it`,
|
|
162
|
+
promptGuidelines: [
|
|
163
|
+
"Use task_complete as soon as a cycle passes, so the task list reflects reality.",
|
|
164
|
+
"Use task_complete with done false to reopen a task that turned out to be unfinished.",
|
|
165
|
+
],
|
|
166
|
+
parameters: Type.Object({
|
|
167
|
+
task: Type.String({ description: "Plan ref such as §8#3, or the number shown by task_list." }),
|
|
168
|
+
done: Type.Optional(Type.Boolean({ description: "True to complete, false to reopen. Defaults to true." })),
|
|
169
|
+
}),
|
|
170
|
+
|
|
171
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
172
|
+
const file = load(ctx);
|
|
173
|
+
const done = params.done ?? true;
|
|
174
|
+
|
|
175
|
+
// Resolve before mutating, so the reply can name the task it changed.
|
|
176
|
+
const target = findTask(file, params.task);
|
|
177
|
+
if (!target) {
|
|
178
|
+
return {
|
|
179
|
+
content: [
|
|
180
|
+
{
|
|
181
|
+
type: "text" as const,
|
|
182
|
+
text: `No task matches "${params.task}". Run task_list to see the refs and numbers.`,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
details: { updated: false },
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setDone(file, params.task, done);
|
|
190
|
+
save(ctx, file);
|
|
191
|
+
refresh(ctx);
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: "text" as const,
|
|
196
|
+
text: `${done ? "Completed" : "Reopened"} "${target.text}". ${summary(file)}\n\n${numberedList(file)}`,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
details: { updated: true, done, task: target.text },
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ------------------------------------------------------------------ /tasks
|
|
205
|
+
|
|
206
|
+
async function togglePicker(ctx: ExtensionContext): Promise<void> {
|
|
207
|
+
for (;;) {
|
|
208
|
+
const file = load(ctx);
|
|
209
|
+
const all = flatten(file);
|
|
210
|
+
if (all.length === 0) {
|
|
211
|
+
ctx.ui.notify("No tasks yet. Run /tasks seed, or /tasks add <text>.", "info");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const options = all.map(
|
|
216
|
+
(task, index) => `${index + 1}. ${task.done ? "[x]" : "[ ]"} ${" ".repeat(task.depth)}${task.text}`,
|
|
217
|
+
);
|
|
218
|
+
const choice = await ctx.ui.select(`${summary(file)} — pick one to toggle, Esc to close`, options);
|
|
219
|
+
if (!choice) return;
|
|
220
|
+
|
|
221
|
+
const number = /^(\d+)\./.exec(choice)?.[1];
|
|
222
|
+
if (!number) return;
|
|
223
|
+
setDone(file, number, !all[Number(number) - 1]!.done);
|
|
224
|
+
save(ctx, file);
|
|
225
|
+
refresh(ctx);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
pi.registerCommand("tasks", {
|
|
230
|
+
description: "List, seed, add, or toggle tasks in TODO.md",
|
|
231
|
+
handler: async (args, ctx) => {
|
|
232
|
+
const input = (args ?? "").trim();
|
|
233
|
+
const [verb, ...rest] = input.split(/\s+/);
|
|
234
|
+
const remainder = rest.join(" ").trim();
|
|
235
|
+
|
|
236
|
+
if (verb === "seed") {
|
|
237
|
+
const plan = readPlan(ctx);
|
|
238
|
+
if (!plan) {
|
|
239
|
+
ctx.ui.notify(`No ${PLAN_FILENAME} in this directory. Run /plan-comprehensively first.`, "warning");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const warning = seedWarning(plan);
|
|
243
|
+
if (warning) {
|
|
244
|
+
ctx.ui.notify(warning, "warning");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const file = load(ctx);
|
|
248
|
+
const result = applySeed(file, seedFromPlan(plan));
|
|
249
|
+
save(ctx, file);
|
|
250
|
+
refresh(ctx);
|
|
251
|
+
ctx.ui.notify(
|
|
252
|
+
result.added > 0
|
|
253
|
+
? `Seeded ${result.added} task(s) from ${PLAN_FILENAME}, skipped ${result.skipped}. ${summary(file)}`
|
|
254
|
+
: `Nothing to seed: all ${result.skipped} task(s) were already there.`,
|
|
255
|
+
"info",
|
|
256
|
+
);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (verb === "add") {
|
|
261
|
+
if (!remainder) {
|
|
262
|
+
ctx.ui.notify("Usage: /tasks add <text>", "error");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const file = load(ctx);
|
|
266
|
+
const result = addTask(file, { text: remainder });
|
|
267
|
+
if (!result.added) {
|
|
268
|
+
ctx.ui.notify(`Not added: ${result.reason}.`, "error");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
save(ctx, file);
|
|
272
|
+
refresh(ctx);
|
|
273
|
+
ctx.ui.notify(`Added. ${summary(file)}`, "info");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (verb === "done" || verb === "undone") {
|
|
278
|
+
if (!remainder) {
|
|
279
|
+
ctx.ui.notify(`Usage: /tasks ${verb} <ref|number>`, "error");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const file = load(ctx);
|
|
283
|
+
if (!setDone(file, remainder, verb === "done")) {
|
|
284
|
+
ctx.ui.notify(`No task matches "${remainder}". Run /tasks to see the numbers.`, "error");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
save(ctx, file);
|
|
288
|
+
refresh(ctx);
|
|
289
|
+
ctx.ui.notify(`${verb === "done" ? "Completed" : "Reopened"}. ${summary(file)}`, "info");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (verb) {
|
|
294
|
+
ctx.ui.notify("Usage: /tasks [seed | add <text> | done <ref> | undone <ref>]", "error");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!ctx.hasUI) {
|
|
299
|
+
const file = load(ctx);
|
|
300
|
+
ctx.ui.notify(`${summary(file)}\n${numberedList(file)}`, "info");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await togglePicker(ctx);
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a task tree into widget lines.
|
|
3
|
+
*
|
|
4
|
+
* Pure on purpose: the collapse rule is the interesting part of the display, so it is
|
|
5
|
+
* unit-tested rather than eyeballed. Colour arrives as injected functions, which keeps
|
|
6
|
+
* this module free of any theme dependency.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { flatten, progress, type Task, type TaskFile } from "./task-file.ts";
|
|
10
|
+
|
|
11
|
+
export interface RenderOptions {
|
|
12
|
+
dim?: (text: string) => string;
|
|
13
|
+
accent?: (text: string) => string;
|
|
14
|
+
/** Collapse a branch whose descendants are all done. On by default. */
|
|
15
|
+
collapse?: boolean;
|
|
16
|
+
/** Truncate each line to this width. Default 72. */
|
|
17
|
+
width?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CHECKED = "[x]";
|
|
21
|
+
const OPEN = "[ ]";
|
|
22
|
+
|
|
23
|
+
/** Every descendant of a task, at all depths. */
|
|
24
|
+
function descendants(task: Task): Task[] {
|
|
25
|
+
const out: Task[] = [];
|
|
26
|
+
const walk = (tasks: Task[]) => {
|
|
27
|
+
for (const child of tasks) {
|
|
28
|
+
out.push(child);
|
|
29
|
+
walk(child.children);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
walk(task.children);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncate(text: string, width: number): string {
|
|
37
|
+
if (text.length <= width) return text;
|
|
38
|
+
return `${text.slice(0, Math.max(1, width - 1))}…`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function label(task: Task): string {
|
|
42
|
+
return `${task.done ? CHECKED : OPEN} ${task.text}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One line per visible task, indented by depth. A branch whose descendants are all
|
|
47
|
+
* done collapses to a single line carrying its own count.
|
|
48
|
+
*
|
|
49
|
+
* The glyph is always the task's own state, never a summary of its children: a slice
|
|
50
|
+
* whose cycles are all finished but which is itself unticked stays `[ ]`, because that
|
|
51
|
+
* is the one thing left to do.
|
|
52
|
+
*/
|
|
53
|
+
export function renderTasks(file: TaskFile, options: RenderOptions = {}): string[] {
|
|
54
|
+
const { dim = (text) => text, collapse = true, width = 72 } = options;
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
|
|
57
|
+
const walk = (tasks: Task[]) => {
|
|
58
|
+
for (const task of tasks) {
|
|
59
|
+
const indent = " ".repeat(task.depth);
|
|
60
|
+
const kids = descendants(task);
|
|
61
|
+
const branchDone = kids.length > 0 && kids.every((child) => child.done);
|
|
62
|
+
|
|
63
|
+
if (collapse && branchDone) {
|
|
64
|
+
const done = kids.filter((child) => child.done).length;
|
|
65
|
+
lines.push(dim(truncate(`${indent}${label(task)} · ${done}/${kids.length}`, width)));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const line = `${indent}${label(task)}`;
|
|
70
|
+
lines.push(truncate(task.done ? dim(line) : line, width));
|
|
71
|
+
walk(task.children);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
walk(file.tasks);
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `TODO.md · 3/7 done`, or `TODO.md · empty` when there is nothing to do. */
|
|
80
|
+
export function renderHeader(file: TaskFile, options: RenderOptions = {}): string {
|
|
81
|
+
const { accent = (text) => text } = options;
|
|
82
|
+
const { done, total } = progress(file);
|
|
83
|
+
const name = file.path.split("/").pop() ?? "TODO.md";
|
|
84
|
+
if (total === 0) return accent(`${name} · empty`);
|
|
85
|
+
return accent(`${name} · ${done}/${total} done`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The whole widget body: header first, then the tree. */
|
|
89
|
+
export function renderWidget(file: TaskFile, options: RenderOptions = {}): string[] {
|
|
90
|
+
const body = renderTasks(file, options);
|
|
91
|
+
if (body.length === 0) return [];
|
|
92
|
+
return [renderHeader(file, options), ...body];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Re-exported so callers can size the widget without importing task-file twice. */
|
|
96
|
+
export { flatten, progress };
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeding TODO.md from a plan.
|
|
3
|
+
*
|
|
4
|
+
* Reads two tables out of PLAN.md and nowhere else:
|
|
5
|
+
* §9 Work sequencing -> one parent per slice, with the files it owns
|
|
6
|
+
* §8 Step-by-step -> one child per cycle, filed under the slice that owns its files
|
|
7
|
+
*
|
|
8
|
+
* A cycle whose files match no slice lands under an `Unassigned` parent. Filing it
|
|
9
|
+
* wrongly is recoverable; dropping it silently is not.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here imports the planning package. The two table shapes are duplicated on
|
|
12
|
+
* purpose, so this package versions and installs independently.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { addTask, findTask, type TaskFile } from "./task-file.ts";
|
|
16
|
+
|
|
17
|
+
export interface SeedChild {
|
|
18
|
+
ref: string;
|
|
19
|
+
text: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SeedParent {
|
|
23
|
+
ref: string;
|
|
24
|
+
text: string;
|
|
25
|
+
children: SeedChild[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SliceRow {
|
|
29
|
+
ref: string;
|
|
30
|
+
text: string;
|
|
31
|
+
files: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CycleRow {
|
|
35
|
+
ref: string;
|
|
36
|
+
text: string;
|
|
37
|
+
files: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const UNASSIGNED_REF = "unassigned";
|
|
41
|
+
|
|
42
|
+
const FILE_LIKE =
|
|
43
|
+
/\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|cpp|cc|cxx|h|hpp|kt|kts|dart|java|rb|php|cs|swift|sql|json|ya?ml|toml|md|proto|sh|tf)$/i;
|
|
44
|
+
|
|
45
|
+
/** Split on `## N. Title` / `### N. Title` headings, keyed by the number. */
|
|
46
|
+
function sections(raw: string): Map<string, string> {
|
|
47
|
+
const found = new Map<string, string>();
|
|
48
|
+
let current: string | null = null;
|
|
49
|
+
let buffer: string[] = [];
|
|
50
|
+
|
|
51
|
+
const flush = () => {
|
|
52
|
+
if (current !== null) found.set(current, buffer.join("\n").trim());
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
56
|
+
const heading = /^#{2,4}\s+(\d+)[.)]\s+/.exec(line);
|
|
57
|
+
if (heading) {
|
|
58
|
+
flush();
|
|
59
|
+
current = heading[1]!;
|
|
60
|
+
buffer = [];
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (current !== null) buffer.push(line);
|
|
64
|
+
}
|
|
65
|
+
flush();
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tableRows(body: string): string[][] {
|
|
70
|
+
const rows: string[][] = [];
|
|
71
|
+
let seenHeader = false;
|
|
72
|
+
|
|
73
|
+
for (const line of body.split(/\r?\n/)) {
|
|
74
|
+
const trimmed = line.trim();
|
|
75
|
+
if (!trimmed.startsWith("|")) {
|
|
76
|
+
if (seenHeader && rows.length > 0 && trimmed === "") seenHeader = false;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const cells = trimmed
|
|
80
|
+
.replace(/^\|/, "")
|
|
81
|
+
.replace(/\|$/, "")
|
|
82
|
+
.split("|")
|
|
83
|
+
.map((cell) => cell.trim());
|
|
84
|
+
|
|
85
|
+
if (cells.every((cell) => /^:?-{2,}:?$/.test(cell))) continue;
|
|
86
|
+
if (!seenHeader) {
|
|
87
|
+
seenHeader = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
rows.push(cells);
|
|
91
|
+
}
|
|
92
|
+
return rows;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function backticked(text: string): string[] {
|
|
96
|
+
return [...text.matchAll(/`([^`]+)`/g)]
|
|
97
|
+
.map((match) => match[1]!.trim().replace(/^\.\//, ""))
|
|
98
|
+
.filter((value) => value !== "" && FILE_LIKE.test(value));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function clean(text: string): string {
|
|
102
|
+
return text.replace(/\s+/g, " ").replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `A: the file format` -> `A`. Falls back to the row's position. */
|
|
106
|
+
function sliceLabel(cell: string, index: number): string {
|
|
107
|
+
const match = /^([A-Za-z][A-Za-z0-9]*)\s*[::\-–—]/.exec(cell.trim());
|
|
108
|
+
return match ? match[1]! : String(index + 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function readPlanTables(planRaw: string): { slices: SliceRow[]; cycles: CycleRow[] } {
|
|
112
|
+
const parts = sections(planRaw);
|
|
113
|
+
|
|
114
|
+
const slices = tableRows(parts.get("9") ?? "").map((cells, index) => ({
|
|
115
|
+
ref: `§9#${sliceLabel(cells[0] ?? "", index)}`,
|
|
116
|
+
text: clean(cells[0] ?? ""),
|
|
117
|
+
files: backticked(cells[1] ?? ""),
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const cycles = tableRows(parts.get("8") ?? "").map((cells) => ({
|
|
121
|
+
ref: `§8#${(cells[0] ?? "").replace(/[^A-Za-z0-9]/g, "")}`,
|
|
122
|
+
text: clean(cells[1] ?? ""),
|
|
123
|
+
files: [...backticked(cells[1] ?? ""), ...backticked(cells[2] ?? "")],
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
return { slices, cycles };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the tree a plan implies. Slice order is preserved; `Unassigned` comes last
|
|
131
|
+
* and only when something actually landed there.
|
|
132
|
+
*/
|
|
133
|
+
export function seedFromPlan(planRaw: string): SeedParent[] {
|
|
134
|
+
const { slices, cycles } = readPlanTables(planRaw);
|
|
135
|
+
|
|
136
|
+
const parents: SeedParent[] = slices.map((slice) => ({
|
|
137
|
+
ref: slice.ref,
|
|
138
|
+
text: slice.text,
|
|
139
|
+
children: [],
|
|
140
|
+
}));
|
|
141
|
+
const unassigned: SeedParent = { ref: UNASSIGNED_REF, text: "Unassigned", children: [] };
|
|
142
|
+
|
|
143
|
+
for (const cycle of cycles) {
|
|
144
|
+
const owner = slices.find((slice) =>
|
|
145
|
+
slice.files.some((owned) => cycle.files.includes(owned)),
|
|
146
|
+
);
|
|
147
|
+
const child: SeedChild = { ref: cycle.ref, text: cycle.text };
|
|
148
|
+
if (!owner) {
|
|
149
|
+
unassigned.children.push(child);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
parents.find((parent) => parent.ref === owner.ref)!.children.push(child);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (unassigned.children.length > 0) parents.push(unassigned);
|
|
156
|
+
return parents;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface SeedResult {
|
|
160
|
+
added: number;
|
|
161
|
+
skipped: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Explain why a seed would do nothing, instead of silently doing nothing.
|
|
166
|
+
* Returns null when the plan looks seedable.
|
|
167
|
+
*/
|
|
168
|
+
export function seedWarning(planRaw: string): string | null {
|
|
169
|
+
const parts = sections(planRaw);
|
|
170
|
+
|
|
171
|
+
if (!parts.has("8") || !parts.has("9")) {
|
|
172
|
+
const missing = ["8", "9"].filter((key) => !parts.has(key));
|
|
173
|
+
const label = missing.length === 2 ? "§8 and §9" : `§${missing[0]}`;
|
|
174
|
+
return `PLAN.md has no ${label} section. Seeding reads §9 for slices and §8 for cycles.`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const { slices, cycles } = readPlanTables(planRaw);
|
|
178
|
+
if (cycles.length === 0) return "§8 has no table rows, so there are no cycles to seed.";
|
|
179
|
+
if (slices.length === 0) return "§9 has no table rows, so there are no slices to group under.";
|
|
180
|
+
|
|
181
|
+
if (cycles.every((cycle) => cycle.files.length === 0)) {
|
|
182
|
+
return "§8 names no file paths. Wrap them in backticks, for example `src/cache.ts`, so cycles can be matched to slices.";
|
|
183
|
+
}
|
|
184
|
+
if (slices.every((slice) => slice.files.length === 0)) {
|
|
185
|
+
return "§9 lists no file paths. Wrap them in backticks so cycles can be matched to slices.";
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Merge a seed into an open task file. Idempotent: a ref already present is skipped. */
|
|
191
|
+
export function applySeed(file: TaskFile, parents: SeedParent[]): SeedResult {
|
|
192
|
+
let added = 0;
|
|
193
|
+
let skipped = 0;
|
|
194
|
+
|
|
195
|
+
for (const parent of parents) {
|
|
196
|
+
if (findTask(file, parent.ref)) {
|
|
197
|
+
skipped += 1;
|
|
198
|
+
} else {
|
|
199
|
+
const result = addTask(file, { text: parent.text, planRef: parent.ref });
|
|
200
|
+
if (result.added) added += 1;
|
|
201
|
+
else skipped += 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const child of parent.children) {
|
|
205
|
+
if (findTask(file, child.ref)) {
|
|
206
|
+
skipped += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const result = addTask(file, { text: child.text, planRef: child.ref, parentRef: parent.ref });
|
|
210
|
+
if (result.added) added += 1;
|
|
211
|
+
else skipped += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { added, skipped };
|
|
216
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and writing TODO.md.
|
|
3
|
+
*
|
|
4
|
+
* The file is the interface: plain Markdown nested checkboxes, so it renders as a real
|
|
5
|
+
* checklist on GitHub. Only checkbox lines are ever rewritten — prose the user added
|
|
6
|
+
* around the list passes through untouched.
|
|
7
|
+
*
|
|
8
|
+
* # Tasks
|
|
9
|
+
*
|
|
10
|
+
* - [ ] Slice A: the file format <!-- plan:§9#A -->
|
|
11
|
+
* - [ ] parse the tree <!-- plan:§8#1 -->
|
|
12
|
+
* - [x] toggle a child <!-- plan:§8#2 -->
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface Task {
|
|
16
|
+
text: string;
|
|
17
|
+
done: boolean;
|
|
18
|
+
/** Origin in the plan, for example `§8#1`. Null for tasks a human added. */
|
|
19
|
+
planRef: string | null;
|
|
20
|
+
/** Nesting level, 0 for a root. Two spaces of indent per level. */
|
|
21
|
+
depth: number;
|
|
22
|
+
/** Index into `TaskFile.lines`. */
|
|
23
|
+
line: number;
|
|
24
|
+
children: Task[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TaskFile {
|
|
28
|
+
path: string;
|
|
29
|
+
/** Raw lines. Mutations rewrite this, then re-parse. */
|
|
30
|
+
lines: string[];
|
|
31
|
+
tasks: Task[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ITEM_RE = /^([ \t]*)-[ \t]+\[([ xX])\][ \t]*(.*)$/;
|
|
35
|
+
const TAG_RE = /<!--\s*plan:([^\s>]+)\s*-->/;
|
|
36
|
+
const INDENT_PER_LEVEL = 2;
|
|
37
|
+
|
|
38
|
+
/** Tabs become two spaces so depth is measured from spaces alone. */
|
|
39
|
+
function expandTabs(line: string): string {
|
|
40
|
+
return line.replace(/\t/g, " ".repeat(INDENT_PER_LEVEL));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function depthOf(expandedLine: string): number {
|
|
44
|
+
const indent = /^[ \t]*/.exec(expandedLine)![0];
|
|
45
|
+
return Math.floor(expandTabs(indent).length / INDENT_PER_LEVEL);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseTaskFile(path: string, raw: string): TaskFile {
|
|
49
|
+
const lines = raw.split(/\r?\n/);
|
|
50
|
+
const tasks: Task[] = [];
|
|
51
|
+
const stack: Task[] = [];
|
|
52
|
+
|
|
53
|
+
lines.forEach((line, index) => {
|
|
54
|
+
const match = ITEM_RE.exec(expandTabs(line));
|
|
55
|
+
if (!match) return;
|
|
56
|
+
|
|
57
|
+
const body = match[3] ?? "";
|
|
58
|
+
const tag = TAG_RE.exec(body);
|
|
59
|
+
const text = body.replace(TAG_RE, "").trim();
|
|
60
|
+
const depth = depthOf(line);
|
|
61
|
+
|
|
62
|
+
const task: Task = {
|
|
63
|
+
text,
|
|
64
|
+
done: (match[2] ?? " ").toLowerCase() === "x",
|
|
65
|
+
planRef: tag ? tag[1]! : null,
|
|
66
|
+
depth,
|
|
67
|
+
line: index,
|
|
68
|
+
children: [],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) stack.pop();
|
|
72
|
+
const parent = stack[stack.length - 1];
|
|
73
|
+
if (parent) parent.children.push(task);
|
|
74
|
+
else tasks.push(task);
|
|
75
|
+
|
|
76
|
+
stack.push(task);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return { path, lines, tasks };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createTaskFile(path: string): TaskFile {
|
|
83
|
+
return parseTaskFile(path, "# Tasks\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function serializeTaskFile(file: TaskFile): string {
|
|
87
|
+
return file.lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Depth-first order, the order the widget and `task_list` both use. */
|
|
91
|
+
export function flatten(file: TaskFile): Task[] {
|
|
92
|
+
const out: Task[] = [];
|
|
93
|
+
const walk = (tasks: Task[]) => {
|
|
94
|
+
for (const task of tasks) {
|
|
95
|
+
out.push(task);
|
|
96
|
+
walk(task.children);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
walk(file.tasks);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Find a task by plan ref (`§8#3`) or by its 1-based position in `flatten`.
|
|
105
|
+
* Numbers shift as the file changes, so a ref is the durable way to name one.
|
|
106
|
+
*/
|
|
107
|
+
export function findTask(file: TaskFile, ref: string): Task | undefined {
|
|
108
|
+
const wanted = ref.trim();
|
|
109
|
+
if (wanted === "") return undefined;
|
|
110
|
+
|
|
111
|
+
const byRef = flatten(file).find((task) => task.planRef === wanted);
|
|
112
|
+
if (byRef) return byRef;
|
|
113
|
+
|
|
114
|
+
if (/^\d+$/.test(wanted)) {
|
|
115
|
+
const index = Number(wanted);
|
|
116
|
+
const list = flatten(file);
|
|
117
|
+
if (index >= 1 && index <= list.length) return list[index - 1];
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Rebuild the parsed tree after mutating `lines`.
|
|
124
|
+
*
|
|
125
|
+
* Every mutation must call this. A stale tree makes the next `findTask` miss a task that
|
|
126
|
+
* was just added, which silently turns a full seed into a partial one.
|
|
127
|
+
*/
|
|
128
|
+
function reparse(file: TaskFile): void {
|
|
129
|
+
file.tasks = parseTaskFile(file.path, file.lines.join("\n")).tasks;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function setDone(file: TaskFile, ref: string, done: boolean): boolean {
|
|
133
|
+
const task = findTask(file, ref);
|
|
134
|
+
if (!task) return false;
|
|
135
|
+
|
|
136
|
+
const line = file.lines[task.line] ?? "";
|
|
137
|
+
const next = line.replace(/\[([ xX])\]/, done ? "[x]" : "[ ]");
|
|
138
|
+
if (next === line) return true;
|
|
139
|
+
|
|
140
|
+
file.lines[task.line] = next;
|
|
141
|
+
reparse(file);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface AddOptions {
|
|
146
|
+
text: string;
|
|
147
|
+
/** Plan origin to tag the new task with, for example `§8#1`. */
|
|
148
|
+
planRef?: string | null;
|
|
149
|
+
/** Plan ref of the parent. Omit to add at the top level. */
|
|
150
|
+
parentRef?: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface AddResult {
|
|
154
|
+
added: boolean;
|
|
155
|
+
reason?: string;
|
|
156
|
+
task?: Task;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Index of the last line belonging to this task or any of its descendants. */
|
|
160
|
+
function lastLineOf(task: Task): number {
|
|
161
|
+
let last = task.line;
|
|
162
|
+
for (const child of task.children) last = Math.max(last, lastLineOf(child));
|
|
163
|
+
return last;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function addTask(file: TaskFile, options: AddOptions): AddResult {
|
|
167
|
+
const text = options.text.trim();
|
|
168
|
+
if (text === "") return { added: false, reason: "empty text" };
|
|
169
|
+
|
|
170
|
+
const ref = options.planRef?.trim() || null;
|
|
171
|
+
if (ref && findTask(file, ref)) {
|
|
172
|
+
return { added: false, reason: `already present: ${ref}` };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let depth = 0;
|
|
176
|
+
let insertAt = file.lines.length;
|
|
177
|
+
|
|
178
|
+
if (options.parentRef) {
|
|
179
|
+
const parent = findTask(file, options.parentRef);
|
|
180
|
+
if (!parent) return { added: false, reason: `no such parent: ${options.parentRef}` };
|
|
181
|
+
depth = parent.depth + 1;
|
|
182
|
+
insertAt = lastLineOf(parent) + 1;
|
|
183
|
+
} else {
|
|
184
|
+
// Append after the last task so prose below the list keeps its place.
|
|
185
|
+
const all = flatten(file);
|
|
186
|
+
if (all.length > 0) insertAt = Math.max(...all.map((task) => lastLineOf(task))) + 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const line = `${" ".repeat(depth * INDENT_PER_LEVEL)}- [ ] ${text}${ref ? ` <!-- plan:${ref} -->` : ""}`;
|
|
190
|
+
file.lines.splice(insertAt, 0, line);
|
|
191
|
+
reparse(file);
|
|
192
|
+
return { added: true };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Done and total counts across every level. */
|
|
196
|
+
export function progress(file: TaskFile): { done: number; total: number } {
|
|
197
|
+
const all = flatten(file);
|
|
198
|
+
return { done: all.filter((task) => task.done).length, total: all.length };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Plan refs already present, used to keep seeding idempotent. */
|
|
202
|
+
export function presentRefs(file: TaskFile): Set<string> {
|
|
203
|
+
return new Set(flatten(file).map((task) => task.planRef).filter((ref): ref is string => ref !== null));
|
|
204
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-comprehensive-tasks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A task tree in TODO.md for the pi coding agent, seeded from a pi-comprehensive-planning plan and collapsed as branches finish.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"tasks",
|
|
9
|
+
"todo",
|
|
10
|
+
"checklist",
|
|
11
|
+
"planning",
|
|
12
|
+
"extension",
|
|
13
|
+
"skill"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Rizquuula",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/rizquuula/pi-comprehensive-tasks.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/rizquuula/pi-comprehensive-tasks#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/rizquuula/pi-comprehensive-tasks/issues"
|
|
25
|
+
},
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./extensions"
|
|
29
|
+
],
|
|
30
|
+
"skills": [
|
|
31
|
+
"./skills"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
36
|
+
"typebox": "*"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"typebox": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node --test tests/*.test.ts"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"extensions",
|
|
51
|
+
"skills",
|
|
52
|
+
"README.md",
|
|
53
|
+
"LICENSE"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: comprehensive-tasks
|
|
3
|
+
description: Keep TODO.md honest while implementing a plan. Use when working through a plan's cycles, when a task turns out to be bigger or different than the plan said, when new work appears mid-flight, or when deciding whether a task is really done. Covers when to add a task rather than hold it in your head, when to complete one, when to reopen one, and how to file a task under the right slice.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Comprehensive Tasks
|
|
7
|
+
|
|
8
|
+
`TODO.md` is the live state of the work. `PLAN.md` is the intent.
|
|
9
|
+
The plan is written once and reviewed. The task list changes as reality does.
|
|
10
|
+
|
|
11
|
+
## The shape
|
|
12
|
+
|
|
13
|
+
```markdown
|
|
14
|
+
# Tasks
|
|
15
|
+
|
|
16
|
+
- [ ] Slice A: the file format <!-- plan:§9#A -->
|
|
17
|
+
- [ ] parse the tree <!-- plan:§8#1 -->
|
|
18
|
+
- [x] toggle a child <!-- plan:§8#2 -->
|
|
19
|
+
- [ ] Unassigned
|
|
20
|
+
- [ ] wiring only <!-- plan:§8#9 -->
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Two spaces of indent per level. The `<!-- plan:… -->` comment records where the task came
|
|
24
|
+
from, so a task can be traced back to its cycle. Keep the tag when you edit a line.
|
|
25
|
+
|
|
26
|
+
## Workflow
|
|
27
|
+
|
|
28
|
+
1. **Read the list before you start.** `task_list` gives the numbers and refs.
|
|
29
|
+
2. **Work one task at a time**, in order. The list is ordered for a reason.
|
|
30
|
+
3. **Complete a task the moment its done-criterion holds** — not when the code looks
|
|
31
|
+
right, but when the test in its Red column passes. `task_complete §8#3`.
|
|
32
|
+
4. **Reopen it if you were wrong.** `task_complete` with `done: false`. A stale tick is
|
|
33
|
+
worse than an unticked box.
|
|
34
|
+
5. **Add work you discover**, rather than holding it in your head. File it under the
|
|
35
|
+
slice it belongs to, or at the top level if it belongs to none.
|
|
36
|
+
6. **Finish with a truthful list.** Every box ticked means every criterion in §1a holds.
|
|
37
|
+
|
|
38
|
+
## Adding a task
|
|
39
|
+
|
|
40
|
+
Use `task_add` when:
|
|
41
|
+
|
|
42
|
+
- The plan did not cover something you had to do. Add it and keep going.
|
|
43
|
+
- A cycle turned out to be two behaviours. Add the second rather than quietly widening
|
|
44
|
+
the first.
|
|
45
|
+
- You found a follow-up worth keeping. If it is genuinely out of scope, put it in the
|
|
46
|
+
plan's §13 instead — the list is for work you are actually doing.
|
|
47
|
+
|
|
48
|
+
Do not add a task for something you are about to do in the same breath. The list is a
|
|
49
|
+
commitment, not a log.
|
|
50
|
+
|
|
51
|
+
## Filing
|
|
52
|
+
|
|
53
|
+
A task belongs under the slice that owns the files it touches. If nothing owns them, it
|
|
54
|
+
belongs under `Unassigned` — that parent exists on purpose, and a visible mis-filing is
|
|
55
|
+
better than a silent one.
|
|
56
|
+
|
|
57
|
+
## Do / Don't
|
|
58
|
+
|
|
59
|
+
| Do | Don't |
|
|
60
|
+
|---|---|
|
|
61
|
+
| Tick a task when its test passes | Tick it because the code is written |
|
|
62
|
+
| Reopen a task you closed too early | Leave a wrong tick in place |
|
|
63
|
+
| Add the work you discovered, with a short line | Keep three undone things in your head |
|
|
64
|
+
| File a task under its slice | Dump everything at the top level |
|
|
65
|
+
| Keep the `<!-- plan:… -->` tag when editing | Strip the tag and lose the trail |
|
|
66
|
+
| Leave prose outside the list alone | Rewrite the whole file to change one box |
|