@dreki-gg/pi-plan-mode 0.23.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 +13 -0
- package/README.md +48 -5
- package/bin/clean-plans.js +67 -49
- 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__/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/constants.ts +5 -0
- package/extensions/plan-mode/index.ts +22 -0
- package/extensions/plan-mode/initiative.ts +168 -0
- package/extensions/plan-mode/prompts.ts +4 -0
- 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/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 +1 -1
- package/skills/planning-context/SKILL.md +11 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update_initiative tool — initiative-level lifecycle control.
|
|
3
|
+
*
|
|
4
|
+
* The initiative sibling of update_plan. An initiative's `done` status is a
|
|
5
|
+
* projection of its member plans, but `superseded` / `abandoned` (and reopen)
|
|
6
|
+
* are explicit decisions recorded here with an optional `reason`. A manually
|
|
7
|
+
* set terminal status is never auto-overridden by projection.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import { StringEnum } from '@earendil-works/pi-ai';
|
|
12
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
13
|
+
import { Type } from 'typebox';
|
|
14
|
+
import type { InitiativeStatus } from '../types.js';
|
|
15
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
16
|
+
import {
|
|
17
|
+
readInitiativesManifest,
|
|
18
|
+
upsertInitiativeEntry,
|
|
19
|
+
} from '../storage/initiatives-manifest.js';
|
|
20
|
+
|
|
21
|
+
/** Normalize an initiative hint (`x` or `.plans/x`) to a bare name. */
|
|
22
|
+
function normalizeName(hint: string): string {
|
|
23
|
+
return hint
|
|
24
|
+
.replace(/^\.plans\//, '')
|
|
25
|
+
.replace(/\/+$/, '')
|
|
26
|
+
.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerUpdateInitiativeTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
|
|
30
|
+
pi.registerTool({
|
|
31
|
+
name: 'update_initiative',
|
|
32
|
+
label: 'Update Initiative',
|
|
33
|
+
description:
|
|
34
|
+
'Set an initiative-level status (done, superseded, abandoned, or reopen to in-progress) with an optional reason.',
|
|
35
|
+
promptSnippet: 'Close or reopen an initiative (done/superseded/abandoned/in-progress)',
|
|
36
|
+
promptGuidelines: [
|
|
37
|
+
'Use update_initiative to close an initiative that will not complete via its plans: superseded (another initiative shipped it) or abandoned (won\u2019t do).',
|
|
38
|
+
'Always pass a reason for superseded/abandoned so the registry keeps honest history.',
|
|
39
|
+
'An initiative usually reaches done automatically once every member plan is closed \u2014 prefer letting projection handle it.',
|
|
40
|
+
],
|
|
41
|
+
parameters: Type.Object({
|
|
42
|
+
initiative: Type.String({ description: 'Initiative name (or .plans/<name>) to update' }),
|
|
43
|
+
status: StringEnum(['in-progress', 'done', 'superseded', 'abandoned'] as const),
|
|
44
|
+
reason: Type.Optional(
|
|
45
|
+
Type.String({ description: 'Why — recorded in the registry (esp. superseded/abandoned)' }),
|
|
46
|
+
),
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
async execute(_toolCallId, params) {
|
|
50
|
+
const name = normalizeName(params.initiative);
|
|
51
|
+
const manifest = await runPlanIO(readInitiativesManifest());
|
|
52
|
+
const existing = manifest.find((entry) => entry.name === name);
|
|
53
|
+
if (!existing) {
|
|
54
|
+
const names = manifest.map((entry) => entry.name).join(', ');
|
|
55
|
+
return {
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: 'text' as const,
|
|
59
|
+
text: `Initiative not found: ${name}. Known initiatives: ${names || '(none)'}`,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
details: { error: 'not_found', initiative: name } as Record<string, unknown>,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await runPlanIO(
|
|
67
|
+
upsertInitiativeEntry(name, {
|
|
68
|
+
status: params.status as InitiativeStatus,
|
|
69
|
+
title: existing.title,
|
|
70
|
+
reason: params.reason,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const reasonSuffix = params.reason ? ` — ${params.reason}` : '';
|
|
75
|
+
return {
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: 'text' as const,
|
|
79
|
+
text: `Initiative ${name}: ${existing.status} → ${params.status}${reasonSuffix}`,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
details: {
|
|
83
|
+
initiative: name,
|
|
84
|
+
from: existing.status,
|
|
85
|
+
status: params.status,
|
|
86
|
+
reason: params.reason,
|
|
87
|
+
} as Record<string, unknown>,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
renderCall(args, theme) {
|
|
92
|
+
const name = (args as { initiative?: string }).initiative ?? 'initiative';
|
|
93
|
+
const status = (args as { status?: string }).status ?? '';
|
|
94
|
+
let content = theme.fg('toolTitle', theme.bold('update_initiative '));
|
|
95
|
+
content += theme.fg('accent', name);
|
|
96
|
+
if (status) content += ' ' + theme.fg('muted', status);
|
|
97
|
+
return new Text(content, 0, 0);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
renderResult(result, _options, theme) {
|
|
101
|
+
const details = result.details as
|
|
102
|
+
| { initiative?: string; from?: string; status?: string }
|
|
103
|
+
| undefined;
|
|
104
|
+
if (!details?.status) return new Text(theme.fg('dim', 'Initiative updated'), 0, 0);
|
|
105
|
+
const color =
|
|
106
|
+
details.status === 'done'
|
|
107
|
+
? 'success'
|
|
108
|
+
: details.status === 'in-progress'
|
|
109
|
+
? 'accent'
|
|
110
|
+
: 'warning';
|
|
111
|
+
return new Text(
|
|
112
|
+
theme.fg('muted', `${details.initiative ?? 'initiative'} `) +
|
|
113
|
+
theme.fg('dim', `${details.from ?? ''} → `) +
|
|
114
|
+
theme.fg(color, details.status),
|
|
115
|
+
0,
|
|
116
|
+
0,
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -18,6 +18,7 @@ import { Type } from 'typebox';
|
|
|
18
18
|
import type { PlanStatus } from '../types.js';
|
|
19
19
|
import type { RunPlanIO } from '../effects/runtime.js';
|
|
20
20
|
import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
21
|
+
import { reconcileInitiativeForPlan } from '../initiative.js';
|
|
21
22
|
|
|
22
23
|
/** Normalize a plan hint (`my-plan` or `.plans/my-plan`) to a bare name. */
|
|
23
24
|
function normalizeName(hint: string): string {
|
|
@@ -72,6 +73,8 @@ export function registerUpdatePlanTool(pi: ExtensionAPI, runPlanIO: RunPlanIO):
|
|
|
72
73
|
reason: params.reason,
|
|
73
74
|
}),
|
|
74
75
|
);
|
|
76
|
+
// A plan-level status change can flip its parent initiative's projection.
|
|
77
|
+
await runPlanIO(reconcileInitiativeForPlan(name));
|
|
75
78
|
|
|
76
79
|
const reasonSuffix = params.reason ? ` — ${params.reason}` : '';
|
|
77
80
|
const okDetails: Record<string, unknown> = {
|
|
@@ -13,6 +13,9 @@ export type TaskOrigin = 'plan' | 'discovered';
|
|
|
13
13
|
*/
|
|
14
14
|
export type PlanStatus = 'in-progress' | 'done' | 'superseded' | 'abandoned';
|
|
15
15
|
|
|
16
|
+
/** Initiative lifecycle reuses the plan lifecycle literals. */
|
|
17
|
+
export type InitiativeStatus = PlanStatus;
|
|
18
|
+
|
|
16
19
|
export interface TaskRecord {
|
|
17
20
|
_type: 'task';
|
|
18
21
|
id: string;
|
package/package.json
CHANGED