pi-worklist 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/AGENTS.md ADDED
@@ -0,0 +1,10 @@
1
+ # pi-worklist agent notes
2
+
3
+ - Read Pi's installed `docs/extensions.md`, `docs/tui.md`, `docs/packages.md`, and `docs/session-format.md` before changing extension APIs.
4
+ - Session Tasks are canonical versioned custom-entry snapshots and must remain branch-aware.
5
+ - Project Goals are canonical in `<git-root>/.pi/worklist.json` and every mutation must use the cross-process lock plus atomic rename.
6
+ - Never add a project lifecycle path that bypasses explicit confirmation.
7
+ - Keep the widget compact and width-safe.
8
+ - Keep the model-facing schema compatible with Google providers by using `StringEnum` for string enums.
9
+ - Run `npm run check`, `npm audit`, `npm run pack:check`, and the real Pi RPC test before release.
10
+ - Do not manually add a changelog.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Max Miller
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,97 @@
1
+ # pi-worklist
2
+
3
+ `pi-worklist` gives Pi two deliberately different lists.
4
+ Session Tasks track the concrete work in the current coding session.
5
+ Project Goals track the larger outcomes shared by every Pi session in a Git repository.
6
+
7
+ ## Features
8
+
9
+ - Branch-aware Session Tasks survive `/resume` and follow `/tree`, `/fork`, and `/clone`.
10
+ - A new Pi session starts with an empty Session Task list.
11
+ - Project Goals persist at `<git-root>/.pi/worklist.json` and can be committed with the repository.
12
+ - `/tasks` opens an interactive two-section dashboard.
13
+ - A compact widget shows the active Project Goal and up to three unfinished Session Tasks.
14
+ - The `worklist` model tool manages both scopes through one consistent API.
15
+ - Project Goal completion, reopening, archival, and deletion require explicit user intent.
16
+ - Cross-process locking and atomic replacement prevent concurrent Pi processes from losing updates or corrupting the project file.
17
+
18
+ ## Install
19
+
20
+ Install from npm after the first release:
21
+
22
+ ```sh
23
+ pi install npm:pi-worklist
24
+ ```
25
+
26
+ Install directly from GitHub:
27
+
28
+ ```sh
29
+ pi install git:github.com/max-miller1204/pi-worklist
30
+ ```
31
+
32
+ Try a checkout without installing it:
33
+
34
+ ```sh
35
+ pi -e ./src/extension.ts
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ Run `/tasks` with no arguments to open the dashboard.
41
+ Use Tab to switch lists, arrow keys to navigate, `a` to add, `e` to edit, Space or Enter to advance status, `d` to delete, and Escape to close.
42
+
43
+ Direct commands are useful in RPC mode and scripts:
44
+
45
+ ```text
46
+ /tasks session list
47
+ /tasks session add Write regression tests
48
+ /tasks session status <id> doing
49
+ /tasks project list
50
+ /tasks project add Replace legacy authentication
51
+ /tasks project set_active <id>
52
+ /tasks project complete <id>
53
+ ```
54
+
55
+ Typing a Project Goal lifecycle command is explicit user intent.
56
+ The model-facing tool instead requires `confirm=true`, and its prompt rules prohibit setting that flag without an explicit request.
57
+
58
+ ## Storage semantics
59
+
60
+ Session Tasks are stored as versioned Pi custom entries in the current session tree.
61
+ They do not enter model context directly.
62
+ Only the active goal and an intentionally bounded list of incomplete tasks are added to the current turn's system prompt.
63
+
64
+ Project Goals use a schema-versioned JSON file at `.pi/worklist.json` in the canonical Git root.
65
+ The file is human-readable and suitable for version control.
66
+ A malformed or unsupported file is reported and never overwritten automatically.
67
+ Project Goal operations are unavailable outside a Git repository, while Session Tasks continue to work normally.
68
+
69
+ ## Model tool
70
+
71
+ The `worklist` tool accepts `scope=session|project` and actions including `list`, `add`, `update`, `set_status`, `set_active`, `complete`, `reopen`, `archive`, and `delete`.
72
+ Session Task statuses are `todo`, `doing`, and `done`.
73
+ Project Goal statuses are `open`, `active`, `done`, and `archived`.
74
+ Only activation is a non-destructive direct Project Goal status change.
75
+
76
+ ## Development
77
+
78
+ ```sh
79
+ git clone https://github.com/max-miller1204/pi-worklist.git
80
+ cd pi-worklist
81
+ npm install
82
+ npm run check
83
+ npm run pack:check
84
+ ```
85
+
86
+ The test suite includes a real Pi RPC load test in a temporary Git repository.
87
+ The package uses TypeScript source directly because Pi loads extensions through jiti.
88
+
89
+ ## Publishing and the Pi gallery
90
+
91
+ The package contains the `pi-package` npm keyword and a `pi.extensions` manifest.
92
+ Publishing it to npm makes it discoverable by the package gallery at <https://pi.dev/packages> without a separate submission process.
93
+ Run the full validation suite before `npm publish`.
94
+
95
+ ## License
96
+
97
+ MIT
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "pi-worklist",
3
+ "version": "0.1.0",
4
+ "description": "A Pi extension for session tasks and project goals.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "extension",
10
+ "worklist",
11
+ "tasks",
12
+ "goals"
13
+ ],
14
+ "author": "Max Miller",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/max-miller1204/pi-worklist.git"
19
+ },
20
+ "homepage": "https://github.com/max-miller1204/pi-worklist#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/max-miller1204/pi-worklist/issues"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "AGENTS.md",
27
+ "README.md",
28
+ "package.json"
29
+ ],
30
+ "main": "./src/extension.ts",
31
+ "pi": {
32
+ "extensions": [
33
+ "./src/extension.ts"
34
+ ]
35
+ },
36
+ "scripts": {
37
+ "test": "vitest run",
38
+ "test:watch": "vitest",
39
+ "typecheck": "tsc --noEmit",
40
+ "format": "biome format --write .",
41
+ "format:check": "biome format .",
42
+ "lint": "biome lint --write .",
43
+ "lint:check": "biome lint .",
44
+ "check": "npm run typecheck && npm run format:check && npm run lint:check && npm run test",
45
+ "pack:check": "npm pack --dry-run",
46
+ "verify": "npm run check && npm run pack:check"
47
+ },
48
+ "dependencies": {
49
+ "proper-lockfile": "^4.1.2"
50
+ },
51
+ "peerDependencies": {
52
+ "@earendil-works/pi-ai": "*",
53
+ "@earendil-works/pi-agent-core": "*",
54
+ "@earendil-works/pi-coding-agent": "*",
55
+ "@earendil-works/pi-tui": "*",
56
+ "typebox": "*"
57
+ },
58
+ "devDependencies": {
59
+ "@biomejs/biome": "^2.2.4",
60
+ "@types/node": "^24.0.0",
61
+ "@types/proper-lockfile": "^4.1.4",
62
+ "typescript": "^5.9.0",
63
+ "vitest": "^3.2.0"
64
+ },
65
+ "engines": {
66
+ "node": ">=20"
67
+ }
68
+ }
@@ -0,0 +1,228 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { WorklistParamsSchema } from "./schema.ts";
4
+ import { readProjectWorklist } from "./project-store.ts";
5
+ import { SessionStore } from "./session-store.ts";
6
+ import {
7
+ executeWorklist,
8
+ formatProjectGoals,
9
+ formatSessionTasks,
10
+ getProjectPath,
11
+ WORKLIST_EXECUTION_MODE,
12
+ } from "./tool.ts";
13
+ import type { ProjectGoal, ProjectGoalStatus, SessionTaskStatus } from "./types.ts";
14
+ import { buildPromptSummary, buildWidgetLines, Dashboard, type DashboardAction } from "./ui.ts";
15
+
16
+ export interface ParsedCommand {
17
+ scope: "session" | "project";
18
+ action: string;
19
+ id?: string;
20
+ title?: string;
21
+ status?: SessionTaskStatus | ProjectGoalStatus;
22
+ confirm?: boolean;
23
+ }
24
+
25
+ export function parseTasksCommand(args: string): ParsedCommand | null {
26
+ const parts = args.trim().split(/\s+/).filter(Boolean);
27
+ if (parts.length < 2 || !["session", "project"].includes(parts[0])) return null;
28
+ const scope = parts.shift() as "session" | "project";
29
+ const action = parts.shift();
30
+ if (!action) return null;
31
+ if (action === "list") return { scope, action };
32
+ if (action === "add") return { scope, action, title: parts.join(" ") };
33
+ if (action === "update") return { scope, action, id: parts.shift(), title: parts.join(" ") };
34
+ if (action === "status")
35
+ return { scope, action: "set_status", id: parts[0], status: parts[1] as ParsedCommand["status"] };
36
+ if (["complete", "reopen", "archive", "delete", "set_active"].includes(action)) {
37
+ return { scope, action, id: parts[0], confirm: scope === "project" && action !== "set_active" };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ export default function worklistExtension(pi: ExtensionAPI): void {
43
+ const sessionStore = new SessionStore(pi);
44
+ let projectPath: string | null = null;
45
+ let projectGoals: ProjectGoal[] = [];
46
+ let latestContext: ExtensionContext | undefined;
47
+
48
+ async function refreshProject(): Promise<void> {
49
+ if (!projectPath) {
50
+ projectGoals = [];
51
+ return;
52
+ }
53
+ const result = await readProjectWorklist(projectPath);
54
+ if (result.error) throw new Error(result.error);
55
+ projectGoals = result.data.goals;
56
+ }
57
+
58
+ async function updateUi(ctx: ExtensionContext): Promise<void> {
59
+ latestContext = ctx;
60
+ await refreshProject();
61
+ const lines = buildWidgetLines(sessionStore.getTasks(), projectGoals);
62
+ if (!lines.length) ctx.ui.setWidget("pi-worklist", undefined);
63
+ else if (ctx.mode === "tui") {
64
+ ctx.ui.setWidget(
65
+ "pi-worklist",
66
+ (_tui, theme) =>
67
+ new Text(
68
+ lines.map((line, index) => (index === 0 ? theme.fg("accent", line) : line)).join("\n"),
69
+ 0,
70
+ 0,
71
+ ),
72
+ );
73
+ } else ctx.ui.setWidget("pi-worklist", lines);
74
+ }
75
+
76
+ async function execute(params: ParsedCommand, ctx: ExtensionContext) {
77
+ const result = await executeWorklist(params, ctx, { sessionStore, projectPath });
78
+ await updateUi(ctx);
79
+ return result;
80
+ }
81
+
82
+ pi.registerTool({
83
+ name: "worklist",
84
+ label: "Worklist",
85
+ description:
86
+ "Manage branch-aware session tasks or repository-wide project goals. Project complete, reopen, archive, and delete require confirm=true after explicit user intent.",
87
+ promptSnippet: "Manage Session Tasks and repository-scoped Project Goals",
88
+ promptGuidelines: [
89
+ "Use worklist to maintain Session Tasks for multi-step work and update them as verified work progresses.",
90
+ "Use worklist with scope=project only when the user asks to manage the project roadmap.",
91
+ "Never set worklist confirm=true for a project lifecycle action unless the user explicitly requested that exact completion, reopening, archival, or deletion.",
92
+ ],
93
+ parameters: WorklistParamsSchema,
94
+ executionMode: WORKLIST_EXECUTION_MODE,
95
+ async execute(_id, params, _signal, _onUpdate, ctx) {
96
+ const result = await executeWorklist(params, ctx, { sessionStore, projectPath });
97
+ await updateUi(ctx);
98
+ return { content: [{ type: "text", text: result.content }], details: result.details };
99
+ },
100
+ renderCall(args, theme) {
101
+ return new Text(
102
+ theme.fg("toolTitle", theme.bold("worklist ")) + theme.fg("muted", `${args.scope} ${args.action}`),
103
+ 0,
104
+ 0,
105
+ );
106
+ },
107
+ renderResult(result, _options, theme) {
108
+ const block = result.content.find((item) => item.type === "text");
109
+ return new Text(theme.fg("muted", block?.type === "text" ? block.text : ""), 0, 0);
110
+ },
111
+ });
112
+
113
+ async function handleDashboardAction(action: DashboardAction, ctx: ExtensionContext): Promise<boolean> {
114
+ if (action.kind === "close") return false;
115
+ if (action.kind === "add") {
116
+ const title = await ctx.ui.input(
117
+ `Add ${action.scope === "session" ? "session task" : "project goal"}`,
118
+ "Title",
119
+ );
120
+ if (title?.trim()) await execute({ scope: action.scope, action: "add", title: title.trim() }, ctx);
121
+ return true;
122
+ }
123
+ if (action.kind === "edit") {
124
+ const title = await ctx.ui.input("Edit title", "New title");
125
+ if (title?.trim())
126
+ await execute({ scope: action.scope, action: "update", id: action.id, title: title.trim() }, ctx);
127
+ return true;
128
+ }
129
+ if (action.kind === "delete") {
130
+ const confirmed = await ctx.ui.confirm("Delete item?", "This cannot be undone.");
131
+ if (confirmed)
132
+ await execute(
133
+ { scope: action.scope, action: "delete", id: action.id, confirm: action.scope === "project" },
134
+ ctx,
135
+ );
136
+ return true;
137
+ }
138
+ if (action.scope === "session") {
139
+ const task = sessionStore.getTasks().find((item) => item.id === action.id);
140
+ if (task) {
141
+ const status: SessionTaskStatus =
142
+ task.status === "todo" ? "doing" : task.status === "doing" ? "done" : "todo";
143
+ await execute({ scope: "session", action: "set_status", id: task.id, status }, ctx);
144
+ }
145
+ return true;
146
+ }
147
+ const goal = projectGoals.find((item) => item.id === action.id);
148
+ if (!goal) return true;
149
+ if (goal.status === "open") await execute({ scope: "project", action: "set_active", id: goal.id }, ctx);
150
+ else {
151
+ const actionName = goal.status === "active" ? "complete" : "reopen";
152
+ const confirmed = await ctx.ui.confirm(`${actionName} project goal?`, goal.title);
153
+ if (confirmed) await execute({ scope: "project", action: actionName, id: goal.id, confirm: true }, ctx);
154
+ }
155
+ return true;
156
+ }
157
+
158
+ pi.registerCommand("tasks", {
159
+ description:
160
+ "Open Worklist, or use: /tasks <session|project> <list|add|update|status|complete|reopen|archive|delete|set_active> ...",
161
+ handler: async (args, ctx) => {
162
+ if (args.trim()) {
163
+ const parsed = parseTasksCommand(args);
164
+ if (!parsed || (parsed.action === "add" && !parsed.title)) {
165
+ ctx.ui.notify("Usage: /tasks <session|project> <action> [id/status/title]", "error");
166
+ return;
167
+ }
168
+ try {
169
+ const result = await execute(parsed, ctx);
170
+ ctx.ui.notify(result.content, "info");
171
+ } catch (error) {
172
+ ctx.ui.notify(String(error), "error");
173
+ }
174
+ return;
175
+ }
176
+ if (ctx.mode !== "tui") {
177
+ ctx.ui.notify(
178
+ `${formatSessionTasks(sessionStore.getTasks())}\n\n${formatProjectGoals(projectGoals)}`,
179
+ "info",
180
+ );
181
+ return;
182
+ }
183
+ let again = true;
184
+ while (again) {
185
+ await refreshProject();
186
+ const action = await ctx.ui.custom<DashboardAction>((tui, theme, _keys, done) => {
187
+ const dashboard = new Dashboard(sessionStore.getTasks(), projectGoals, theme, done);
188
+ return {
189
+ render: (width) => dashboard.render(width),
190
+ invalidate: () => dashboard.invalidate(),
191
+ handleInput: (data) => {
192
+ dashboard.handleInput(data);
193
+ tui.requestRender();
194
+ },
195
+ };
196
+ });
197
+ again = action ? await handleDashboardAction(action, ctx) : false;
198
+ }
199
+ },
200
+ });
201
+
202
+ pi.on("session_start", async (_event, ctx) => {
203
+ sessionStore.reconstruct(ctx);
204
+ projectPath = getProjectPath(ctx.cwd);
205
+ try {
206
+ await updateUi(ctx);
207
+ } catch (error) {
208
+ ctx.ui.notify(String(error), "error");
209
+ }
210
+ });
211
+ pi.on("session_tree", async (_event, ctx) => {
212
+ sessionStore.reconstruct(ctx);
213
+ try {
214
+ await updateUi(ctx);
215
+ } catch (error) {
216
+ ctx.ui.notify(String(error), "error");
217
+ }
218
+ });
219
+ pi.on("before_agent_start", async (event) => {
220
+ const summary = buildPromptSummary(sessionStore.getTasks(), projectGoals);
221
+ if (!summary) return;
222
+ return { systemPrompt: `${event.systemPrompt}\n\n${summary}` };
223
+ });
224
+ pi.on("session_shutdown", () => {
225
+ latestContext?.ui.setWidget("pi-worklist", undefined);
226
+ latestContext = undefined;
227
+ });
228
+ }
package/src/git.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { execSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ export interface GitRootResult {
6
+ root: string | null;
7
+ isGit: boolean;
8
+ error?: string;
9
+ }
10
+
11
+ export function resolveGitRoot(cwd: string): GitRootResult {
12
+ try {
13
+ const raw = execSync("git rev-parse --show-toplevel", {
14
+ cwd,
15
+ encoding: "utf8",
16
+ stdio: ["pipe", "pipe", "pipe"],
17
+ timeout: 10000,
18
+ });
19
+ const top = raw.trim();
20
+ if (!top) return { root: null, isGit: false, error: "not a git repository" };
21
+ const canonical = realpathSync(top);
22
+ return { root: canonical, isGit: true };
23
+ } catch (err) {
24
+ const message = err instanceof Error ? err.message : String(err);
25
+ return { root: null, isGit: false, error: message };
26
+ }
27
+ }
28
+
29
+ export function getWorklistPath(gitRoot: string): string {
30
+ return resolve(gitRoot, ".pi", "worklist.json");
31
+ }
@@ -0,0 +1,121 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import lockfile from "proper-lockfile";
5
+ import type { ProjectGoal, ProjectWorklist } from "./types.ts";
6
+ import { PROJECT_WORKLIST_VERSION } from "./types.ts";
7
+
8
+ export interface ProjectStoreResult<T> {
9
+ data: T;
10
+ error?: string;
11
+ }
12
+
13
+ export type ProjectMutation<T> = (current: ProjectWorklist) => {
14
+ worklist: ProjectWorklist;
15
+ result: T;
16
+ };
17
+
18
+ export function isProjectWorklist(value: unknown): value is ProjectWorklist {
19
+ if (typeof value !== "object" || value === null) return false;
20
+ const obj = value as Record<string, unknown>;
21
+ if (obj.version !== PROJECT_WORKLIST_VERSION) return false;
22
+ if (!Array.isArray(obj.goals)) return false;
23
+ for (const g of obj.goals) {
24
+ if (typeof g !== "object" || g === null) return false;
25
+ const goal = g as Record<string, unknown>;
26
+ if (typeof goal.id !== "string") return false;
27
+ if (typeof goal.title !== "string") return false;
28
+ if (goal.description !== undefined && typeof goal.description !== "string") return false;
29
+ if (!["open", "active", "done", "archived"].includes(goal.status as string)) return false;
30
+ if (typeof goal.createdAt !== "string") return false;
31
+ if (typeof goal.updatedAt !== "string") return false;
32
+ }
33
+ return true;
34
+ }
35
+
36
+ export function createEmptyWorklist(): ProjectWorklist {
37
+ return { version: PROJECT_WORKLIST_VERSION, goals: [] };
38
+ }
39
+
40
+ export async function readProjectWorklist(path: string): Promise<ProjectStoreResult<ProjectWorklist>> {
41
+ try {
42
+ const text = await readFile(path, "utf8");
43
+ let parsed: unknown;
44
+ try {
45
+ parsed = JSON.parse(text);
46
+ } catch {
47
+ return {
48
+ data: createEmptyWorklist(),
49
+ error: `Malformed project file ${path}: invalid JSON`,
50
+ };
51
+ }
52
+ if (!isProjectWorklist(parsed)) {
53
+ return {
54
+ data: createEmptyWorklist(),
55
+ error: `Malformed or unsupported schema in ${path}. Fix the file manually; it will not be overwritten.`,
56
+ };
57
+ }
58
+ return { data: parsed };
59
+ } catch (err) {
60
+ const code = (err as NodeJS.ErrnoException).code;
61
+ if (code === "ENOENT") {
62
+ return { data: createEmptyWorklist() };
63
+ }
64
+ return {
65
+ data: createEmptyWorklist(),
66
+ error: `Cannot read project file ${path}: ${String(err)}`,
67
+ };
68
+ }
69
+ }
70
+
71
+ export async function mutateProjectWorklist<T>(
72
+ path: string,
73
+ mutate: ProjectMutation<T>,
74
+ ): Promise<ProjectStoreResult<T>> {
75
+ const dir = dirname(path);
76
+ await mkdir(dir, { recursive: true });
77
+
78
+ const release = await lockfile.lock(dir, {
79
+ lockfilePath: resolve(dir, ".worklist.lock"),
80
+ retries: { retries: 20, factor: 1.5, minTimeout: 10, maxTimeout: 250 },
81
+ stale: 10000,
82
+ });
83
+ let tempName: string | undefined;
84
+
85
+ try {
86
+ const readResult = await readProjectWorklist(path);
87
+ if (readResult.error) {
88
+ return { data: undefined as unknown as T, error: readResult.error };
89
+ }
90
+
91
+ const { worklist, result } = mutate(readResult.data);
92
+ if (!isProjectWorklist(worklist)) {
93
+ return {
94
+ data: undefined as unknown as T,
95
+ error: "Project mutation produced an invalid worklist",
96
+ };
97
+ }
98
+
99
+ tempName = resolve(dir, `.worklist-${randomBytes(8).toString("hex")}.tmp`);
100
+ await writeFile(tempName, `${JSON.stringify(worklist, null, 2)}\n`, "utf8");
101
+ await rename(tempName, path);
102
+ tempName = undefined;
103
+ return { data: result };
104
+ } catch (err) {
105
+ return {
106
+ data: undefined as unknown as T,
107
+ error: `Project mutation failed: ${String(err)}`,
108
+ };
109
+ } finally {
110
+ if (tempName) await rm(tempName, { force: true });
111
+ await release();
112
+ }
113
+ }
114
+
115
+ export function sortGoals(goals: ProjectGoal[]): ProjectGoal[] {
116
+ return goals.slice().sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
117
+ }
118
+
119
+ export function generateId(prefix?: string): string {
120
+ return `${prefix ? `${prefix}-` : ""}${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
121
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type, type TUnsafe } from "typebox";
3
+
4
+ const ScopeSchema: TUnsafe<"session" | "project"> = StringEnum(["session", "project"] as const, {
5
+ description: "Whether to operate on session tasks or project goals.",
6
+ });
7
+
8
+ const ActionSchema: TUnsafe<
9
+ "list" | "add" | "update" | "set_status" | "delete" | "complete" | "reopen" | "archive" | "set_active"
10
+ > = StringEnum(
11
+ ["list", "add", "update", "set_status", "delete", "complete", "reopen", "archive", "set_active"] as const,
12
+ {
13
+ description:
14
+ "Action to perform. 'complete', 'reopen', 'archive', and 'delete' on project goals require confirm=true.",
15
+ },
16
+ );
17
+
18
+ export const SessionTaskStatusSchema: TUnsafe<"todo" | "doing" | "done"> = StringEnum([
19
+ "todo",
20
+ "doing",
21
+ "done",
22
+ ] as const);
23
+
24
+ export const ProjectGoalStatusSchema: TUnsafe<"open" | "active" | "done" | "archived"> = StringEnum([
25
+ "open",
26
+ "active",
27
+ "done",
28
+ "archived",
29
+ ] as const);
30
+
31
+ const StatusSchema: TUnsafe<"todo" | "doing" | "done" | "open" | "active" | "archived"> = StringEnum(
32
+ ["todo", "doing", "done", "open", "active", "archived"] as const,
33
+ {
34
+ description:
35
+ "Target status for set_status. Project set_status only accepts active; lifecycle actions use complete, reopen, and archive.",
36
+ },
37
+ );
38
+
39
+ export const WorklistParamsSchema = Type.Object({
40
+ scope: ScopeSchema,
41
+ action: ActionSchema,
42
+ id: Type.Optional(
43
+ Type.String({
44
+ description: "Task or goal ID (for update, set_status, delete, complete, reopen, archive, set_active).",
45
+ }),
46
+ ),
47
+ title: Type.Optional(Type.String({ description: "Title for add/update." })),
48
+ description: Type.Optional(Type.String({ description: "Description for project goal add/update." })),
49
+ status: Type.Optional(StatusSchema),
50
+ goalId: Type.Optional(
51
+ Type.String({
52
+ description: "Associate a session task with a project goal ID.",
53
+ }),
54
+ ),
55
+ confirm: Type.Optional(
56
+ Type.Boolean({
57
+ description:
58
+ "Required boolean for destructive project-goal actions: complete, reopen, archive, delete. Set to true ONLY when the user explicitly requested the action.",
59
+ }),
60
+ ),
61
+ });
@@ -0,0 +1,91 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { SessionSnapshot, SessionTask, SessionTaskStatus } from "./types.ts";
3
+ import { SESSION_SNAPSHOT_VERSION } from "./types.ts";
4
+
5
+ export const SESSION_SNAPSHOT_TYPE = "worklist-session-snapshot";
6
+
7
+ export class SessionStore {
8
+ private tasks: SessionTask[] = [];
9
+ private mutationQueue: Promise<unknown> = Promise.resolve();
10
+
11
+ constructor(private readonly pi: ExtensionAPI) {}
12
+
13
+ getTasks(): SessionTask[] {
14
+ return this.tasks.slice();
15
+ }
16
+
17
+ setTasks(tasks: SessionTask[]): void {
18
+ this.tasks = tasks.slice();
19
+ }
20
+
21
+ reconstruct(ctx: ExtensionContext): void {
22
+ this.tasks = [];
23
+ const branch = ctx.sessionManager.getBranch();
24
+ for (const entry of branch) {
25
+ if (entry.type !== "custom") continue;
26
+ if (entry.customType !== SESSION_SNAPSHOT_TYPE) continue;
27
+ const data = entry.data as SessionSnapshot | undefined;
28
+ if (data && data.version === SESSION_SNAPSHOT_VERSION && Array.isArray(data.tasks)) {
29
+ this.tasks = data.tasks.slice();
30
+ }
31
+ }
32
+ }
33
+
34
+ private async serialized<T>(fn: () => Promise<T>): Promise<T> {
35
+ const next = this.mutationQueue.then(fn);
36
+ this.mutationQueue = next.catch(() => undefined);
37
+ return next;
38
+ }
39
+
40
+ async addTask(title: string, goalId?: string): Promise<SessionTask> {
41
+ return this.serialized(async () => {
42
+ const id = `st-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
43
+ const task: SessionTask = { id, title, status: "todo", goalId };
44
+ this.tasks = [...this.tasks, task];
45
+ this.persist();
46
+ return task;
47
+ });
48
+ }
49
+
50
+ async updateTask(
51
+ id: string,
52
+ updates: Partial<Pick<SessionTask, "title" | "goalId">>,
53
+ ): Promise<SessionTask | null> {
54
+ return this.serialized(async () => {
55
+ const index = this.tasks.findIndex((t) => t.id === id);
56
+ if (index === -1) return null;
57
+ const updated = { ...this.tasks[index], ...updates };
58
+ this.tasks = [...this.tasks.slice(0, index), updated, ...this.tasks.slice(index + 1)];
59
+ this.persist();
60
+ return updated;
61
+ });
62
+ }
63
+
64
+ async setTaskStatus(id: string, status: SessionTaskStatus): Promise<SessionTask | null> {
65
+ return this.serialized(async () => {
66
+ const index = this.tasks.findIndex((t) => t.id === id);
67
+ if (index === -1) return null;
68
+ const updated = { ...this.tasks[index], status };
69
+ this.tasks = [...this.tasks.slice(0, index), updated, ...this.tasks.slice(index + 1)];
70
+ this.persist();
71
+ return updated;
72
+ });
73
+ }
74
+
75
+ async deleteTask(id: string): Promise<boolean> {
76
+ return this.serialized(async () => {
77
+ const before = this.tasks.length;
78
+ this.tasks = this.tasks.filter((t) => t.id !== id);
79
+ if (this.tasks.length === before) return false;
80
+ this.persist();
81
+ return true;
82
+ });
83
+ }
84
+
85
+ private persist(): void {
86
+ this.pi.appendEntry(SESSION_SNAPSHOT_TYPE, {
87
+ version: SESSION_SNAPSHOT_VERSION,
88
+ tasks: this.tasks.slice(),
89
+ });
90
+ }
91
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,303 @@
1
+ /* eslint-disable no-case-declarations */
2
+ import type { ExtensionContext, ToolExecutionMode } from "@earendil-works/pi-coding-agent";
3
+ import type {
4
+ ProjectGoal,
5
+ ProjectGoalStatus,
6
+ SessionTask,
7
+ SessionTaskStatus,
8
+ WorklistToolDetails,
9
+ } from "./types.ts";
10
+ import { generateId, mutateProjectWorklist, readProjectWorklist, sortGoals } from "./project-store.ts";
11
+ import type { SessionStore } from "./session-store.ts";
12
+ import { getWorklistPath, resolveGitRoot } from "./git.ts";
13
+
14
+ export interface ToolDeps {
15
+ sessionStore: SessionStore;
16
+ projectPath: string | null;
17
+ }
18
+
19
+ export function getProjectPath(cwd: string): string | null {
20
+ const result = resolveGitRoot(cwd);
21
+ if (!result.isGit || !result.root) return null;
22
+ return getWorklistPath(result.root);
23
+ }
24
+
25
+ export function formatSessionTasks(tasks: SessionTask[]): string {
26
+ if (tasks.length === 0) return "No session tasks.";
27
+ return tasks
28
+ .map((t) => {
29
+ const marker = t.status === "done" ? "[x]" : t.status === "doing" ? "[~]" : "[ ]";
30
+ const goal = t.goalId ? ` (goal:${t.goalId})` : "";
31
+ return `${marker} ${t.id}: ${t.title}${goal}`;
32
+ })
33
+ .join("\n");
34
+ }
35
+
36
+ export function formatProjectGoals(goals: ProjectGoal[]): string {
37
+ if (goals.length === 0) return "No project goals.";
38
+ return sortGoals(goals)
39
+ .map((g) => `[${g.status}] ${g.id}: ${g.title}${g.description ? ` - ${g.description}` : ""}`)
40
+ .join("\n");
41
+ }
42
+
43
+ export async function executeWorklist(
44
+ params: {
45
+ scope: "session" | "project";
46
+ action: string;
47
+ id?: string;
48
+ title?: string;
49
+ description?: string;
50
+ status?: SessionTaskStatus | ProjectGoalStatus;
51
+ goalId?: string;
52
+ confirm?: boolean;
53
+ },
54
+ _ctx: ExtensionContext,
55
+ deps: ToolDeps,
56
+ ): Promise<{ content: string; details: WorklistToolDetails }> {
57
+ const { sessionStore, projectPath } = deps;
58
+
59
+ if (params.scope === "session") {
60
+ switch (params.action) {
61
+ case "list": {
62
+ const tasks = sessionStore.getTasks();
63
+ return {
64
+ content: formatSessionTasks(tasks),
65
+ details: { scope: "session", action: "list", tasks },
66
+ };
67
+ }
68
+ case "add": {
69
+ if (!params.title) throw new Error("title is required for session add");
70
+ const task = await sessionStore.addTask(params.title, params.goalId);
71
+ const tasks = sessionStore.getTasks();
72
+ return {
73
+ content: `Added session task ${task.id}: ${task.title}`,
74
+ details: { scope: "session", action: "add", tasks },
75
+ };
76
+ }
77
+ case "update": {
78
+ if (!params.id) throw new Error("id is required for session update");
79
+ const updates: Partial<Pick<SessionTask, "title" | "goalId">> = {};
80
+ if (params.title !== undefined) updates.title = params.title;
81
+ if (params.goalId !== undefined) updates.goalId = params.goalId;
82
+ const task = await sessionStore.updateTask(params.id, updates);
83
+ if (!task) throw new Error(`Session task ${params.id} not found`);
84
+ const tasks = sessionStore.getTasks();
85
+ return {
86
+ content: `Updated session task ${task.id}`,
87
+ details: { scope: "session", action: "update", tasks },
88
+ };
89
+ }
90
+ case "set_status": {
91
+ if (!params.id) throw new Error("id is required for session set_status");
92
+ if (!params.status || !["todo", "doing", "done"].includes(params.status)) {
93
+ throw new Error("status must be todo, doing, or done for session tasks");
94
+ }
95
+ const task = await sessionStore.setTaskStatus(params.id, params.status as SessionTaskStatus);
96
+ if (!task) throw new Error(`Session task ${params.id} not found`);
97
+ const tasks = sessionStore.getTasks();
98
+ return {
99
+ content: `Set session task ${task.id} to ${task.status}`,
100
+ details: { scope: "session", action: "set_status", tasks },
101
+ };
102
+ }
103
+ case "delete": {
104
+ if (!params.id) throw new Error("id is required for session delete");
105
+ const removed = await sessionStore.deleteTask(params.id);
106
+ if (!removed) throw new Error(`Session task ${params.id} not found`);
107
+ const tasks = sessionStore.getTasks();
108
+ return {
109
+ content: `Deleted session task ${params.id}`,
110
+ details: { scope: "session", action: "delete", tasks },
111
+ };
112
+ }
113
+ default:
114
+ throw new Error(`Unknown session action: ${params.action}`);
115
+ }
116
+ }
117
+
118
+ if (params.scope === "project") {
119
+ if (!projectPath) {
120
+ throw new Error(
121
+ "Project goals require a git repository. Session tasks are still available outside git.",
122
+ );
123
+ }
124
+
125
+ switch (params.action) {
126
+ case "list": {
127
+ const { data, error } = await readProjectWorklist(projectPath);
128
+ if (error) throw new Error(error);
129
+ return {
130
+ content: formatProjectGoals(data.goals),
131
+ details: { scope: "project", action: "list", goals: data.goals },
132
+ };
133
+ }
134
+ case "add": {
135
+ if (!params.title) throw new Error("title is required for project add");
136
+ const now = new Date().toISOString();
137
+ const goal: ProjectGoal = {
138
+ id: generateId("goal"),
139
+ title: params.title,
140
+ description: params.description,
141
+ status: "open",
142
+ createdAt: now,
143
+ updatedAt: now,
144
+ };
145
+ const result = await mutateProjectWorklist(projectPath, (worklist) => ({
146
+ worklist: {
147
+ ...worklist,
148
+ goals: sortGoals([...worklist.goals, goal]),
149
+ },
150
+ result: goal,
151
+ }));
152
+ if (result.error) throw new Error(result.error);
153
+ return {
154
+ content: `Added project goal ${result.data.id}: ${result.data.title}`,
155
+ details: {
156
+ scope: "project",
157
+ action: "add",
158
+ goals: sortGoals([...(await readProjectWorklist(projectPath)).data.goals]),
159
+ },
160
+ };
161
+ }
162
+ case "update": {
163
+ if (!params.id) throw new Error("id is required for project update");
164
+ {
165
+ const result = await mutateProjectWorklist(projectPath, (worklist) => {
166
+ const index = worklist.goals.findIndex((g) => g.id === params.id);
167
+ if (index === -1) return { worklist, result: null };
168
+ const updated: ProjectGoal = { ...worklist.goals[index] };
169
+ if (params.title !== undefined) updated.title = params.title;
170
+ if (params.description !== undefined) updated.description = params.description;
171
+ updated.updatedAt = new Date().toISOString();
172
+ const goals = [...worklist.goals];
173
+ goals[index] = updated;
174
+ return { worklist: { ...worklist, goals }, result: updated };
175
+ });
176
+ if (result.error) throw new Error(result.error);
177
+ if (!result.data) throw new Error(`Project goal ${params.id} not found`);
178
+ return {
179
+ content: `Updated project goal ${result.data.id}`,
180
+ details: {
181
+ scope: "project",
182
+ action: "update",
183
+ goals: (await readProjectWorklist(projectPath)).data.goals,
184
+ },
185
+ };
186
+ }
187
+ }
188
+ case "set_status": {
189
+ if (params.status !== "active") {
190
+ throw new Error(
191
+ "Project set_status only accepts active. Use complete, reopen, or archive with confirm=true for lifecycle changes.",
192
+ );
193
+ }
194
+ return executeWorklist({ ...params, action: "set_active" }, _ctx, deps);
195
+ }
196
+ case "set_active": {
197
+ if (!params.id) throw new Error("id is required for project set_active");
198
+ {
199
+ const result = await mutateProjectWorklist(projectPath, (worklist) => {
200
+ const target = worklist.goals.find((goal) => goal.id === params.id);
201
+ if (!target) return { worklist, result: { goal: null, blocked: false } };
202
+ if (target.status === "done" || target.status === "archived") {
203
+ return { worklist, result: { goal: target, blocked: true } };
204
+ }
205
+ const now = new Date().toISOString();
206
+ const goals = worklist.goals.map((goal) =>
207
+ goal.id === params.id
208
+ ? { ...goal, status: "active" as ProjectGoalStatus, updatedAt: now }
209
+ : goal.status === "active"
210
+ ? { ...goal, status: "open" as ProjectGoalStatus, updatedAt: now }
211
+ : goal,
212
+ );
213
+ return {
214
+ worklist: { ...worklist, goals },
215
+ result: { goal: goals.find((goal) => goal.id === params.id) ?? null, blocked: false },
216
+ };
217
+ });
218
+ if (result.error) throw new Error(result.error);
219
+ if (result.data.blocked)
220
+ throw new Error(
221
+ "A done or archived Project Goal must be reopened with confirm=true before activation.",
222
+ );
223
+ if (!result.data.goal) throw new Error(`Project goal ${params.id} not found`);
224
+ return {
225
+ content: `Activated project goal ${result.data.goal.id}`,
226
+ details: {
227
+ scope: "project",
228
+ action: "set_active",
229
+ goals: (await readProjectWorklist(projectPath)).data.goals,
230
+ },
231
+ };
232
+ }
233
+ }
234
+ case "complete":
235
+ case "reopen":
236
+ case "archive":
237
+ case "delete": {
238
+ if (!params.id) throw new Error(`id is required for project ${params.action}`);
239
+ if (params.confirm !== true) {
240
+ return {
241
+ content: `Project goal ${params.action} requires explicit user intent. Set confirm=true only when the user explicitly requested this action.`,
242
+ details: {
243
+ scope: "project",
244
+ action: params.action,
245
+ requiresConfirm: true,
246
+ },
247
+ };
248
+ }
249
+ if (params.action === "delete") {
250
+ {
251
+ const result = await mutateProjectWorklist(projectPath, (worklist) => {
252
+ const goals = worklist.goals.filter((g) => g.id !== params.id);
253
+ const removed = goals.length !== worklist.goals.length;
254
+ return { worklist: { ...worklist, goals }, result: removed };
255
+ });
256
+ if (result.error) throw new Error(result.error);
257
+ if (!result.data) throw new Error(`Project goal ${params.id} not found`);
258
+ return {
259
+ content: `Deleted project goal ${params.id}`,
260
+ details: {
261
+ scope: "project",
262
+ action: "delete",
263
+ goals: (await readProjectWorklist(projectPath)).data.goals,
264
+ },
265
+ };
266
+ }
267
+ }
268
+ {
269
+ const targetStatus: ProjectGoalStatus =
270
+ params.action === "complete" ? "done" : params.action === "reopen" ? "open" : "archived";
271
+ const result = await mutateProjectWorklist(projectPath, (worklist) => {
272
+ const index = worklist.goals.findIndex((g) => g.id === params.id);
273
+ if (index === -1) return { worklist, result: null };
274
+ const updated: ProjectGoal = {
275
+ ...worklist.goals[index],
276
+ status: targetStatus,
277
+ };
278
+ updated.updatedAt = new Date().toISOString();
279
+ const goals = [...worklist.goals];
280
+ goals[index] = updated;
281
+ return { worklist: { ...worklist, goals }, result: updated };
282
+ });
283
+ if (result.error) throw new Error(result.error);
284
+ if (!result.data) throw new Error(`Project goal ${params.id} not found`);
285
+ return {
286
+ content: `Project goal ${result.data.id} is now ${result.data.status}`,
287
+ details: {
288
+ scope: "project",
289
+ action: params.action,
290
+ goals: (await readProjectWorklist(projectPath)).data.goals,
291
+ },
292
+ };
293
+ }
294
+ }
295
+ default:
296
+ throw new Error(`Unknown project action: ${params.action}`);
297
+ }
298
+ }
299
+
300
+ throw new Error(`Unknown scope: ${params.scope}`);
301
+ }
302
+
303
+ export const WORKLIST_EXECUTION_MODE = "sequential" as ToolExecutionMode;
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type SessionTaskStatus = "todo" | "doing" | "done";
2
+ export type ProjectGoalStatus = "open" | "active" | "done" | "archived";
3
+
4
+ export interface SessionTask {
5
+ id: string;
6
+ title: string;
7
+ status: SessionTaskStatus;
8
+ goalId?: string;
9
+ }
10
+
11
+ export interface ProjectGoal {
12
+ id: string;
13
+ title: string;
14
+ description?: string;
15
+ status: ProjectGoalStatus;
16
+ createdAt: string;
17
+ updatedAt: string;
18
+ }
19
+
20
+ export interface SessionSnapshot {
21
+ version: number;
22
+ tasks: SessionTask[];
23
+ }
24
+
25
+ export interface ProjectWorklist {
26
+ version: number;
27
+ goals: ProjectGoal[];
28
+ }
29
+
30
+ export interface WorklistToolDetails {
31
+ scope: "session" | "project";
32
+ action: string;
33
+ tasks?: SessionTask[];
34
+ goals?: ProjectGoal[];
35
+ error?: string;
36
+ requiresConfirm?: boolean;
37
+ }
38
+
39
+ export const SESSION_SNAPSHOT_VERSION = 1;
40
+ export const PROJECT_WORKLIST_VERSION = 1;
package/src/ui.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { ProjectGoal, SessionTask } from "./types.ts";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
4
+
5
+ export function buildWidgetLines(tasks: SessionTask[], goals: ProjectGoal[]): string[] {
6
+ const active = goals.find((goal) => goal.status === "active");
7
+ const pending = tasks.filter((task) => task.status !== "done");
8
+ if (!active && pending.length === 0) return [];
9
+ const lines: string[] = [];
10
+ if (active) lines.push(`Goal: ${active.title}`);
11
+ for (const task of pending.slice(0, 3)) {
12
+ lines.push(`${task.status === "doing" ? "●" : "○"} ${task.title}`);
13
+ }
14
+ if (pending.length > 3) lines.push(`+${pending.length - 3} more`);
15
+ return lines;
16
+ }
17
+
18
+ export function buildPromptSummary(tasks: SessionTask[], goals: ProjectGoal[], maxItems = 8): string {
19
+ const active = goals.find((goal) => goal.status === "active");
20
+ const pending = tasks.filter((task) => task.status !== "done").slice(0, maxItems);
21
+ if (!active && pending.length === 0) return "";
22
+ const lines = ["[WORKLIST]"];
23
+ if (active) lines.push(`Active project goal: ${active.title}`);
24
+ if (pending.length) {
25
+ lines.push("Incomplete session tasks:");
26
+ for (const task of pending) lines.push(`- [${task.status === "doing" ? "doing" : "todo"}] ${task.title}`);
27
+ }
28
+ const remaining = tasks.filter((task) => task.status !== "done").length - pending.length;
29
+ if (remaining > 0) lines.push(`- ...and ${remaining} more`);
30
+ return lines.join("\n");
31
+ }
32
+
33
+ export type DashboardAction =
34
+ | { kind: "close" }
35
+ | { kind: "add"; scope: "session" | "project" }
36
+ | { kind: "edit"; scope: "session" | "project"; id: string }
37
+ | { kind: "advance"; scope: "session" | "project"; id: string }
38
+ | { kind: "delete"; scope: "session" | "project"; id: string };
39
+
40
+ export class Dashboard {
41
+ private scope: "session" | "project" = "session";
42
+ private selected = 0;
43
+ constructor(
44
+ private readonly tasks: SessionTask[],
45
+ private readonly goals: ProjectGoal[],
46
+ private readonly theme: Theme,
47
+ private readonly done: (action: DashboardAction) => void,
48
+ ) {}
49
+
50
+ private items(): Array<SessionTask | ProjectGoal> {
51
+ return this.scope === "session" ? this.tasks : this.goals.filter((goal) => goal.status !== "archived");
52
+ }
53
+
54
+ handleInput(data: string): void {
55
+ const items = this.items();
56
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
57
+ this.done({ kind: "close" });
58
+ return;
59
+ }
60
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
61
+ this.scope = this.scope === "session" ? "project" : "session";
62
+ this.selected = 0;
63
+ return;
64
+ }
65
+ if (matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
66
+ if (matchesKey(data, Key.down))
67
+ this.selected = Math.min(Math.max(0, items.length - 1), this.selected + 1);
68
+ if (data === "a") {
69
+ this.done({ kind: "add", scope: this.scope });
70
+ return;
71
+ }
72
+ const item = items[this.selected];
73
+ if (!item) return;
74
+ if (data === "e") this.done({ kind: "edit", scope: this.scope, id: item.id });
75
+ else if (data === "d") this.done({ kind: "delete", scope: this.scope, id: item.id });
76
+ else if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
77
+ this.done({ kind: "advance", scope: this.scope, id: item.id });
78
+ }
79
+ }
80
+
81
+ render(width: number): string[] {
82
+ const th = this.theme;
83
+ const lines = [
84
+ th.fg("accent", th.bold("Worklist")),
85
+ `${this.scope === "session" ? th.fg("accent", "[Session Tasks]") : "Session Tasks"} ${this.scope === "project" ? th.fg("accent", "[Project Goals]") : "Project Goals"}`,
86
+ "",
87
+ ];
88
+ const items = this.items();
89
+ if (!items.length) lines.push(th.fg("dim", " No items. Press a to add one."));
90
+ items.forEach((item, index) => {
91
+ const status = item.status;
92
+ const marker = status === "done" ? "✓" : status === "doing" || status === "active" ? "●" : "○";
93
+ const prefix = index === this.selected ? th.fg("accent", ">") : " ";
94
+ lines.push(`${prefix} ${marker} ${item.title} ${th.fg("dim", item.id)}`);
95
+ });
96
+ lines.push(
97
+ "",
98
+ th.fg("dim", "tab switch ↑↓ navigate a add e edit space/enter advance d delete esc close"),
99
+ );
100
+ return lines.map((line) => truncateToWidth(line, width));
101
+ }
102
+
103
+ invalidate(): void {}
104
+ }