@dreki-gg/pi-plan-mode 0.10.1 → 0.14.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/CHANGELOG.md +30 -0
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +43 -0
- package/extensions/plan-mode/__tests__/html-render.test.ts +43 -0
- package/extensions/plan-mode/__tests__/package-skills.test.ts +47 -0
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +37 -0
- package/extensions/plan-mode/__tests__/prompts.test.ts +38 -0
- package/extensions/plan-mode/__tests__/task-storage.test.ts +44 -0
- package/extensions/plan-mode/__tests__/types.test.ts +55 -0
- package/extensions/plan-mode/constants.ts +3 -1
- package/extensions/plan-mode/html/render.ts +67 -0
- package/extensions/plan-mode/html/templates/plan.pug +58 -0
- package/extensions/plan-mode/index.ts +79 -42
- package/extensions/plan-mode/plan-storage.ts +1 -67
- package/extensions/plan-mode/prompts.ts +32 -31
- package/extensions/plan-mode/pug.d.ts +5 -0
- package/extensions/plan-mode/resume.ts +38 -37
- package/extensions/plan-mode/storage/atomic-write.ts +53 -0
- package/extensions/plan-mode/storage/plan-storage.ts +47 -0
- package/extensions/plan-mode/storage/plans-manifest.ts +57 -0
- package/extensions/plan-mode/storage/task-storage.ts +44 -0
- package/extensions/plan-mode/tools/submit-plan.ts +40 -88
- package/extensions/plan-mode/tools/update-task.ts +82 -0
- package/extensions/plan-mode/types.ts +57 -5
- package/extensions/plan-mode/ui.ts +10 -10
- package/package.json +7 -2
- package/skills/technical-options/SKILL.md +89 -0
- package/extensions/plan-mode/plans-json.ts +0 -48
- package/extensions/plan-mode/tools/update-step.ts +0 -145
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Refactor plan-mode to conversational planning with JSONL task storage and HTML output. Replace steps with task records, add atomic writes, Pug-based plan.html generation, and migrate manifest to JSONL. Update subagent prompts.
|
|
8
|
+
|
|
9
|
+
## 0.13.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- Replace context+risks with HANDOFF.md and step summaries. submit_plan now writes a HANDOFF.md alongside plan.json. Completion message shows an actual summary of changes from step notes instead of just a checklist. Executor is prompted to always include notes summarizing what was done.
|
|
14
|
+
|
|
15
|
+
## 0.12.1
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Include `skills/` directory in package files so the bundled technical-options skill is published.
|
|
20
|
+
|
|
21
|
+
## 0.12.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- Bundle `technical-options` skill inside the package (installable via `pi.skills`). The planner prompt now explicitly tells the agent to generate proposals itself and only delegate voting to subagents, keeping the planner visible as the main agent.
|
|
26
|
+
|
|
27
|
+
## 0.11.0
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- Integrate technical-options skill into plan mode: add `subagent` to plan-phase tools and nudge the planner to use structured proposal evaluation when facing significant design decisions with multiple viable approaches.
|
|
32
|
+
|
|
3
33
|
## 0.10.1
|
|
4
34
|
|
|
5
35
|
### Patch Changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { writeFileAtomic } from '../storage/atomic-write.js';
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-atomic-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('writeFileAtomic', () => {
|
|
18
|
+
test('writes complete content to the target path', async () => {
|
|
19
|
+
const target = join(dir, 'data.txt');
|
|
20
|
+
|
|
21
|
+
await writeFileAtomic(target, 'hello world');
|
|
22
|
+
|
|
23
|
+
expect(await readFile(target, 'utf8')).toBe('hello world');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('replaces existing content atomically from the caller perspective', async () => {
|
|
27
|
+
const target = join(dir, 'data.txt');
|
|
28
|
+
await writeFile(target, 'old');
|
|
29
|
+
|
|
30
|
+
await writeFileAtomic(target, 'new');
|
|
31
|
+
|
|
32
|
+
expect(await readFile(target, 'utf8')).toBe('new');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('leaves target untouched when writing fails before rename', async () => {
|
|
36
|
+
const target = join(dir, 'data.txt');
|
|
37
|
+
await writeFile(target, 'original');
|
|
38
|
+
|
|
39
|
+
await expect(writeFileAtomic(target, 'next', { mode: 0o400 })).rejects.toThrow();
|
|
40
|
+
|
|
41
|
+
expect(await readFile(target, 'utf8')).toBe('original');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { renderPlanHtml } from '../html/render.js';
|
|
3
|
+
import type { PlanData } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const plan: PlanData = {
|
|
6
|
+
title: 'Refactor Auth',
|
|
7
|
+
planName: 'refactor-auth',
|
|
8
|
+
handoff: '# Handoff\nShip carefully.',
|
|
9
|
+
tasks: [
|
|
10
|
+
{
|
|
11
|
+
_type: 'task',
|
|
12
|
+
id: 't-001',
|
|
13
|
+
description: 'Add auth middleware',
|
|
14
|
+
details: 'Implement the middleware.',
|
|
15
|
+
status: 'pending',
|
|
16
|
+
created_at: '2026-05-27T12:00:00.000Z',
|
|
17
|
+
updated_at: '2026-05-27T12:00:00.000Z',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('renderPlanHtml', () => {
|
|
23
|
+
test('renders title, task count, handoff, and tasks', () => {
|
|
24
|
+
const html = renderPlanHtml(plan);
|
|
25
|
+
|
|
26
|
+
expect(html).toContain('Refactor Auth');
|
|
27
|
+
expect(html).toContain('1 task');
|
|
28
|
+
expect(html).toContain('Ship carefully.');
|
|
29
|
+
expect(html).toContain('t-001');
|
|
30
|
+
expect(html).toContain('Add auth middleware');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('omits prototypes section when no prototype is provided', () => {
|
|
34
|
+
expect(renderPlanHtml(plan)).not.toContain('Prototype');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('renders optional prototype pug markup', () => {
|
|
38
|
+
const html = renderPlanHtml(plan, '.mockup Prototype card');
|
|
39
|
+
|
|
40
|
+
expect(html).toContain('Prototype');
|
|
41
|
+
expect(html).toContain('Prototype card');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const PKG_ROOT = join(import.meta.dir, '..', '..', '..');
|
|
6
|
+
const PKG_JSON = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8'));
|
|
7
|
+
|
|
8
|
+
describe('package.json pi manifest', () => {
|
|
9
|
+
test('declares skills directory', () => {
|
|
10
|
+
expect(PKG_JSON.pi?.skills).toBeDefined();
|
|
11
|
+
expect(PKG_JSON.pi.skills).toContain('./skills');
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('bundled technical-options skill', () => {
|
|
16
|
+
const skillDir = join(PKG_ROOT, 'skills', 'technical-options');
|
|
17
|
+
const skillFile = join(skillDir, 'SKILL.md');
|
|
18
|
+
|
|
19
|
+
test('SKILL.md exists', () => {
|
|
20
|
+
expect(existsSync(skillFile)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('has valid frontmatter with name and description', () => {
|
|
24
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
25
|
+
expect(content).toMatch(/^---\n/);
|
|
26
|
+
expect(content).toMatch(/name:\s*technical-options/);
|
|
27
|
+
expect(content).toMatch(/description:/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('description does not contain overly broad triggers', () => {
|
|
31
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
32
|
+
// These were flagged during review as too broad for routing
|
|
33
|
+
expect(content).not.toMatch(/description:.*help me decide/i);
|
|
34
|
+
expect(content).not.toMatch(/description:.*what are my options/i);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('skill content mentions parallel voting agents', () => {
|
|
38
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
39
|
+
expect(content).toContain('voting');
|
|
40
|
+
expect(content).toContain('parallel');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('skill content includes adversarial framing challenge step', () => {
|
|
44
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
45
|
+
expect(content).toMatch(/challenge.*framing|framing.*challenge/i);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { chdir } from 'node:process';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { readPlansManifest, upsertPlanEntry, writePlansManifest } from '../storage/plans-manifest.js';
|
|
7
|
+
|
|
8
|
+
const originalCwd = process.cwd();
|
|
9
|
+
let dir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'plan-mode-manifest-')); chdir(dir); });
|
|
12
|
+
afterEach(async () => { chdir(originalCwd); await rm(dir, { recursive: true, force: true }); });
|
|
13
|
+
|
|
14
|
+
describe('plans.jsonl manifest', () => {
|
|
15
|
+
test('round trips entries', async () => {
|
|
16
|
+
const entry = { _type: 'plan' as const, name: 'refactor', status: 'in-progress' as const, title: 'Refactor', created_at: 'now', completed_at: null };
|
|
17
|
+
await writePlansManifest([entry]);
|
|
18
|
+
await expect(readPlansManifest()).resolves.toEqual([entry]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('upserts new entries', async () => {
|
|
22
|
+
await upsertPlanEntry('new-plan', { status: 'in-progress', title: 'New Plan' });
|
|
23
|
+
const entries = await readPlansManifest();
|
|
24
|
+
expect(entries).toHaveLength(1);
|
|
25
|
+
expect(entries[0]?.name).toBe('new-plan');
|
|
26
|
+
expect(entries[0]?.title).toBe('New Plan');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('upserts existing entries without changing created_at', async () => {
|
|
30
|
+
await writePlansManifest([{ _type: 'plan', name: 'p', status: 'in-progress', title: 'Old', created_at: 'created', completed_at: null }]);
|
|
31
|
+
await upsertPlanEntry('p', { status: 'done', title: 'New' });
|
|
32
|
+
const [entry] = await readPlansManifest();
|
|
33
|
+
expect(entry.created_at).toBe('created');
|
|
34
|
+
expect(entry.status).toBe('done');
|
|
35
|
+
expect(entry.completed_at).toBeString();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { buildPlanModePrompt } from '../prompts.js';
|
|
3
|
+
import { PLAN_TOOLS } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
describe('buildPlanModePrompt', () => {
|
|
6
|
+
const prompt = buildPlanModePrompt();
|
|
7
|
+
|
|
8
|
+
test('mentions technical-options skill for significant decisions', () => {
|
|
9
|
+
expect(prompt).toContain('technical-options');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('mentions handoff instead of context and risks', () => {
|
|
13
|
+
expect(prompt).toContain('handoff');
|
|
14
|
+
expect(prompt).not.toContain('- risks:');
|
|
15
|
+
expect(prompt).not.toContain('- context:');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('tells planner to do proposal generation itself, not delegate', () => {
|
|
19
|
+
// The planner should generate proposals as the main agent, only using
|
|
20
|
+
// subagents for voting/evaluation — not delegating the entire workflow
|
|
21
|
+
expect(prompt).toMatch(/you.*generat|generat.*yourself|do this yourself/i);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('mentions subagent is available for voting only', () => {
|
|
25
|
+
// Should clarify subagent is for evaluation, not for the whole workflow
|
|
26
|
+
expect(prompt).toMatch(/subagent|voting|evaluat/i);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('PLAN_TOOLS', () => {
|
|
31
|
+
test('includes subagent for voting workflows', () => {
|
|
32
|
+
expect(PLAN_TOOLS).toContain('subagent');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('includes search_skills for skill discovery', () => {
|
|
36
|
+
expect(PLAN_TOOLS).toContain('search_skills');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { readTasksJsonl, updateTask, writeTasksJsonl } from '../storage/task-storage.js';
|
|
6
|
+
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
7
|
+
|
|
8
|
+
let dir: string;
|
|
9
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
10
|
+
const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
|
|
11
|
+
const task: TaskRecord = { _type: 'task', id: 't-001', description: 'Do work', details: 'Details', status: 'pending', created_at: now, updated_at: now };
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-')); });
|
|
14
|
+
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
|
|
15
|
+
|
|
16
|
+
describe('tasks.jsonl storage', () => {
|
|
17
|
+
test('round trips meta and tasks', async () => {
|
|
18
|
+
await writeTasksJsonl(dir, meta, [task]);
|
|
19
|
+
await expect(readTasksJsonl(dir)).resolves.toEqual({ meta, tasks: [task] });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('missing file returns undefined', async () => {
|
|
23
|
+
await expect(readTasksJsonl(dir)).resolves.toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('rejects corrupt lines', async () => {
|
|
27
|
+
await Bun.write(join(dir, 'tasks.jsonl'), `${JSON.stringify(meta)}\nnot-json\n`);
|
|
28
|
+
await expect(readTasksJsonl(dir)).rejects.toThrow(/Invalid JSONL/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('rejects empty files', async () => {
|
|
32
|
+
await Bun.write(join(dir, 'tasks.jsonl'), '');
|
|
33
|
+
await expect(readTasksJsonl(dir)).rejects.toThrow(/meta/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('updates a task by id and rewrites the snapshot', async () => {
|
|
37
|
+
await writeTasksJsonl(dir, meta, [task]);
|
|
38
|
+
const updated = await updateTask(dir, 't-001', { status: 'done', notes: 'finished' });
|
|
39
|
+
|
|
40
|
+
expect(updated.status).toBe('done');
|
|
41
|
+
expect(updated.notes).toBe('finished');
|
|
42
|
+
expect((await readTasksJsonl(dir))?.tasks[0]?.status).toBe('done');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { isTaskMeta, isTaskRecord } from '../types.js';
|
|
3
|
+
|
|
4
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
5
|
+
|
|
6
|
+
describe('task record type guards', () => {
|
|
7
|
+
test('accepts valid task records', () => {
|
|
8
|
+
expect(
|
|
9
|
+
isTaskRecord({
|
|
10
|
+
_type: 'task',
|
|
11
|
+
id: 't-001',
|
|
12
|
+
description: 'Do work',
|
|
13
|
+
details: 'Full instructions',
|
|
14
|
+
status: 'pending',
|
|
15
|
+
depends_on: ['t-000'],
|
|
16
|
+
notes: 'note',
|
|
17
|
+
created_at: now,
|
|
18
|
+
updated_at: now,
|
|
19
|
+
}),
|
|
20
|
+
).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('rejects malformed task records', () => {
|
|
24
|
+
expect(isTaskRecord({ _type: 'task', id: 't-001', status: 'pending' })).toBe(false);
|
|
25
|
+
expect(
|
|
26
|
+
isTaskRecord({
|
|
27
|
+
_type: 'task',
|
|
28
|
+
id: 't-001',
|
|
29
|
+
description: 'Do work',
|
|
30
|
+
details: 'Full instructions',
|
|
31
|
+
status: 'unknown',
|
|
32
|
+
created_at: now,
|
|
33
|
+
updated_at: now,
|
|
34
|
+
}),
|
|
35
|
+
).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('task meta type guard', () => {
|
|
40
|
+
test('accepts valid meta records', () => {
|
|
41
|
+
expect(
|
|
42
|
+
isTaskMeta({
|
|
43
|
+
_type: 'meta',
|
|
44
|
+
title: 'Refactor',
|
|
45
|
+
plan_name: 'refactor',
|
|
46
|
+
created_at: now,
|
|
47
|
+
}),
|
|
48
|
+
).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rejects malformed meta records', () => {
|
|
52
|
+
expect(isTaskMeta({ _type: 'meta', title: 'Refactor' })).toBe(false);
|
|
53
|
+
expect(isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now })).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -10,11 +10,13 @@ export const PLAN_TOOLS = [
|
|
|
10
10
|
'find',
|
|
11
11
|
'ls',
|
|
12
12
|
'submit_plan',
|
|
13
|
+
'write',
|
|
13
14
|
'questionnaire',
|
|
14
15
|
'search_skills',
|
|
16
|
+
'subagent',
|
|
15
17
|
];
|
|
16
18
|
|
|
17
|
-
export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', '
|
|
19
|
+
export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task'];
|
|
18
20
|
|
|
19
21
|
// ── Model + thinking presets ─────────────────────────────────────────────────
|
|
20
22
|
export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import pug from 'pug';
|
|
5
|
+
import type { PlanData } from '../types.js';
|
|
6
|
+
|
|
7
|
+
const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'plan.pug');
|
|
8
|
+
|
|
9
|
+
export function renderPlanHtml(plan: PlanData, prototype?: string): string {
|
|
10
|
+
const template = readFileSync(templatePath, 'utf8');
|
|
11
|
+
const prototypeHtml = prototype?.trim() ? pug.render(prototype) : undefined;
|
|
12
|
+
|
|
13
|
+
return pug.render(template, {
|
|
14
|
+
filename: templatePath,
|
|
15
|
+
plan,
|
|
16
|
+
prototypeHtml,
|
|
17
|
+
handoffHtml: markdownToHtml(plan.handoff),
|
|
18
|
+
taskCountLabel: `${plan.tasks.length} ${plan.tasks.length === 1 ? 'task' : 'tasks'}`,
|
|
19
|
+
generatedAt: new Date().toISOString(),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function markdownToHtml(markdown: string): string {
|
|
24
|
+
const lines = markdown.split(/\r?\n/);
|
|
25
|
+
const html: string[] = [];
|
|
26
|
+
let inList = false;
|
|
27
|
+
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
if (line.startsWith('# ')) {
|
|
30
|
+
if (inList) {
|
|
31
|
+
html.push('</ul>');
|
|
32
|
+
inList = false;
|
|
33
|
+
}
|
|
34
|
+
html.push(`<h1>${escapeHtml(line.slice(2))}</h1>`);
|
|
35
|
+
} else if (line.startsWith('## ')) {
|
|
36
|
+
if (inList) {
|
|
37
|
+
html.push('</ul>');
|
|
38
|
+
inList = false;
|
|
39
|
+
}
|
|
40
|
+
html.push(`<h2>${escapeHtml(line.slice(3))}</h2>`);
|
|
41
|
+
} else if (line.startsWith('- ')) {
|
|
42
|
+
if (!inList) {
|
|
43
|
+
html.push('<ul>');
|
|
44
|
+
inList = true;
|
|
45
|
+
}
|
|
46
|
+
html.push(`<li>${escapeHtml(line.slice(2))}</li>`);
|
|
47
|
+
} else if (line.trim()) {
|
|
48
|
+
if (inList) {
|
|
49
|
+
html.push('</ul>');
|
|
50
|
+
inList = false;
|
|
51
|
+
}
|
|
52
|
+
html.push(`<p>${escapeHtml(line)}</p>`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (inList) html.push('</ul>');
|
|
57
|
+
return html.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function escapeHtml(value: string): string {
|
|
61
|
+
return value
|
|
62
|
+
.replaceAll('&', '&')
|
|
63
|
+
.replaceAll('<', '<')
|
|
64
|
+
.replaceAll('>', '>')
|
|
65
|
+
.replaceAll('"', '"')
|
|
66
|
+
.replaceAll("'", ''');
|
|
67
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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= plan.title
|
|
7
|
+
style.
|
|
8
|
+
:root { color-scheme: dark; --bg:#0b0d12; --panel:#11141b; --muted:#8b93a7; --text:#eef1f7; --line:#242936; --accent:#8b5cf6; --done:#33d69f; --blocked:#ff6b6b; }
|
|
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 { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; margin-bottom:28px; }
|
|
13
|
+
h1 { font-size:34px; line-height:1.08; margin:0 0 10px; letter-spacing:-0.04em; }
|
|
14
|
+
h2 { font-size:15px; text-transform:uppercase; color:var(--muted); letter-spacing:0.14em; margin:0 0 14px; }
|
|
15
|
+
.meta { color:var(--muted); display:flex; gap:12px; flex-wrap:wrap; }
|
|
16
|
+
.pill { border:1px solid var(--line); border-radius:999px; padding:6px 10px; background:rgba(255,255,255,0.03); }
|
|
17
|
+
section { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; margin:18px 0; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
|
|
18
|
+
.handoff :first-child { margin-top:0; }
|
|
19
|
+
.handoff :last-child { margin-bottom:0; }
|
|
20
|
+
.tasks { display:grid; gap:12px; }
|
|
21
|
+
.task { border:1px solid var(--line); border-radius:14px; padding:16px; background:#0f1218; }
|
|
22
|
+
.task-top { display:flex; gap:10px; align-items:center; margin-bottom:8px; }
|
|
23
|
+
.id { color:var(--accent); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
24
|
+
.status { margin-left:auto; color:var(--muted); }
|
|
25
|
+
.task.done .status { color:var(--done); }
|
|
26
|
+
.task.blocked .status { color:var(--blocked); }
|
|
27
|
+
.description { font-weight:650; }
|
|
28
|
+
.details { color:#c7ccda; margin:0; white-space:pre-wrap; }
|
|
29
|
+
.deps { color:var(--muted); margin-top:10px; font-size:12px; }
|
|
30
|
+
body
|
|
31
|
+
main
|
|
32
|
+
header
|
|
33
|
+
div
|
|
34
|
+
h1= plan.title
|
|
35
|
+
.meta
|
|
36
|
+
span.pill= plan.planName
|
|
37
|
+
span.pill= taskCountLabel
|
|
38
|
+
.meta
|
|
39
|
+
span.pill Generated #{generatedAt}
|
|
40
|
+
section.handoff
|
|
41
|
+
h2 Handoff
|
|
42
|
+
!= handoffHtml
|
|
43
|
+
section
|
|
44
|
+
h2 Tasks
|
|
45
|
+
.tasks
|
|
46
|
+
each task in plan.tasks
|
|
47
|
+
article.task(class=task.status)
|
|
48
|
+
.task-top
|
|
49
|
+
span.id= task.id
|
|
50
|
+
span.description= task.description
|
|
51
|
+
span.status= task.status
|
|
52
|
+
p.details= task.details
|
|
53
|
+
if task.depends_on && task.depends_on.length
|
|
54
|
+
.deps Depends on #{task.depends_on.join(', ')}
|
|
55
|
+
if prototypeHtml
|
|
56
|
+
section.prototype
|
|
57
|
+
h2 Prototype
|
|
58
|
+
!= prototypeHtml
|