@dreki-gg/pi-plan-mode 0.15.1 → 0.17.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/extensions/plan-mode/__tests__/add-task.test.ts +75 -0
  3. package/extensions/plan-mode/__tests__/atomic-write.test.ts +6 -4
  4. package/extensions/plan-mode/__tests__/html-render.test.ts +26 -35
  5. package/extensions/plan-mode/__tests__/package-skills.test.ts +51 -0
  6. package/extensions/plan-mode/__tests__/plan-storage.test.ts +57 -0
  7. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +27 -16
  8. package/extensions/plan-mode/__tests__/schema.test.ts +212 -0
  9. package/extensions/plan-mode/__tests__/task-status.test.ts +74 -0
  10. package/extensions/plan-mode/__tests__/task-storage.test.ts +35 -12
  11. package/extensions/plan-mode/__tests__/utils.test.ts +19 -1
  12. package/extensions/plan-mode/constants.ts +2 -1
  13. package/extensions/plan-mode/effects/filesystem.ts +65 -0
  14. package/extensions/plan-mode/effects/runtime.ts +24 -0
  15. package/extensions/plan-mode/errors.ts +105 -0
  16. package/extensions/plan-mode/html/render.ts +10 -114
  17. package/extensions/plan-mode/html/templates/prototype.pug +25 -0
  18. package/extensions/plan-mode/index.ts +151 -48
  19. package/extensions/plan-mode/prompts.ts +5 -3
  20. package/extensions/plan-mode/resume.ts +19 -15
  21. package/extensions/plan-mode/schema.ts +57 -0
  22. package/extensions/plan-mode/storage/atomic-write.ts +19 -1
  23. package/extensions/plan-mode/storage/plan-storage.ts +57 -29
  24. package/extensions/plan-mode/storage/plans-manifest.ts +64 -57
  25. package/extensions/plan-mode/storage/task-storage.ts +83 -45
  26. package/extensions/plan-mode/task-status.ts +46 -0
  27. package/extensions/plan-mode/tools/add-task.ts +95 -0
  28. package/extensions/plan-mode/tools/preview-prototype.ts +98 -0
  29. package/extensions/plan-mode/tools/submit-plan.ts +18 -16
  30. package/extensions/plan-mode/types.ts +8 -38
  31. package/extensions/plan-mode/utils.ts +20 -0
  32. package/package.json +2 -1
  33. package/skills/planning-context/SKILL.md +42 -0
  34. package/skills/visual-prototype/SKILL.md +45 -0
  35. package/extensions/plan-mode/__tests__/types.test.ts +0 -70
  36. package/extensions/plan-mode/html/templates/plan.pug +0 -69
@@ -1,10 +1,26 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { Cause, Effect, Exit, Option } from 'effect';
2
3
  import { mkdtemp, rm } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { tmpdir } from 'node:os';
6
+ import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
5
7
  import { readTasksJsonl, updateTask, writeTasksJsonl } from '../storage/task-storage.js';
6
8
  import type { TaskMeta, TaskRecord } from '../types.js';
7
9
 
