@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,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* initiative_status tool — read-only snapshot of an initiative.
|
|
3
|
+
*
|
|
4
|
+
* The initiative sibling of plan_status. Shows each member plan with its task
|
|
5
|
+
* progress (resolved/total), lifecycle status, and ready/blocked-by readiness
|
|
6
|
+
* derived from plan-level dependencies. This is the at-a-glance view for
|
|
7
|
+
* splitting an initiative's work across sessions or subagents: "ready" plans
|
|
8
|
+
* have all dependencies satisfied and can be picked up now.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
12
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
13
|
+
import { Type } from 'typebox';
|
|
14
|
+
import { Effect } from 'effect';
|
|
15
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
16
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
17
|
+
import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
|
|
18
|
+
import { readInitiativesManifest } from '../storage/initiatives-manifest.js';
|
|
19
|
+
import { readTasksJsonl } from '../storage/task-storage.js';
|
|
20
|
+
import { initiativeRollup, membersOf, type InitiativeRollup } from '../initiative.js';
|
|
21
|
+
|
|
22
|
+
/** Normalize an initiative hint (`x` or `.plans/x`) to a bare name. */
|
|
23
|
+
function normalizeName(hint: string): string {
|
|
24
|
+
return hint
|
|
25
|
+
.replace(/^\.plans\//, '')
|
|
26
|
+
.replace(/\/+$/, '')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TaskCount {
|
|
31
|
+
resolved: number;
|
|
32
|
+
total: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Load member task counts for a set of plans (resolved = done + skipped). */
|
|
36
|
+
function loadTaskCounts(
|
|
37
|
+
plans: readonly PlanManifestEntry[],
|
|
38
|
+
): Effect.Effect<Map<string, TaskCount>, never, FileSystem> {
|
|
39
|
+
return Effect.gen(function* () {
|
|
40
|
+
const counts = new Map<string, TaskCount>();
|
|
41
|
+
for (const plan of plans) {
|
|
42
|
+
const snapshot = yield* Effect.orElseSucceed(
|
|
43
|
+
readTasksJsonl(`.plans/${plan.name}`),
|
|
44
|
+
() => undefined,
|
|
45
|
+
);
|
|
46
|
+
const total = snapshot?.tasks.length ?? 0;
|
|
47
|
+
const resolved =
|
|
48
|
+
snapshot?.tasks.filter((t) => t.status === 'done' || t.status === 'skipped').length ?? 0;
|
|
49
|
+
counts.set(plan.name, { resolved, total });
|
|
50
|
+
}
|
|
51
|
+
return counts;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const STATUS_GLYPH: Record<string, string> = {
|
|
56
|
+
'in-progress': '○',
|
|
57
|
+
done: '✓',
|
|
58
|
+
superseded: '🔄',
|
|
59
|
+
abandoned: '✗',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function registerInitiativeStatusTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
|
|
63
|
+
pi.registerTool({
|
|
64
|
+
name: 'initiative_status',
|
|
65
|
+
label: 'Initiative Status',
|
|
66
|
+
description:
|
|
67
|
+
'Read-only snapshot of an initiative: member plans with task progress, status, and ready/blocked-by. Use to see what work is unblocked and can be picked up next.',
|
|
68
|
+
promptSnippet: 'Show an initiative: member plans, progress, and ready/blocked work',
|
|
69
|
+
promptGuidelines: [
|
|
70
|
+
'Call initiative_status to see which member plans are ready (dependencies satisfied) before dispatching work across sessions or subagents.',
|
|
71
|
+
'It is read-only and never mutates state.',
|
|
72
|
+
],
|
|
73
|
+
parameters: Type.Object({
|
|
74
|
+
initiative: Type.Optional(
|
|
75
|
+
Type.String({
|
|
76
|
+
description:
|
|
77
|
+
'Initiative name (or .plans/<name>). Omit to use the sole in-progress initiative.',
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
}),
|
|
81
|
+
|
|
82
|
+
async execute(_toolCallId, params) {
|
|
83
|
+
const hint = params.initiative ? normalizeName(params.initiative) : undefined;
|
|
84
|
+
|
|
85
|
+
const snapshot = await runPlanIO(
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const initiatives = yield* readInitiativesManifest();
|
|
88
|
+
const plans = yield* readPlansManifest();
|
|
89
|
+
return { initiatives, plans };
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
const { initiatives, plans } = snapshot;
|
|
93
|
+
|
|
94
|
+
// Resolve the target initiative.
|
|
95
|
+
let targetName = hint;
|
|
96
|
+
if (!targetName) {
|
|
97
|
+
const inProgress = initiatives.filter((entry) => entry.status === 'in-progress');
|
|
98
|
+
if (inProgress.length === 1) targetName = inProgress[0]!.name;
|
|
99
|
+
else {
|
|
100
|
+
// Zero or many: list in-progress initiatives with rollup progress.
|
|
101
|
+
const rows = inProgress.map((entry) => {
|
|
102
|
+
const r = initiativeRollup(entry.name, plans);
|
|
103
|
+
return ` ${r.done}/${r.total} plans ${entry.name} — ${entry.title} (ready ${r.ready}, blocked ${r.blocked})`;
|
|
104
|
+
});
|
|
105
|
+
const text = inProgress.length
|
|
106
|
+
? `No single active initiative — ${inProgress.length} in-progress. Pass { initiative: "<name>" }.\n${rows.join('\n')}`
|
|
107
|
+
: 'No in-progress initiative found in .plans/initiatives.jsonl.';
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: 'text' as const, text }],
|
|
110
|
+
details: {
|
|
111
|
+
active: false,
|
|
112
|
+
in_progress: inProgress.map((e) => e.name),
|
|
113
|
+
} as Record<string, unknown>,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const entry = initiatives.find((e) => e.name === targetName);
|
|
119
|
+
if (!entry) {
|
|
120
|
+
const names = initiatives.map((e) => e.name).join(', ');
|
|
121
|
+
return {
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: 'text' as const,
|
|
125
|
+
text: `Initiative not found: ${targetName}. Known: ${names || '(none)'}`,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
details: {
|
|
129
|
+
active: false,
|
|
130
|
+
error: 'not_found',
|
|
131
|
+
initiative: targetName,
|
|
132
|
+
} as Record<string, unknown>,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const rollup: InitiativeRollup = initiativeRollup(entry.name, plans);
|
|
137
|
+
const taskCounts = await runPlanIO(loadTaskCounts(membersOf(entry.name, plans)));
|
|
138
|
+
|
|
139
|
+
const memberLines = rollup.members.map((m) => {
|
|
140
|
+
const glyph = STATUS_GLYPH[m.status] ?? '○';
|
|
141
|
+
const tc = taskCounts.get(m.name);
|
|
142
|
+
const progress = tc && tc.total > 0 ? ` ${tc.resolved}/${tc.total} tasks` : '';
|
|
143
|
+
let readiness = '';
|
|
144
|
+
if (m.status === 'in-progress') {
|
|
145
|
+
readiness = m.ready ? ' [ready]' : ` [blocked by ${m.blockedBy?.join(', ')}]`;
|
|
146
|
+
}
|
|
147
|
+
return ` ${glyph} ${m.name} [${m.status}]${progress}${readiness}`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const text =
|
|
151
|
+
`Initiative: ${entry.title} (${entry.name}) — ${entry.status}\n` +
|
|
152
|
+
`Plans: ${rollup.done}/${rollup.total} done — in-progress ${rollup.inProgress} (ready ${rollup.ready}, blocked ${rollup.blocked})` +
|
|
153
|
+
(rollup.closed ? `, closed ${rollup.closed}` : '') +
|
|
154
|
+
'\n' +
|
|
155
|
+
(memberLines.length ? `Member plans:\n${memberLines.join('\n')}` : 'No member plans yet.');
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text' as const, text }],
|
|
159
|
+
details: {
|
|
160
|
+
active: true,
|
|
161
|
+
initiative: entry.name,
|
|
162
|
+
status: entry.status,
|
|
163
|
+
rollup,
|
|
164
|
+
ready_plans: rollup.members.filter((m) => m.ready).map((m) => m.name),
|
|
165
|
+
} as Record<string, unknown>,
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
renderCall(args, theme) {
|
|
170
|
+
const name = (args as { initiative?: string }).initiative;
|
|
171
|
+
let content = theme.fg('toolTitle', theme.bold('initiative_status'));
|
|
172
|
+
if (name) content += ' ' + theme.fg('muted', name);
|
|
173
|
+
return new Text(content, 0, 0);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
renderResult(result, _options, theme) {
|
|
177
|
+
const details = result.details as
|
|
178
|
+
| { active?: boolean; initiative?: string; rollup?: InitiativeRollup }
|
|
179
|
+
| undefined;
|
|
180
|
+
if (!details?.active || !details.rollup)
|
|
181
|
+
return new Text(theme.fg('dim', 'No active initiative'), 0, 0);
|
|
182
|
+
const r = details.rollup;
|
|
183
|
+
return new Text(
|
|
184
|
+
theme.fg('toolTitle', `${details.initiative ?? 'initiative'} `) +
|
|
185
|
+
theme.fg('muted', `${r.done}/${r.total} plans — ready ${r.ready}, blocked ${r.blocked}`),
|
|
186
|
+
0,
|
|
187
|
+
0,
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* preview_prototype tool — available during the plan phase.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* .plans/_prototypes/, and best-effort opens
|
|
6
|
-
* visual BEFORE the plan is finalized.
|
|
4
|
+
* Persists a freeform HTML prototype the agent authored (no template engine,
|
|
5
|
+
* no imposed theme), writes it under .plans/_prototypes/, and best-effort opens
|
|
6
|
+
* it so the user can react to the visual BEFORE the plan is finalized.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
@@ -14,7 +14,7 @@ import { spawn } from 'node:child_process';
|
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { FileSystem } from '../effects/filesystem.js';
|
|
16
16
|
import type { RunPlanIO } from '../effects/runtime.js';
|
|
17
|
-
import {
|
|
17
|
+
import { buildPrototypeDocument } from '../html/render.js';
|
|
18
18
|
import { toKebabCase } from '../utils.js';
|
|
19
19
|
|
|
20
20
|
const PREVIEW_DIR = '.plans/_prototypes';
|
|
@@ -41,25 +41,30 @@ export function registerPreviewPrototypeTool(pi: ExtensionAPI, runPlanIO: RunPla
|
|
|
41
41
|
name: 'preview_prototype',
|
|
42
42
|
label: 'Preview Prototype',
|
|
43
43
|
description:
|
|
44
|
-
'
|
|
45
|
-
promptSnippet: '
|
|
44
|
+
'Open a freeform HTML prototype for review during planning. You write the HTML — any markup, styles, fonts, and scripts you want — and the tool just persists and opens it.',
|
|
45
|
+
promptSnippet: 'Open a freeform HTML prototype for the user to review',
|
|
46
46
|
promptGuidelines: [
|
|
47
47
|
'Use preview_prototype during planning for visual/UI/layout/style work, before submit_plan.',
|
|
48
48
|
'The prototype is a convergence aid — show it so the user can react before the plan hardens.',
|
|
49
|
-
'
|
|
49
|
+
'You have full freedom over the HTML: there is no template engine and no imposed theme. Avoid generic boilerplate — design something that fits the actual product.',
|
|
50
|
+
'For real design taste, consider delegating the markup to the ux-designer subagent and passing its HTML straight through.',
|
|
51
|
+
'Pass a complete, self-contained HTML document (doctype + html/head/body). Inline any styles or scripts; assume nothing about a host page.',
|
|
50
52
|
],
|
|
51
53
|
parameters: Type.Object({
|
|
52
54
|
title: Type.String({ description: 'Short title for the prototype' }),
|
|
53
55
|
intent: Type.String({
|
|
54
56
|
description: 'One-line description of what this prototype is showing',
|
|
55
57
|
}),
|
|
56
|
-
|
|
58
|
+
html: Type.String({
|
|
59
|
+
description:
|
|
60
|
+
'Complete, self-contained HTML document for the prototype (your own markup, styles, and scripts). A bare fragment is also accepted and wrapped in a minimal unstyled shell.',
|
|
61
|
+
}),
|
|
57
62
|
}),
|
|
58
63
|
|
|
59
64
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
60
65
|
const slug = toKebabCase(params.title) || 'prototype';
|
|
61
66
|
const filePath = join(PREVIEW_DIR, `${slug}.html`);
|
|
62
|
-
const html =
|
|
67
|
+
const html = buildPrototypeDocument(params.title, params.html);
|
|
63
68
|
|
|
64
69
|
await runPlanIO(
|
|
65
70
|
Effect.gen(function* () {
|
|
@@ -11,7 +11,14 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
11
11
|
import { Text } from '@earendil-works/pi-tui';
|
|
12
12
|
import { Type } from 'typebox';
|
|
13
13
|
import type { RunPlanIO } from '../effects/runtime.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
applyInitiativeReconcile,
|
|
16
|
+
applyReconcile,
|
|
17
|
+
collectInitiativeDrift,
|
|
18
|
+
collectPlanDrift,
|
|
19
|
+
type InitiativeDriftRow,
|
|
20
|
+
type PlanDriftRow,
|
|
21
|
+
} from '../reconcile.js';
|
|
15
22
|
|
|
16
23
|
function describeRow(row: PlanDriftRow): string {
|
|
17
24
|
const progress = row.hasTasks ? ` (${row.resolved}/${row.total})` : '';
|
|
@@ -48,23 +55,37 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
|
|
|
48
55
|
|
|
49
56
|
async execute(_toolCallId, params) {
|
|
50
57
|
const rows = await runPlanIO(collectPlanDrift());
|
|
58
|
+
const initiativeRows = await runPlanIO(collectInitiativeDrift());
|
|
51
59
|
const drifted = rows.filter((row) => row.drift);
|
|
60
|
+
const initiativeDrifted = initiativeRows.filter((row) => row.drift);
|
|
52
61
|
|
|
53
62
|
let repaired: PlanDriftRow[] = [];
|
|
63
|
+
let initiativeRepaired: InitiativeDriftRow[] = [];
|
|
54
64
|
if (params.apply) {
|
|
55
65
|
repaired = await runPlanIO(applyReconcile(rows));
|
|
66
|
+
// Re-collect after plan repairs so initiative projection sees fresh state.
|
|
67
|
+
initiativeRepaired = await runPlanIO(
|
|
68
|
+
applyInitiativeReconcile(await runPlanIO(collectInitiativeDrift())),
|
|
69
|
+
);
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
const lines = rows.map(describeRow);
|
|
73
|
+
const initiativeLines = initiativeRows.map(
|
|
74
|
+
(row) =>
|
|
75
|
+
row.drift === 'status'
|
|
76
|
+
? ` ✗ ${row.name} (initiative, ${row.members} plans) — registry ${row.registryStatus}, plans say ${row.derivedStatus}`
|
|
77
|
+
: ` ✓ ${row.name} (initiative, ${row.members} plans) — ${row.registryStatus} (in sync)`,
|
|
78
|
+
);
|
|
59
79
|
const statusDrift = drifted.filter((r) => r.drift === 'status').length;
|
|
60
80
|
const orphans = drifted.filter((r) => r.drift === 'orphan').length;
|
|
61
81
|
const registryOnly = drifted.filter((r) => r.drift === 'registry-only').length;
|
|
62
82
|
|
|
83
|
+
const totalDrift = drifted.length + initiativeDrifted.length;
|
|
63
84
|
const header = params.apply
|
|
64
|
-
? `Reconciled ${repaired.length} plan(s).`
|
|
65
|
-
:
|
|
66
|
-
? 'All plans in sync.'
|
|
67
|
-
: `${
|
|
85
|
+
? `Reconciled ${repaired.length} plan(s) + ${initiativeRepaired.length} initiative(s).`
|
|
86
|
+
: totalDrift === 0
|
|
87
|
+
? 'All plans and initiatives in sync.'
|
|
88
|
+
: `${totalDrift} drift issue(s) found (run with apply:true to repair status drift).`;
|
|
68
89
|
|
|
69
90
|
const summary = [
|
|
70
91
|
`status-drift ${statusDrift}`,
|
|
@@ -72,7 +93,10 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
|
|
|
72
93
|
`registry-only ${registryOnly}`,
|
|
73
94
|
].join(', ');
|
|
74
95
|
|
|
75
|
-
const
|
|
96
|
+
const initiativeBlock = initiativeLines.length
|
|
97
|
+
? `\nInitiatives:\n${initiativeLines.join('\n')}`
|
|
98
|
+
: '';
|
|
99
|
+
const text = `${header}\n${summary}\n${lines.join('\n')}${initiativeBlock}`;
|
|
76
100
|
return {
|
|
77
101
|
content: [{ type: 'text' as const, text }],
|
|
78
102
|
details: {
|
|
@@ -80,6 +104,9 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
|
|
|
80
104
|
repaired: repaired.map((r) => r.name),
|
|
81
105
|
drift: drifted.map((r) => ({ name: r.name, kind: r.drift })),
|
|
82
106
|
total: rows.length,
|
|
107
|
+
initiative_repaired: initiativeRepaired.map((r) => r.name),
|
|
108
|
+
initiative_drift: initiativeDrifted.map((r) => ({ name: r.name, kind: r.drift })),
|
|
109
|
+
initiative_total: initiativeRows.length,
|
|
83
110
|
},
|
|
84
111
|
};
|
|
85
112
|
},
|
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
reconcilePlanStatus,
|
|
24
24
|
upsertPlanEntry,
|
|
25
25
|
} from '../storage/plans-manifest.js';
|
|
26
|
+
import { reconcileInitiativeForPlan, reconcileInitiativeStatus } from '../initiative.js';
|
|
26
27
|
import { isPlanFinalizable } from '../task-status.js';
|
|
28
|
+
import { toKebabCase } from '../utils.js';
|
|
27
29
|
import type { RunPlanIO } from '../effects/runtime.js';
|
|
28
30
|
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
29
31
|
|
|
@@ -47,6 +49,7 @@ export function registerRevisePlanTool(
|
|
|
47
49
|
'Use revise_plan instead of submit_plan when a plan with the same name already exists and the user asks for follow-up changes.',
|
|
48
50
|
'All content fields are optional — pass only what changes; omitted title/handoff/tasks are left as-is.',
|
|
49
51
|
'When you pass tasks, it fully replaces the task set (tasks you omit are dropped). Status and notes are preserved for any task whose id is unchanged.',
|
|
52
|
+
'Pass initiative / depends_on_plans to (re)link this plan to an initiative or change its plan-level dependencies; omit them to preserve the existing links.',
|
|
50
53
|
],
|
|
51
54
|
parameters: Type.Object({
|
|
52
55
|
plan: Type.String({ description: 'Plan name (or .plans/<name>) to revise' }),
|
|
@@ -69,6 +72,14 @@ export function registerRevisePlanTool(
|
|
|
69
72
|
{ minItems: 1 },
|
|
70
73
|
),
|
|
71
74
|
),
|
|
75
|
+
initiative: Type.Optional(
|
|
76
|
+
Type.String({ description: 'Parent initiative name (kebab); omit to preserve.' }),
|
|
77
|
+
),
|
|
78
|
+
depends_on_plans: Type.Optional(
|
|
79
|
+
Type.Array(
|
|
80
|
+
Type.String({ description: 'Plan names this plan depends on (cross-initiative allowed).' }),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
72
83
|
}),
|
|
73
84
|
|
|
74
85
|
async execute(_toolCallId, params) {
|
|
@@ -127,6 +138,9 @@ export function registerRevisePlanTool(
|
|
|
127
138
|
};
|
|
128
139
|
const planDir = `.plans/${plan.planName}`;
|
|
129
140
|
|
|
141
|
+
const newInitiative = params.initiative ? toKebabCase(params.initiative) : undefined;
|
|
142
|
+
const newDependsOn = params.depends_on_plans?.map(toKebabCase);
|
|
143
|
+
|
|
130
144
|
await runPlanIO(
|
|
131
145
|
Effect.gen(function* () {
|
|
132
146
|
yield* writeTasksJsonl(planDir, meta, tasks);
|
|
@@ -135,11 +149,19 @@ export function registerRevisePlanTool(
|
|
|
135
149
|
// re-derive in-progress ⇄ done (never clobbers superseded/abandoned).
|
|
136
150
|
const manifest = yield* readPlansManifest();
|
|
137
151
|
const current = manifest.find((entry) => entry.name === plan.planName);
|
|
152
|
+
const oldInitiative = current?.initiative;
|
|
138
153
|
yield* upsertPlanEntry(plan.planName, {
|
|
139
154
|
status: current?.status ?? 'in-progress',
|
|
140
155
|
title: newTitle,
|
|
156
|
+
initiative: newInitiative,
|
|
157
|
+
depends_on: newDependsOn,
|
|
141
158
|
});
|
|
142
159
|
yield* reconcilePlanStatus(plan.planName, isPlanFinalizable(tasks), newTitle);
|
|
160
|
+
// Keep both the new and any vacated initiative projections in sync.
|
|
161
|
+
yield* reconcileInitiativeForPlan(plan.planName);
|
|
162
|
+
if (newInitiative && oldInitiative && oldInitiative !== newInitiative) {
|
|
163
|
+
yield* reconcileInitiativeStatus(oldInitiative);
|
|
164
|
+
}
|
|
143
165
|
}),
|
|
144
166
|
);
|
|
145
167
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* submit_initiative tool — create an initiative that groups multiple plans.
|
|
3
|
+
*
|
|
4
|
+
* The initiative-level sibling of submit_plan. Use it FIRST for work too large
|
|
5
|
+
* for a single coherent execution session: create the initiative, then submit
|
|
6
|
+
* each executable chunk as its own plan with `initiative` set (and
|
|
7
|
+
* `depends_on_plans` for ordering). The initiative's status is then a
|
|
8
|
+
* projection of its member plans.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
12
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
13
|
+
import { Type } from 'typebox';
|
|
14
|
+
import { Effect } from 'effect';
|
|
15
|
+
import { saveInitiative } from '../storage/plan-storage.js';
|
|
16
|
+
import { upsertInitiativeEntry } from '../storage/initiatives-manifest.js';
|
|
17
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
18
|
+
import { toKebabCase } from '../utils.js';
|
|
19
|
+
|
|
20
|
+
export function registerSubmitInitiativeTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
|
|
21
|
+
pi.registerTool({
|
|
22
|
+
name: 'submit_initiative',
|
|
23
|
+
label: 'Submit Initiative',
|
|
24
|
+
description:
|
|
25
|
+
'Create an initiative that groups multiple plans for a large body of work. Submit member plans separately with their initiative set.',
|
|
26
|
+
promptSnippet: 'Create an initiative to group multiple plans for large work',
|
|
27
|
+
promptGuidelines: [
|
|
28
|
+
'Use submit_initiative when the work does not fit one coherent execution session, or spans multiple subsystems with dependencies between chunks.',
|
|
29
|
+
'Create the initiative first, then submit each executable chunk as its own plan with `initiative` set and `depends_on_plans` capturing ordering.',
|
|
30
|
+
'For a bounded change, skip initiatives and just submit a flat plan.',
|
|
31
|
+
],
|
|
32
|
+
parameters: Type.Object({
|
|
33
|
+
name: Type.String({
|
|
34
|
+
description: 'Short kebab-case name for the initiative (e.g. "auth-overhaul")',
|
|
35
|
+
}),
|
|
36
|
+
title: Type.String({ description: 'Human-readable initiative title' }),
|
|
37
|
+
overview: Type.String({
|
|
38
|
+
description:
|
|
39
|
+
'Markdown overview for INITIATIVE.md: the goal, the plan breakdown, ordering/dependencies, and how the chunks fit together.',
|
|
40
|
+
}),
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
async execute(_toolCallId, params) {
|
|
44
|
+
const name = toKebabCase(params.name);
|
|
45
|
+
const initiativeDir = `.plans/${name}`;
|
|
46
|
+
|
|
47
|
+
await runPlanIO(
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
yield* saveInitiative(initiativeDir, params.overview);
|
|
50
|
+
yield* upsertInitiativeEntry(name, { status: 'in-progress', title: params.title });
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: 'text' as const,
|
|
58
|
+
text: `Initiative "${params.title}" created in ${initiativeDir}. Submit member plans with initiative: "${name}".`,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
details: { initiativeDir, name, title: params.title },
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
renderCall(args, theme) {
|
|
66
|
+
const name = (args as { name?: string }).name ?? 'initiative';
|
|
67
|
+
const title = (args as { title?: string }).title ?? '';
|
|
68
|
+
let content = theme.fg('toolTitle', theme.bold('submit_initiative '));
|
|
69
|
+
content += theme.fg('accent', name);
|
|
70
|
+
if (title) content += ' ' + theme.fg('dim', `"${title}"`);
|
|
71
|
+
return new Text(content, 0, 0);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
renderResult(result, _options, theme) {
|
|
75
|
+
const details = result.details as { name?: string; title?: string } | undefined;
|
|
76
|
+
if (!details?.name) return new Text(theme.fg('success', '✓ Initiative created'), 0, 0);
|
|
77
|
+
return new Text(
|
|
78
|
+
theme.fg('success', '✓ ') +
|
|
79
|
+
theme.fg('accent', theme.bold(details.title ?? details.name)) +
|
|
80
|
+
theme.fg('dim', ` (${details.name})`),
|
|
81
|
+
0,
|
|
82
|
+
0,
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -9,6 +9,8 @@ import { Effect } from 'effect';
|
|
|
9
9
|
import { saveHandoff } from '../storage/plan-storage.js';
|
|
10
10
|
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
11
11
|
import { upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
12
|
+
import { readInitiativesManifest } from '../storage/initiatives-manifest.js';
|
|
13
|
+
import { reconcileInitiativeForPlan } from '../initiative.js';
|
|
12
14
|
import type { RunPlanIO } from '../effects/runtime.js';
|
|
13
15
|
import { toKebabCase } from '../utils.js';
|
|
14
16
|
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
@@ -34,6 +36,7 @@ export function registerSubmitPlanTool(
|
|
|
34
36
|
'When you are planning and executing yourself (same session), use lightweight checklist-style tasks: just id + description, omit details. Put the real context in the handoff document instead.',
|
|
35
37
|
'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
|
|
36
38
|
'For visual/UI work, preview a prototype with preview_prototype during planning — before submit_plan, not as part of it.',
|
|
39
|
+
'For large work split across plans, set `initiative` to the parent initiative (create it first with submit_initiative) and use `depends_on_plans` to order plans against each other — this enables ready-work tracking across sessions and agents.',
|
|
37
40
|
],
|
|
38
41
|
parameters: Type.Object({
|
|
39
42
|
name: Type.String({
|
|
@@ -57,11 +60,26 @@ export function registerSubmitPlanTool(
|
|
|
57
60
|
}),
|
|
58
61
|
{ minItems: 1 },
|
|
59
62
|
),
|
|
63
|
+
initiative: Type.Optional(
|
|
64
|
+
Type.String({
|
|
65
|
+
description:
|
|
66
|
+
'Parent initiative name (kebab) when this plan is one chunk of a larger initiative. Create the initiative first with submit_initiative.',
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
depends_on_plans: Type.Optional(
|
|
70
|
+
Type.Array(
|
|
71
|
+
Type.String({
|
|
72
|
+
description: 'Names of other plans this plan depends on (cross-initiative allowed).',
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
60
76
|
}),
|
|
61
77
|
|
|
62
78
|
async execute(_toolCallId, params) {
|
|
63
79
|
const planName = toKebabCase(params.name);
|
|
64
80
|
const planDir = `.plans/${planName}`;
|
|
81
|
+
const initiative = params.initiative ? toKebabCase(params.initiative) : undefined;
|
|
82
|
+
const dependsOnPlans = params.depends_on_plans?.map(toKebabCase);
|
|
65
83
|
const now = new Date().toISOString();
|
|
66
84
|
const meta: TaskMeta = {
|
|
67
85
|
_type: 'meta',
|
|
@@ -81,24 +99,39 @@ export function registerSubmitPlanTool(
|
|
|
81
99
|
}));
|
|
82
100
|
const plan: PlanData = { title: params.title, planName, handoff: params.handoff, tasks };
|
|
83
101
|
|
|
84
|
-
await runPlanIO(
|
|
102
|
+
const unknownInitiative = await runPlanIO(
|
|
85
103
|
Effect.gen(function* () {
|
|
86
104
|
yield* writeTasksJsonl(planDir, meta, tasks);
|
|
87
105
|
yield* saveHandoff(planDir, params.handoff);
|
|
88
|
-
yield* upsertPlanEntry(planName, {
|
|
106
|
+
yield* upsertPlanEntry(planName, {
|
|
107
|
+
status: 'in-progress',
|
|
108
|
+
title: params.title,
|
|
109
|
+
initiative,
|
|
110
|
+
depends_on: dependsOnPlans,
|
|
111
|
+
});
|
|
112
|
+
// Keep the parent initiative's projected status in sync.
|
|
113
|
+
yield* reconcileInitiativeForPlan(planName);
|
|
114
|
+
if (!initiative) return false;
|
|
115
|
+
const initiatives = yield* readInitiativesManifest();
|
|
116
|
+
return !initiatives.some((entry) => entry.name === initiative);
|
|
89
117
|
}),
|
|
90
118
|
);
|
|
91
119
|
|
|
92
120
|
callbacks.onPlanSubmitted(planDir, plan);
|
|
93
121
|
|
|
122
|
+
const linkSuffix = initiative
|
|
123
|
+
? ` Linked to initiative "${initiative}"${
|
|
124
|
+
unknownInitiative ? ' (no initiatives.jsonl entry yet — create it with submit_initiative)' : ''
|
|
125
|
+
}.`
|
|
126
|
+
: '';
|
|
94
127
|
return {
|
|
95
128
|
content: [
|
|
96
129
|
{
|
|
97
130
|
type: 'text' as const,
|
|
98
|
-
text: `Plan "${params.title}" saved with ${tasks.length} tasks in ${planDir}. Execute when ready
|
|
131
|
+
text: `Plan "${params.title}" saved with ${tasks.length} tasks in ${planDir}. Execute when ready.${linkSuffix}`,
|
|
99
132
|
},
|
|
100
133
|
],
|
|
101
|
-
details: { planDir, plan },
|
|
134
|
+
details: { planDir, plan, initiative, depends_on_plans: dependsOnPlans },
|
|
102
135
|
};
|
|
103
136
|
},
|
|
104
137
|
|