@dreki-gg/pi-plan-mode 0.21.0 → 0.24.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 +25 -0
- package/README.md +48 -5
- package/bin/clean-plans.js +67 -49
- package/extensions/plan-mode/__tests__/html-render.test.ts +37 -23
- package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +159 -0
- package/extensions/plan-mode/__tests__/initiative-status.test.ts +98 -0
- package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +89 -0
- package/extensions/plan-mode/__tests__/list-initiatives.test.ts +59 -0
- package/extensions/plan-mode/__tests__/list-plans.test.ts +253 -0
- package/extensions/plan-mode/__tests__/reconcile.test.ts +38 -1
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +38 -0
- package/extensions/plan-mode/__tests__/schema.test.ts +88 -0
- package/extensions/plan-mode/__tests__/submit-initiative.test.ts +58 -0
- package/extensions/plan-mode/__tests__/submit-plan.test.ts +97 -0
- package/extensions/plan-mode/__tests__/update-initiative.test.ts +72 -0
- package/extensions/plan-mode/commands/list-initiatives.ts +148 -0
- package/extensions/plan-mode/commands/list-plans.ts +229 -0
- package/extensions/plan-mode/constants.ts +5 -0
- package/extensions/plan-mode/html/render.ts +41 -18
- package/extensions/plan-mode/index.ts +31 -0
- package/extensions/plan-mode/initiative.ts +168 -0
- package/extensions/plan-mode/prompts.ts +5 -1
- package/extensions/plan-mode/reconcile.ts +65 -0
- package/extensions/plan-mode/schema.ts +28 -0
- package/extensions/plan-mode/storage/initiatives-manifest.ts +112 -0
- package/extensions/plan-mode/storage/plan-storage.ts +11 -0
- package/extensions/plan-mode/storage/plans-manifest.ts +16 -1
- package/extensions/plan-mode/tools/initiative-status.ts +191 -0
- package/extensions/plan-mode/tools/preview-prototype.ts +14 -9
- package/extensions/plan-mode/tools/reconcile-plans.ts +33 -6
- package/extensions/plan-mode/tools/revise-plan.ts +22 -0
- package/extensions/plan-mode/tools/submit-initiative.ts +86 -0
- package/extensions/plan-mode/tools/submit-plan.ts +37 -4
- package/extensions/plan-mode/tools/update-initiative.ts +120 -0
- package/extensions/plan-mode/tools/update-plan.ts +3 -0
- package/extensions/plan-mode/types.ts +3 -0
- package/package.json +2 -3
- package/skills/planning-context/SKILL.md +11 -0
- package/skills/visual-prototype/SKILL.md +4 -2
- package/extensions/plan-mode/html/templates/prototype.pug +0 -25
- package/extensions/plan-mode/pug.d.ts +0 -5
|
@@ -0,0 +1,97 @@
|
|
|
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 { makePlanRuntime } from '../effects/runtime.js';
|
|
7
|
+
import { readPlansManifest } from '../storage/plans-manifest.js';
|
|
8
|
+
import {
|
|
9
|
+
readInitiativesManifest,
|
|
10
|
+
upsertInitiativeEntry,
|
|
11
|
+
} from '../storage/initiatives-manifest.js';
|
|
12
|
+
import { registerSubmitPlanTool } from '../tools/submit-plan.js';
|
|
13
|
+
|
|
14
|
+
const runPlanIO = makePlanRuntime();
|
|
15
|
+
|
|
16
|
+
interface SubmitParams {
|
|
17
|
+
name: string;
|
|
18
|
+
title: string;
|
|
19
|
+
handoff: string;
|
|
20
|
+
tasks: Array<{ id: string; description: string }>;
|
|
21
|
+
initiative?: string;
|
|
22
|
+
depends_on_plans?: string[];
|
|
23
|
+
}
|
|
24
|
+
interface CapturedTool {
|
|
25
|
+
execute: (
|
|
26
|
+
id: string,
|
|
27
|
+
params: SubmitParams,
|
|
28
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setup(): CapturedTool {
|
|
32
|
+
let tool: CapturedTool | undefined;
|
|
33
|
+
const pi = {
|
|
34
|
+
registerTool: (config: CapturedTool) => {
|
|
35
|
+
tool = config;
|
|
36
|
+
},
|
|
37
|
+
} as unknown as Parameters<typeof registerSubmitPlanTool>[0];
|
|
38
|
+
registerSubmitPlanTool(pi, runPlanIO, { onPlanSubmitted: () => {} });
|
|
39
|
+
return tool!;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const baseParams = (over: Partial<SubmitParams> = {}): SubmitParams => ({
|
|
43
|
+
name: 'auth-jwt',
|
|
44
|
+
title: 'Auth JWT',
|
|
45
|
+
handoff: '# handoff',
|
|
46
|
+
tasks: [{ id: 't-001', description: 'do it' }],
|
|
47
|
+
...over,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const originalCwd = process.cwd();
|
|
51
|
+
let dir: string;
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-submit-plan-'));
|
|
54
|
+
chdir(dir);
|
|
55
|
+
});
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
chdir(originalCwd);
|
|
58
|
+
await rm(dir, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('submit_plan tool — initiative + plan deps', () => {
|
|
62
|
+
test('persists initiative + depends_on onto the plan manifest entry', async () => {
|
|
63
|
+
await runPlanIO(upsertInitiativeEntry('auth-overhaul', { status: 'in-progress', title: 'Auth' }));
|
|
64
|
+
const tool = setup();
|
|
65
|
+
await tool.execute('c', baseParams({ initiative: 'auth-overhaul', depends_on_plans: ['auth-schema'] }));
|
|
66
|
+
|
|
67
|
+
const [entry] = await runPlanIO(readPlansManifest());
|
|
68
|
+
expect(entry.initiative).toBe('auth-overhaul');
|
|
69
|
+
expect(entry.depends_on).toEqual(['auth-schema']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('kebab-cases the initiative name and reconciles it (member keeps it in-progress)', async () => {
|
|
73
|
+
await runPlanIO(upsertInitiativeEntry('auth-overhaul', { status: 'done', title: 'Auth' }));
|
|
74
|
+
const tool = setup();
|
|
75
|
+
await tool.execute('c', baseParams({ initiative: 'Auth Overhaul' }));
|
|
76
|
+
|
|
77
|
+
const [plan] = await runPlanIO(readPlansManifest());
|
|
78
|
+
expect(plan.initiative).toBe('auth-overhaul');
|
|
79
|
+
// A fresh in-progress member must reopen a prematurely-done initiative.
|
|
80
|
+
const [init] = await runPlanIO(readInitiativesManifest());
|
|
81
|
+
expect(init.status).toBe('in-progress');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('warns softly when the initiative has no registry entry yet', async () => {
|
|
85
|
+
const tool = setup();
|
|
86
|
+
const result = await tool.execute('c', baseParams({ initiative: 'ghost-initiative' }));
|
|
87
|
+
expect(result.content?.[0]?.text).toMatch(/no initiatives\.jsonl entry yet/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a standalone plan stores no initiative and no warning', async () => {
|
|
91
|
+
const tool = setup();
|
|
92
|
+
const result = await tool.execute('c', baseParams());
|
|
93
|
+
const [entry] = await runPlanIO(readPlansManifest());
|
|
94
|
+
expect(entry.initiative).toBeUndefined();
|
|
95
|
+
expect(result.content?.[0]?.text).not.toMatch(/initiative/i);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { makePlanRuntime } from '../effects/runtime.js';
|
|
7
|
+
import {
|
|
8
|
+
readInitiativesManifest,
|
|
9
|
+
upsertInitiativeEntry,
|
|
10
|
+
} from '../storage/initiatives-manifest.js';
|
|
11
|
+
import { registerUpdateInitiativeTool } from '../tools/update-initiative.js';
|
|
12
|
+
|
|
13
|
+
const runPlanIO = makePlanRuntime();
|
|
14
|
+
|
|
15
|
+
interface CapturedTool {
|
|
16
|
+
execute: (
|
|
17
|
+
id: string,
|
|
18
|
+
params: { initiative: string; status: string; reason?: string },
|
|
19
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setup(): CapturedTool {
|
|
23
|
+
let tool: CapturedTool | undefined;
|
|
24
|
+
const pi = {
|
|
25
|
+
registerTool: (config: CapturedTool) => {
|
|
26
|
+
tool = config;
|
|
27
|
+
},
|
|
28
|
+
} as unknown as Parameters<typeof registerUpdateInitiativeTool>[0];
|
|
29
|
+
registerUpdateInitiativeTool(pi, runPlanIO);
|
|
30
|
+
return tool!;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const originalCwd = process.cwd();
|
|
34
|
+
let dir: string;
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-update-init-'));
|
|
37
|
+
chdir(dir);
|
|
38
|
+
});
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
chdir(originalCwd);
|
|
41
|
+
await rm(dir, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('update_initiative tool', () => {
|
|
45
|
+
test('closes an initiative as superseded with a reason', async () => {
|
|
46
|
+
await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
|
|
47
|
+
const tool = setup();
|
|
48
|
+
const result = await tool.execute('c', {
|
|
49
|
+
initiative: 'big',
|
|
50
|
+
status: 'superseded',
|
|
51
|
+
reason: 'merged into mega',
|
|
52
|
+
});
|
|
53
|
+
expect(result.content?.[0]?.text).toMatch(/in-progress → superseded/);
|
|
54
|
+
const [entry] = await runPlanIO(readInitiativesManifest());
|
|
55
|
+
expect(entry.status).toBe('superseded');
|
|
56
|
+
expect(entry.reason).toBe('merged into mega');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('accepts a .plans/<name> hint', async () => {
|
|
60
|
+
await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
|
|
61
|
+
const tool = setup();
|
|
62
|
+
await tool.execute('c', { initiative: '.plans/big', status: 'abandoned', reason: 'dropped' });
|
|
63
|
+
const [entry] = await runPlanIO(readInitiativesManifest());
|
|
64
|
+
expect(entry.status).toBe('abandoned');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('reports not_found for an unknown initiative (no throw)', async () => {
|
|
68
|
+
const tool = setup();
|
|
69
|
+
const result = await tool.execute('c', { initiative: 'ghost', status: 'done' });
|
|
70
|
+
expect((result.details as { error?: string }).error).toBe('not_found');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /initiatives command — list, filter, and sort initiatives interactively.
|
|
3
|
+
*
|
|
4
|
+
* The initiative sibling of /plans. Reads the initiatives registry plus the
|
|
5
|
+
* plans manifest to roll up member-plan progress (done/total, ready/blocked).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Effect } from 'effect';
|
|
9
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
10
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
11
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
12
|
+
import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
|
|
13
|
+
import {
|
|
14
|
+
readInitiativesManifest,
|
|
15
|
+
type InitiativeManifestEntry,
|
|
16
|
+
} from '../storage/initiatives-manifest.js';
|
|
17
|
+
import { initiativeRollup } from '../initiative.js';
|
|
18
|
+
import type { InitiativeStatus } from '../types.js';
|
|
19
|
+
|
|
20
|
+
export type StatusFilter = 'all' | InitiativeStatus;
|
|
21
|
+
|
|
22
|
+
export interface InitiativeListItem {
|
|
23
|
+
name: string;
|
|
24
|
+
title: string;
|
|
25
|
+
status: InitiativeStatus;
|
|
26
|
+
created_at: string;
|
|
27
|
+
totalPlans: number;
|
|
28
|
+
donePlans: number;
|
|
29
|
+
ready: number;
|
|
30
|
+
blocked: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function filterInitiatives(
|
|
34
|
+
items: InitiativeListItem[],
|
|
35
|
+
filter: StatusFilter,
|
|
36
|
+
): InitiativeListItem[] {
|
|
37
|
+
if (filter === 'all') return items;
|
|
38
|
+
return items.filter((i) => i.status === filter);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STATUS_ICON: Record<InitiativeStatus, string> = {
|
|
42
|
+
'in-progress': '🔵',
|
|
43
|
+
done: '✅',
|
|
44
|
+
superseded: '🔄',
|
|
45
|
+
abandoned: '❌',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function formatInitiativeList(items: InitiativeListItem[], filter: StatusFilter): string {
|
|
49
|
+
if (items.length === 0) {
|
|
50
|
+
return filter === 'all'
|
|
51
|
+
? 'No initiatives found in .plans/initiatives.jsonl'
|
|
52
|
+
: `No initiatives with status "${filter}"`;
|
|
53
|
+
}
|
|
54
|
+
const header =
|
|
55
|
+
filter === 'all'
|
|
56
|
+
? `All initiatives (${items.length})`
|
|
57
|
+
: `Initiatives: ${filter} (${items.length})`;
|
|
58
|
+
const lines = items.map((i) => {
|
|
59
|
+
const icon = STATUS_ICON[i.status];
|
|
60
|
+
const progress =
|
|
61
|
+
i.totalPlans > 0
|
|
62
|
+
? ` [${i.donePlans}/${i.totalPlans} plans, ready ${i.ready}, blocked ${i.blocked}]`
|
|
63
|
+
: ' [no plans]';
|
|
64
|
+
const date = i.created_at.slice(0, 10);
|
|
65
|
+
return ` ${icon} ${i.name} — ${i.title}${progress} (${date})`;
|
|
66
|
+
});
|
|
67
|
+
return `${header}\n${lines.join('\n')}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function loadInitiativeListItems(): Effect.Effect<InitiativeListItem[], never, FileSystem> {
|
|
71
|
+
return Effect.gen(function* () {
|
|
72
|
+
const initiatives = yield* Effect.orElseSucceed(
|
|
73
|
+
readInitiativesManifest(),
|
|
74
|
+
() => [] as InitiativeManifestEntry[],
|
|
75
|
+
);
|
|
76
|
+
const plans = yield* Effect.orElseSucceed(
|
|
77
|
+
readPlansManifest(),
|
|
78
|
+
() => [] as PlanManifestEntry[],
|
|
79
|
+
);
|
|
80
|
+
return initiatives.map((entry): InitiativeListItem => {
|
|
81
|
+
const r = initiativeRollup(entry.name, plans);
|
|
82
|
+
return {
|
|
83
|
+
name: entry.name,
|
|
84
|
+
title: entry.title,
|
|
85
|
+
status: entry.status,
|
|
86
|
+
created_at: entry.created_at,
|
|
87
|
+
totalPlans: r.total,
|
|
88
|
+
donePlans: r.done,
|
|
89
|
+
ready: r.ready,
|
|
90
|
+
blocked: r.blocked,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const FILTER_ALIASES: Record<string, StatusFilter> = {
|
|
97
|
+
all: 'all',
|
|
98
|
+
'in-progress': 'in-progress',
|
|
99
|
+
active: 'in-progress',
|
|
100
|
+
done: 'done',
|
|
101
|
+
completed: 'done',
|
|
102
|
+
superseded: 'superseded',
|
|
103
|
+
abandoned: 'abandoned',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function parseFilter(raw: string): StatusFilter {
|
|
107
|
+
for (const token of raw.toLowerCase().split(/\s+/)) {
|
|
108
|
+
if (FILTER_ALIASES[token]) return FILTER_ALIASES[token];
|
|
109
|
+
}
|
|
110
|
+
return 'all';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function handleListInitiatives(
|
|
114
|
+
ctx: ExtensionCommandContext,
|
|
115
|
+
runPlanIO: RunPlanIO,
|
|
116
|
+
args?: string,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const items = await runPlanIO(loadInitiativeListItems());
|
|
119
|
+
if (items.length === 0) {
|
|
120
|
+
ctx.ui.notify('No initiatives found in .plans/initiatives.jsonl', 'info');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let filter: StatusFilter = 'all';
|
|
125
|
+
if (args?.trim()) {
|
|
126
|
+
filter = parseFilter(args.trim());
|
|
127
|
+
} else {
|
|
128
|
+
const choice = await ctx.ui.select('Filter initiatives by status:', [
|
|
129
|
+
'All',
|
|
130
|
+
'In-progress',
|
|
131
|
+
'Done',
|
|
132
|
+
'Superseded',
|
|
133
|
+
'Abandoned',
|
|
134
|
+
]);
|
|
135
|
+
if (!choice) return;
|
|
136
|
+
const map: Record<string, StatusFilter> = {
|
|
137
|
+
All: 'all',
|
|
138
|
+
'In-progress': 'in-progress',
|
|
139
|
+
Done: 'done',
|
|
140
|
+
Superseded: 'superseded',
|
|
141
|
+
Abandoned: 'abandoned',
|
|
142
|
+
};
|
|
143
|
+
filter = map[choice] ?? 'all';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const filtered = filterInitiatives(items, filter);
|
|
147
|
+
ctx.ui.notify(formatInitiativeList(filtered, filter), 'info');
|
|
148
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /plans command — list, filter, and sort plans interactively.
|
|
3
|
+
*
|
|
4
|
+
* Reads from the plans manifest AND task snapshots to build a rich table
|
|
5
|
+
* with status, task counts, and dates. The user can filter by status and
|
|
6
|
+
* sort by different criteria via interactive selectors.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Effect } from 'effect';
|
|
10
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
12
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
13
|
+
import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
|
|
14
|
+
import { readTasksJsonl } from '../storage/task-storage.js';
|
|
15
|
+
import type { PlanStatus } from '../types.js';
|
|
16
|
+
|
|
17
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type SortField = 'name' | 'date-asc' | 'date-desc' | 'tasks';
|
|
20
|
+
export type StatusFilter = 'all' | PlanStatus;
|
|
21
|
+
|
|
22
|
+
export interface PlanListItem {
|
|
23
|
+
name: string;
|
|
24
|
+
title: string;
|
|
25
|
+
status: PlanStatus;
|
|
26
|
+
created_at: string;
|
|
27
|
+
completed_at: string | null;
|
|
28
|
+
totalTasks: number;
|
|
29
|
+
doneTasks: number;
|
|
30
|
+
pendingTasks: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Core logic (pure, testable) ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function filterPlans(plans: PlanListItem[], filter: StatusFilter): PlanListItem[] {
|
|
36
|
+
if (filter === 'all') return plans;
|
|
37
|
+
return plans.filter((p) => p.status === filter);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sortPlans(plans: PlanListItem[], sort: SortField): PlanListItem[] {
|
|
41
|
+
const sorted = [...plans];
|
|
42
|
+
switch (sort) {
|
|
43
|
+
case 'name':
|
|
44
|
+
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
|
45
|
+
break;
|
|
46
|
+
case 'date-asc':
|
|
47
|
+
sorted.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
48
|
+
break;
|
|
49
|
+
case 'date-desc':
|
|
50
|
+
sorted.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
51
|
+
break;
|
|
52
|
+
case 'tasks':
|
|
53
|
+
sorted.sort((a, b) => b.totalTasks - a.totalTasks);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
return sorted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const STATUS_ICON: Record<PlanStatus, string> = {
|
|
60
|
+
'in-progress': '🔵',
|
|
61
|
+
done: '✅',
|
|
62
|
+
superseded: '🔄',
|
|
63
|
+
abandoned: '❌',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function formatPlanList(plans: PlanListItem[], filter: StatusFilter, sort: SortField): string {
|
|
67
|
+
if (plans.length === 0) {
|
|
68
|
+
return filter === 'all'
|
|
69
|
+
? 'No plans found in .plans/plans.jsonl'
|
|
70
|
+
: `No plans with status "${filter}"`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sortLabel: Record<SortField, string> = {
|
|
74
|
+
name: 'name',
|
|
75
|
+
'date-asc': 'oldest first',
|
|
76
|
+
'date-desc': 'newest first',
|
|
77
|
+
tasks: 'most tasks first',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const header =
|
|
81
|
+
filter === 'all'
|
|
82
|
+
? `All plans (${plans.length}) — sorted by ${sortLabel[sort]}`
|
|
83
|
+
: `Plans: ${filter} (${plans.length}) — sorted by ${sortLabel[sort]}`;
|
|
84
|
+
|
|
85
|
+
const lines = plans.map((p) => {
|
|
86
|
+
const icon = STATUS_ICON[p.status];
|
|
87
|
+
const progress =
|
|
88
|
+
p.totalTasks > 0 ? ` [${p.doneTasks}/${p.totalTasks} tasks]` : ' [no tasks]';
|
|
89
|
+
const date = p.created_at.slice(0, 10);
|
|
90
|
+
return ` ${icon} ${p.name} — ${p.title}${progress} (${date})`;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return `${header}\n${lines.join('\n')}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Data loading (Effect-based) ──────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export function loadPlanListItems(): Effect.Effect<PlanListItem[], never, FileSystem> {
|
|
99
|
+
return Effect.gen(function* () {
|
|
100
|
+
const manifest = yield* Effect.orElseSucceed(readPlansManifest(), () => [] as PlanManifestEntry[]);
|
|
101
|
+
const items: PlanListItem[] = [];
|
|
102
|
+
|
|
103
|
+
for (const entry of manifest) {
|
|
104
|
+
const dir = `.plans/${entry.name}`;
|
|
105
|
+
const snapshot = yield* Effect.orElseSucceed(readTasksJsonl(dir), () => undefined);
|
|
106
|
+
const totalTasks = snapshot?.tasks.length ?? 0;
|
|
107
|
+
const doneTasks = snapshot?.tasks.filter(
|
|
108
|
+
(t) => t.status === 'done' || t.status === 'skipped',
|
|
109
|
+
).length ?? 0;
|
|
110
|
+
const pendingTasks = snapshot?.tasks.filter((t) => t.status === 'pending').length ?? 0;
|
|
111
|
+
|
|
112
|
+
items.push({
|
|
113
|
+
name: entry.name,
|
|
114
|
+
title: entry.title,
|
|
115
|
+
status: entry.status,
|
|
116
|
+
created_at: entry.created_at,
|
|
117
|
+
completed_at: entry.completed_at,
|
|
118
|
+
totalTasks,
|
|
119
|
+
doneTasks,
|
|
120
|
+
pendingTasks,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return items;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Interactive command handler ──────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export async function handleListPlans(
|
|
131
|
+
ctx: ExtensionCommandContext,
|
|
132
|
+
runPlanIO: RunPlanIO,
|
|
133
|
+
args?: string,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const allItems = await runPlanIO(loadPlanListItems());
|
|
136
|
+
|
|
137
|
+
if (allItems.length === 0) {
|
|
138
|
+
ctx.ui.notify('No plans found in .plans/plans.jsonl', 'info');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Parse inline args: /plans [filter] [sort]
|
|
143
|
+
// e.g. /plans done tasks, /plans in-progress date-asc
|
|
144
|
+
let filter: StatusFilter = 'all';
|
|
145
|
+
let sort: SortField = 'date-desc';
|
|
146
|
+
|
|
147
|
+
if (args?.trim()) {
|
|
148
|
+
const parsed = parseArgs(args.trim());
|
|
149
|
+
filter = parsed.filter;
|
|
150
|
+
sort = parsed.sort;
|
|
151
|
+
} else {
|
|
152
|
+
// Interactive mode: ask the user
|
|
153
|
+
const filterChoice = await ctx.ui.select('Filter plans by status:', [
|
|
154
|
+
'All',
|
|
155
|
+
'In-progress',
|
|
156
|
+
'Done',
|
|
157
|
+
'Superseded',
|
|
158
|
+
'Abandoned',
|
|
159
|
+
]);
|
|
160
|
+
if (!filterChoice) return;
|
|
161
|
+
|
|
162
|
+
const filterMap: Record<string, StatusFilter> = {
|
|
163
|
+
All: 'all',
|
|
164
|
+
'In-progress': 'in-progress',
|
|
165
|
+
Done: 'done',
|
|
166
|
+
Superseded: 'superseded',
|
|
167
|
+
Abandoned: 'abandoned',
|
|
168
|
+
};
|
|
169
|
+
filter = filterMap[filterChoice] ?? 'all';
|
|
170
|
+
|
|
171
|
+
const sortChoice = await ctx.ui.select('Sort by:', [
|
|
172
|
+
'Newest first',
|
|
173
|
+
'Oldest first',
|
|
174
|
+
'Most tasks',
|
|
175
|
+
'Name',
|
|
176
|
+
]);
|
|
177
|
+
if (!sortChoice) return;
|
|
178
|
+
|
|
179
|
+
const sortMap: Record<string, SortField> = {
|
|
180
|
+
'Newest first': 'date-desc',
|
|
181
|
+
'Oldest first': 'date-asc',
|
|
182
|
+
'Most tasks': 'tasks',
|
|
183
|
+
Name: 'name',
|
|
184
|
+
};
|
|
185
|
+
sort = sortMap[sortChoice] ?? 'date-desc';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const filtered = filterPlans(allItems, filter);
|
|
189
|
+
const sorted = sortPlans(filtered, sort);
|
|
190
|
+
const output = formatPlanList(sorted, filter, sort);
|
|
191
|
+
|
|
192
|
+
ctx.ui.notify(output, 'info');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Arg parsing ──────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
const FILTER_ALIASES: Record<string, StatusFilter> = {
|
|
198
|
+
all: 'all',
|
|
199
|
+
'in-progress': 'in-progress',
|
|
200
|
+
pending: 'in-progress',
|
|
201
|
+
active: 'in-progress',
|
|
202
|
+
done: 'done',
|
|
203
|
+
completed: 'done',
|
|
204
|
+
superseded: 'superseded',
|
|
205
|
+
abandoned: 'abandoned',
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const SORT_ALIASES: Record<string, SortField> = {
|
|
209
|
+
name: 'name',
|
|
210
|
+
'date-asc': 'date-asc',
|
|
211
|
+
oldest: 'date-asc',
|
|
212
|
+
'date-desc': 'date-desc',
|
|
213
|
+
newest: 'date-desc',
|
|
214
|
+
tasks: 'tasks',
|
|
215
|
+
'task-count': 'tasks',
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export function parseArgs(raw: string): { filter: StatusFilter; sort: SortField } {
|
|
219
|
+
const tokens = raw.toLowerCase().split(/\s+/);
|
|
220
|
+
let filter: StatusFilter = 'all';
|
|
221
|
+
let sort: SortField = 'date-desc';
|
|
222
|
+
|
|
223
|
+
for (const token of tokens) {
|
|
224
|
+
if (FILTER_ALIASES[token]) filter = FILTER_ALIASES[token];
|
|
225
|
+
else if (SORT_ALIASES[token]) sort = SORT_ALIASES[token];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { filter, sort };
|
|
229
|
+
}
|
|
@@ -10,6 +10,7 @@ export const PLAN_TOOLS = [
|
|
|
10
10
|
'find',
|
|
11
11
|
'ls',
|
|
12
12
|
'submit_plan',
|
|
13
|
+
'submit_initiative',
|
|
13
14
|
'revise_plan',
|
|
14
15
|
'preview_prototype',
|
|
15
16
|
'write',
|
|
@@ -19,6 +20,8 @@ export const PLAN_TOOLS = [
|
|
|
19
20
|
'plan_status',
|
|
20
21
|
'set_active_plan',
|
|
21
22
|
'update_plan',
|
|
23
|
+
'update_initiative',
|
|
24
|
+
'initiative_status',
|
|
22
25
|
'reconcile_plans',
|
|
23
26
|
];
|
|
24
27
|
|
|
@@ -33,6 +36,8 @@ export const EXEC_TOOLS = [
|
|
|
33
36
|
'plan_status',
|
|
34
37
|
'set_active_plan',
|
|
35
38
|
'update_plan',
|
|
39
|
+
'update_initiative',
|
|
40
|
+
'initiative_status',
|
|
36
41
|
'reconcile_plans',
|
|
37
42
|
];
|
|
38
43
|
|
|
@@ -1,24 +1,47 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Prototype document builder.
|
|
3
|
+
*
|
|
4
|
+
* A prototype is a planning-phase visual aid the agent authors freely — there
|
|
5
|
+
* is no template engine and no imposed theme. Whatever HTML the agent (or a
|
|
6
|
+
* delegated ux-designer subagent) writes is what the user sees.
|
|
7
|
+
*
|
|
8
|
+
* The only thing this does is tolerate a bare fragment: if the agent passes
|
|
9
|
+
* body-only markup instead of a complete page, we drop it into a minimal,
|
|
10
|
+
* UNSTYLED shell so the browser still renders it. No fonts, colors, or layout
|
|
11
|
+
* are injected — the design is entirely the agent's.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function escapeHtml(value: string): string {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"');
|
|
20
|
+
}
|
|
5
21
|
|
|
6
|
-
|
|
22
|
+
/** True when the markup is already a complete HTML page. */
|
|
23
|
+
function isFullDocument(markup: string): boolean {
|
|
24
|
+
return /<!doctype\s+html|<html[\s>]/i.test(markup);
|
|
25
|
+
}
|
|
7
26
|
|
|
8
27
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* not a plan dump — no tasks, no handoff.
|
|
28
|
+
* Returns a standalone HTML document for the prototype. Full documents are
|
|
29
|
+
* passed through verbatim; fragments are wrapped in a barebones shell.
|
|
12
30
|
*/
|
|
13
|
-
export function
|
|
14
|
-
const
|
|
15
|
-
|
|
31
|
+
export function buildPrototypeDocument(title: string, html: string): string {
|
|
32
|
+
const markup = html.trim();
|
|
33
|
+
|
|
34
|
+
if (isFullDocument(markup)) return markup;
|
|
16
35
|
|
|
17
|
-
return
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
return `<!doctype html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="utf-8">
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
41
|
+
<title>${escapeHtml(title)}</title>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
${markup}
|
|
45
|
+
</body>
|
|
46
|
+
</html>`;
|
|
24
47
|
}
|