10
+ const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
11
+ Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
12
+
13
+ const runExit = <A, E>(program: Effect.Effect<A, E, FileSystem>) =>
14
+ Effect.runPromiseExit(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
15
+
16
+ const failureTag = <A, E>(exit: Exit.Exit<A, E>): string | undefined => {
17
+ if (!Exit.isFailure(exit)) return undefined;
18
+ const error = Option.getOrUndefined(Cause.failureOption(exit.cause)) as
19
+ | { _tag?: string }
20
+ | undefined;
21
+ return error?._tag;
22
+ };
23
+
8
24
  let dir: string;
9
25
  const now = '2026-05-27T12:00:00.000Z';
10
26
  const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
@@ -27,8 +43,8 @@ afterEach(async () => {
27
43
 
28
44
  describe('tasks.jsonl storage', () => {
29
45
  test('round trips meta and tasks', async () => {
30
- await writeTasksJsonl(dir, meta, [task]);
31
- await expect(readTasksJsonl(dir)).resolves.toEqual({ meta, tasks: [task] });
46
+ await run(writeTasksJsonl(dir, meta, [task]));
47
+ await expect(run(readTasksJsonl(dir))).resolves.toEqual({ meta, tasks: [task] });
32
48
  });
33
49
 
34
50
  test('round trips tasks without details (lightweight checklist)', async () => {
@@ -40,32 +56,39 @@ describe('tasks.jsonl storage', () => {
40
56
  created_at: now,
41
57
  updated_at: now,
42
58
  };
43
- await writeTasksJsonl(dir, meta, [lightweight]);
44
- const result = await readTasksJsonl(dir);
59
+ await run(writeTasksJsonl(dir, meta, [lightweight]));
60
+ const result = await run(readTasksJsonl(dir));
45
61
  expect(result?.tasks[0]?.id).toBe('t-002');
46
62
  expect(result?.tasks[0]?.details).toBeUndefined();
47
63
  });
48
64
 
49
65
  test('missing file returns undefined', async () => {
50
- await expect(readTasksJsonl(dir)).resolves.toBeUndefined();
66
+ await expect(run(readTasksJsonl(dir))).resolves.toBeUndefined();
51
67
  });
52
68
 
53
- test('rejects corrupt lines', async () => {
69
+ test('rejects corrupt lines with JsonlParseError', async () => {
54
70
  await Bun.write(join(dir, 'tasks.jsonl'), `${JSON.stringify(meta)}\nnot-json\n`);
55
- await expect(readTasksJsonl(dir)).rejects.toThrow(/Invalid JSONL/);
71
+ expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('JsonlParseError');
56
72
  });
57
73
 
58
- test('rejects empty files', async () => {
74
+ test('rejects empty files with MissingMetaRecord', async () => {
59
75
  await Bun.write(join(dir, 'tasks.jsonl'), '');
60
- await expect(readTasksJsonl(dir)).rejects.toThrow(/meta/);
76
+ expect(failureTag(await runExit(readTasksJsonl(dir)))).toBe('MissingMetaRecord');
61
77
  });
62
78
 
63
79
  test('updates a task by id and rewrites the snapshot', async () => {
64
- await writeTasksJsonl(dir, meta, [task]);
65
- const updated = await updateTask(dir, 't-001', { status: 'done', notes: 'finished' });
80
+ await run(writeTasksJsonl(dir, meta, [task]));
81
+ const updated = await run(updateTask(dir, 't-001', { status: 'done', notes: 'finished' }));
66
82
 
67
83
  expect(updated.status).toBe('done');
68
84
  expect(updated.notes).toBe('finished');
69
- expect((await readTasksJsonl(dir))?.tasks[0]?.status).toBe('done');
85
+ expect((await run(readTasksJsonl(dir)))?.tasks[0]?.status).toBe('done');
86
+ });
87
+
88
+ test('fails with TaskNotFound for an unknown task id', async () => {
89
+ await run(writeTasksJsonl(dir, meta, [task]));
90
+ expect(failureTag(await runExit(updateTask(dir, 't-999', { status: 'done' })))).toBe(
91
+ 'TaskNotFound',
92
+ );
70
93
  });
71
94
  });
@@ -1,5 +1,23 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { isSafeCommand, isPlanPath } from '../utils.js';
2
+ import { isSafeCommand, isPlanPath, nextTaskId } from '../utils.js';
3
+
4
+ describe('nextTaskId', () => {
5
+ test('increments the max numeric suffix', () => {
6
+ expect(nextTaskId(['t-001', 't-002', 't-003'])).toBe('t-004');
7
+ });
8
+
9
+ test('uses the max even when ids are unordered or sparse', () => {
10
+ expect(nextTaskId(['t-003', 't-001', 't-010'])).toBe('t-011');
11
+ });
12
+
13
+ test('starts at t-001 for an empty plan', () => {
14
+ expect(nextTaskId([])).toBe('t-001');
15
+ });
16
+
17
+ test('falls back to count+1 when no ids match the pattern', () => {
18
+ expect(nextTaskId(['setup', 'cleanup'])).toBe('t-003');
19
+ });
20
+ });
3
21
 
4
22
  describe('isSafeCommand', () => {
5
23
  // ── Commands that SHOULD be allowed ──────────────────────────────────────
@@ -10,13 +10,14 @@ export const PLAN_TOOLS = [
10
10
  'find',
11
11
  'ls',
12
12
  'submit_plan',
13
+ 'preview_prototype',
13
14
  'write',
14
15
  'questionnaire',
15
16
  'search_skills',
16
17
  'subagent',
17
18
  ];
18
19
 
19
- export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task'];
20
+ export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task', 'add_task'];
20
21
 
21
22
  // ── Model + thinking presets ─────────────────────────────────────────────────
22
23
  export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * FileSystem service — the single seam for plan-mode disk I/O.
3
+ *
4
+ * Storage programs depend on this `Context.Tag` rather than touching
5
+ * `node:fs/promises` directly, which makes them trivially testable and keeps
6
+ * all failure modes typed (`PlanReadError` / `PlanWriteError`).
7
+ */
8
+
9
+ import { Context, Effect } from 'effect';
10
+ import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
11
+ import { PlanReadError, PlanWriteError } from '../errors.js';
12
+ import { writeFileAtomic } from '../storage/atomic-write.js';
13
+
14
+ export interface FileSystemService {
15
+ readonly readFileString: (path: string) => Effect.Effect<string, PlanReadError>;
16
+ readonly writeFileString: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
17
+ readonly writeFileAtomic: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
18
+ readonly makeDir: (path: string) => Effect.Effect<void, PlanWriteError>;
19
+ readonly listDirectories: (path: string) => Effect.Effect<string[], PlanReadError>;
20
+ readonly removeFile: (path: string) => Effect.Effect<void, PlanWriteError>;
21
+ }
22
+
23
+ export class FileSystem extends Context.Tag('PlanMode/FileSystem')<
24
+ FileSystem,
25
+ FileSystemService
26
+ >() {}
27
+
28
+ export const nodeFileSystemService: FileSystemService = {
29
+ readFileString: (path) =>
30
+ Effect.tryPromise({
31
+ try: () => readFile(path, 'utf-8'),
32
+ catch: (cause) => new PlanReadError({ path, cause }),
33
+ }),
34
+
35
+ writeFileString: (path, data) =>
36
+ Effect.tryPromise({
37
+ try: () => writeFile(path, data, 'utf-8'),
38
+ catch: (cause) => new PlanWriteError({ path, cause }),
39
+ }),
40
+
41
+ writeFileAtomic: (path, data) => writeFileAtomic(path, data),
42
+
43
+ makeDir: (path) =>
44
+ Effect.tryPromise({
45
+ try: async () => {
46
+ await mkdir(path, { recursive: true });
47
+ },
48
+ catch: (cause) => new PlanWriteError({ path, cause }),
49
+ }),
50
+
51
+ listDirectories: (path) =>
52
+ Effect.tryPromise({
53
+ try: async () => {
54
+ const entries = await readdir(path, { withFileTypes: true });
55
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
56
+ },
57
+ catch: (cause) => new PlanReadError({ path, cause }),
58
+ }),
59
+
60
+ removeFile: (path) =>
61
+ Effect.tryPromise({
62
+ try: () => unlink(path),
63
+ catch: (cause) => new PlanWriteError({ path, cause }),
64
+ }),
65
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Live Effect runtime for the plan-mode extension.
3
+ *
4
+ * Build the layer once inside the extension entry and run storage programs
5
+ * through the `runPlanIO` bridge so the imperative pi event handlers keep their
6
+ * `await fn(...)` shape.
7
+ */
8
+
9
+ import { Effect, Layer } from 'effect';
10
+ import { FileSystem, nodeFileSystemService } from './filesystem.js';
11
+
12
+ export function makeRuntimeLayer(): Layer.Layer<FileSystem> {
13
+ return Layer.succeed(FileSystem, nodeFileSystemService);
14
+ }
15
+
16
+ /** Build a bridge that runs storage programs against the live filesystem layer. */
17
+ export function makePlanRuntime() {
18
+ const layer = makeRuntimeLayer();
19
+ return function runPlanIO<A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> {
20
+ return Effect.runPromise(program.pipe(Effect.provide(layer)));
21
+ };
22
+ }
23
+
24
+ export type RunPlanIO = ReturnType<typeof makePlanRuntime>;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Tagged errors for plan-mode disk I/O and JSONL validation.
3
+ *
4
+ * These replace ad-hoc `throw new Error(...)` so storage programs surface
5
+ * typed, inspectable failures. They are mapped back to user-facing strings at
6
+ * the tool boundary via `errorMessage`.
7
+ */
8
+
9
+ import { Data } from 'effect';
10
+
11
+ export class PlanReadError extends Data.TaggedError('PlanReadError')<{
12
+ readonly path: string;
13
+ readonly cause: unknown;
14
+ }> {
15
+ get message(): string {
16
+ return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
17
+ }
18
+ }
19
+
20
+ export class PlanWriteError extends Data.TaggedError('PlanWriteError')<{
21
+ readonly path: string;
22
+ readonly cause: unknown;
23
+ }> {
24
+ get message(): string {
25
+ return `Failed to write ${this.path}: ${causeMessage(this.cause)}`;
26
+ }
27
+ }
28
+
29
+ export class JsonlParseError extends Data.TaggedError('JsonlParseError')<{
30
+ readonly path: string;
31
+ readonly line: number;
32
+ readonly cause?: unknown;
33
+ }> {
34
+ get message(): string {
35
+ return `Invalid JSONL in ${this.path} at line ${this.line}: ${causeMessage(this.cause)}`;
36
+ }
37
+ }
38
+
39
+ export class JsonlValidationError extends Data.TaggedError('JsonlValidationError')<{
40
+ readonly path: string;
41
+ readonly line: number;
42
+ readonly reason: string;
43
+ }> {
44
+ get message(): string {
45
+ return `Invalid record in ${this.path} at line ${this.line}: ${this.reason}`;
46
+ }
47
+ }
48
+
49
+ export class MissingMetaRecord extends Data.TaggedError('MissingMetaRecord')<{
50
+ readonly path: string;
51
+ }> {
52
+ get message(): string {
53
+ return `${this.path} is missing meta record`;
54
+ }
55
+ }
56
+
57
+ export class TaskNotFound extends Data.TaggedError('TaskNotFound')<{
58
+ readonly planDir: string;
59
+ readonly taskId: string;
60
+ }> {
61
+ get message(): string {
62
+ return `Task not found: ${this.taskId}`;
63
+ }
64
+ }
65
+
66
+ export class TasksFileNotFound extends Data.TaggedError('TasksFileNotFound')<{
67
+ readonly planDir: string;
68
+ }> {
69
+ get message(): string {
70
+ return `No tasks.jsonl found in ${this.planDir}`;
71
+ }
72
+ }
73
+
74
+ export type PlanStorageError =
75
+ | PlanReadError
76
+ | PlanWriteError
77
+ | JsonlParseError
78
+ | JsonlValidationError
79
+ | MissingMetaRecord
80
+ | TaskNotFound
81
+ | TasksFileNotFound;
82
+
83
+ export function causeMessage(cause: unknown): string {
84
+ if (cause instanceof Error) return cause.message;
85
+ return String(cause);
86
+ }
87
+
88
+ export function errorMessage(error: unknown): string {
89
+ if (error instanceof Error) return error.message;
90
+ if (typeof error === 'object' && error !== null && 'message' in error) {
91
+ const message = (error as { message?: unknown }).message;
92
+ if (typeof message === 'string') return message;
93
+ }
94
+ return String(error);
95
+ }
96
+
97
+ /** Convert any error (including tagged errors) into a native Error for the tool boundary. */
98
+ export function toNativeError(error: unknown): Error {
99
+ if (error instanceof Error) return error;
100
+ const native = new Error(errorMessage(error));
101
+ if (typeof error === 'object' && error !== null && '_tag' in error) {
102
+ native.name = String((error as { _tag: unknown })._tag);
103
+ }
104
+ return native;
105
+ }
@@ -2,127 +2,23 @@ import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import pug from 'pug';
5
- import type { PlanData } from '../types.js';
6
5
 
7
- const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'plan.pug');
6
+ const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'prototype.pug');
8
7
 
9
- export function renderPlanHtml(plan: PlanData, prototype?: string): string {
8
+ /**
9
+ * Renders a standalone prototype preview: a minimal header (title + one-line
10
+ * intent) wrapping the rendered Pug body. This is a planning-phase visual aid,
11
+ * not a plan dump — no tasks, no handoff.
12
+ */
13
+ export function renderPrototypeHtml(title: string, intent: string, pugBody: string): string {
10
14
  const template = readFileSync(templatePath, 'utf8');
11
- const prototypeHtml = prototype?.trim() ? pug.render(prototype) : undefined;
15
+ const prototypeHtml = pugBody.trim() ? pug.render(pugBody) : '';
12
16
 
13
17
  return pug.render(template, {
14
18
  filename: templatePath,
15
- plan,
19
+ title,
20
+ intent,
16
21
  prototypeHtml,
17
- handoffHtml: markdownToHtml(plan.handoff),
18
- taskCountLabel: `${plan.tasks.length} ${plan.tasks.length === 1 ? 'task' : 'tasks'}`,
19
22
  generatedAt: new Date().toISOString(),
20
23
  });
21
24
  }
22
-
23
- function markdownToHtml(markdown: string): string {
24
- const lines = markdown.split(/\r?\n/);
25
- const html: string[] = [];
26
- let inList = false;
27
- let inCode = false;
28
- let codeLang = '';
29
- const codeLines: string[] = [];
30
-
31
- for (const line of lines) {
32
- // Fenced code blocks
33
- if (line.startsWith('```')) {
34
- if (!inCode) {
35
- if (inList) {
36
- html.push('</ul>');
37
- inList = false;
38
- }
39
- inCode = true;
40
- codeLang = line.slice(3).trim();
41
- codeLines.length = 0;
42
- } else {
43
- const langAttr = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : '';
44
- html.push(`<pre><code${langAttr}>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
45
- inCode = false;
46
- codeLang = '';
47
- }
48
- continue;
49
- }
50
-
51
- if (inCode) {
52
- codeLines.push(line);
53
- continue;
54
- }
55
-
56
- // Headings
57
- const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
58
- if (headingMatch) {
59
- if (inList) {
60
- html.push('</ul>');
61
- inList = false;
62
- }
63
- const level = headingMatch[1].length;
64
- html.push(`<h${level}>${inlineMarkdown(escapeHtml(headingMatch[2]))}</h${level}>`);
65
- continue;
66
- }
67
-
68
- // List items (- or *)
69
- const listMatch = line.match(/^\s*[-*]\s+(.*)$/);
70
- if (listMatch) {
71
- if (!inList) {
72
- html.push('<ul>');
73
- inList = true;
74
- }
75
- html.push(`<li>${inlineMarkdown(escapeHtml(listMatch[1]))}</li>`);
76
- continue;
77
- }
78
-
79
- // Blank line
80
- if (!line.trim()) {
81
- if (inList) {
82
- html.push('</ul>');
83
- inList = false;
84
- }
85
- continue;
86
- }
87
-
88
- // Paragraph
89
- if (inList) {
90
- html.push('</ul>');
91
- inList = false;
92
- }
93
- html.push(`<p>${inlineMarkdown(escapeHtml(line))}</p>`);
94
- }
95
-
96
- // Close unclosed blocks
97
- if (inCode) {
98
- html.push(`<pre><code>${codeLines.map(escapeHtml).join('\n')}</code></pre>`);
99
- }
100
- if (inList) html.push('</ul>');
101
- return html.join('\n');
102
- }
103
-
104
- /** Converts inline markdown (bold, italic, inline code, links) in already-escaped HTML. */
105
- function inlineMarkdown(text: string): string {
106
- return (
107
- text
108
- // Inline code: `code`
109
- .replace(/`([^`]+)`/g, '<code>$1</code>')
110
- // Bold: **text** or __text__
111
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
112
- .replace(/__(.+?)__/g, '<strong>$1</strong>')
113
- // Italic: *text* or _text_
114
- .replace(/\*(.+?)\*/g, '<em>$1</em>')
115
- .replace(/_(.+?)_/g, '<em>$1</em>')
116
- // Links: [text](url)
117
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
118
- );
119
- }
120
-
121
- function escapeHtml(value: string): string {
122
- return value
123
- .replaceAll('&', '&amp;')
124
- .replaceAll('<', '&lt;')
125
- .replaceAll('>', '&gt;')
126
- .replaceAll('"', '&quot;')
127
- .replaceAll("'", '&#39;');
128
- }
@@ -0,0 +1,25 @@
1
+ doctype html
2
+ html(lang="en")
3
+ head
4
+ meta(charset="utf-8")
5
+ meta(name="viewport" content="width=device-width, initial-scale=1")
6
+ title= title
7
+ style.
8
+ :root { color-scheme: dark; --bg:#0b0d12; --panel:#11141b; --muted:#8b93a7; --text:#eef1f7; --line:#242936; --accent:#8b5cf6; }
9
+ * { box-sizing: border-box; }
10
+ body { margin:0; background:radial-gradient(circle at top left,#1c1730,transparent 34rem),var(--bg); color:var(--text); font:14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
11
+ main { width:min(1040px, calc(100% - 48px)); margin:48px auto; }
12
+ header { margin-bottom:24px; }
13
+ h1 { font-size:30px; line-height:1.1; margin:0 0 8px; letter-spacing:-0.04em; }
14
+ .intent { color:var(--muted); margin:0; }
15
+ .badge { display:inline-block; border:1px solid var(--line); border-radius:999px; padding:5px 10px; background:rgba(255,255,255,0.03); color:var(--muted); font-size:12px; margin-bottom:14px; }
16
+ .prototype { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
17
+ body
18
+ main
19
+ header
20
+ span.badge Prototype · #{generatedAt}
21
+ h1= title
22
+ if intent
23
+ p.intent= intent
24
+ section.prototype
25
+ != prototypeHtml