@dreki-gg/pi-plan-mode 0.24.1 → 0.25.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 +6 -0
- package/extensions/plan-mode/__tests__/plan-references-autocomplete.test.ts +55 -0
- package/extensions/plan-mode/__tests__/plan-references-context.test.ts +86 -0
- package/extensions/plan-mode/__tests__/plan-references-tokens.test.ts +63 -0
- package/extensions/plan-mode/index.ts +12 -0
- package/extensions/plan-mode/references/autocomplete.ts +120 -0
- package/extensions/plan-mode/references/context.ts +140 -0
- package/extensions/plan-mode/references/plan-index.ts +55 -0
- package/extensions/plan-mode/references/tokens.ts +76 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add `@plan:<slug>` plan references with autocomplete. Type `@plan:` in any message to fuzzy-search and tag a plan (all plans listed, in-progress first), and on send the referenced plan's tasks + handoff are attached as context so the agent understands the reference. Resolution is context-only — it never switches execution mode, tools, or model — and follows first-wins when multiple tokens are present.
|
|
8
|
+
|
|
3
9
|
## 0.24.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { filterPlanReferences, formatPlanSuggestion } from '../references/autocomplete.js';
|
|
3
|
+
import type { PlanListItem } from '../commands/list-plans.js';
|
|
4
|
+
|
|
5
|
+
function item(overrides: Partial<PlanListItem> & { name: string }): PlanListItem {
|
|
6
|
+
return {
|
|
7
|
+
title: `Title ${overrides.name}`,
|
|
8
|
+
status: 'in-progress',
|
|
9
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
10
|
+
completed_at: null,
|
|
11
|
+
totalTasks: 3,
|
|
12
|
+
doneTasks: 1,
|
|
13
|
+
pendingTasks: 2,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const items: PlanListItem[] = [
|
|
19
|
+
item({ name: 'done-plan', status: 'done' }),
|
|
20
|
+
item({ name: 'active-one', status: 'in-progress' }),
|
|
21
|
+
item({ name: 'abandoned-plan', status: 'abandoned' }),
|
|
22
|
+
item({ name: 'active-two', status: 'in-progress' }),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
describe('filterPlanReferences', () => {
|
|
26
|
+
test('empty query lists all plans, in-progress first', () => {
|
|
27
|
+
const result = filterPlanReferences(items, '');
|
|
28
|
+
expect(result.map((p) => p.name).slice(0, 2).sort()).toEqual(['active-one', 'active-two']);
|
|
29
|
+
expect(result).toHaveLength(4);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('name-prefix matches win', () => {
|
|
33
|
+
const result = filterPlanReferences(items, 'active-t');
|
|
34
|
+
expect(result[0].name).toBe('active-two');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('fuzzy fallback finds non-prefix matches', () => {
|
|
38
|
+
const result = filterPlanReferences(items, 'abandon');
|
|
39
|
+
expect(result.some((p) => p.name === 'abandoned-plan')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('returns empty array for no match', () => {
|
|
43
|
+
expect(filterPlanReferences(items, 'zzz-nope-nope')).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('formatPlanSuggestion', () => {
|
|
48
|
+
test('builds a @plan: value with status + progress description', () => {
|
|
49
|
+
const suggestion = formatPlanSuggestion(item({ name: 'active-one', status: 'in-progress' }));
|
|
50
|
+
expect(suggestion.value).toBe('@plan:active-one');
|
|
51
|
+
expect(suggestion.label).toContain('Title active-one');
|
|
52
|
+
expect(suggestion.description).toContain('active-one');
|
|
53
|
+
expect(suggestion.description).toContain('1/3');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
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 { upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
8
|
+
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
9
|
+
import { saveHandoff } from '../storage/plan-storage.js';
|
|
10
|
+
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
11
|
+
import { buildPlanContextPack, resolvePlanReference } from '../references/context.js';
|
|
12
|
+
|
|
13
|
+
const runPlanIO = makePlanRuntime();
|
|
14
|
+
const originalCwd = process.cwd();
|
|
15
|
+
let dir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-refctx-'));
|
|
19
|
+
chdir(dir);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
chdir(originalCwd);
|
|
24
|
+
await rm(dir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const meta: TaskMeta = {
|
|
28
|
+
_type: 'meta',
|
|
29
|
+
title: 'Add Auth',
|
|
30
|
+
plan_name: 'add-auth',
|
|
31
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const tasks: TaskRecord[] = [
|
|
35
|
+
{
|
|
36
|
+
_type: 'task',
|
|
37
|
+
id: 't-001',
|
|
38
|
+
description: 'Write middleware',
|
|
39
|
+
status: 'done',
|
|
40
|
+
origin: 'plan',
|
|
41
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
42
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
_type: 'task',
|
|
46
|
+
id: 't-002',
|
|
47
|
+
description: 'Wire routes',
|
|
48
|
+
status: 'pending',
|
|
49
|
+
origin: 'plan',
|
|
50
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
51
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
describe('buildPlanContextPack', () => {
|
|
56
|
+
test('includes title, status, tasks, and handoff', () => {
|
|
57
|
+
const pack = buildPlanContextPack('add-auth', 'Add Auth', 'in-progress', tasks, 'Handoff body');
|
|
58
|
+
expect(pack).toContain('Add Auth');
|
|
59
|
+
expect(pack).toContain('add-auth');
|
|
60
|
+
expect(pack).toContain('in-progress');
|
|
61
|
+
expect(pack).toContain('t-001');
|
|
62
|
+
expect(pack).toContain('Write middleware');
|
|
63
|
+
expect(pack).toContain('t-002');
|
|
64
|
+
expect(pack).toContain('Handoff body');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('resolvePlanReference', () => {
|
|
69
|
+
test('resolves a real plan on disk', async () => {
|
|
70
|
+
await runPlanIO(upsertPlanEntry('add-auth', { status: 'in-progress', title: 'Add Auth' }));
|
|
71
|
+
await runPlanIO(writeTasksJsonl('.plans/add-auth', meta, tasks));
|
|
72
|
+
await runPlanIO(saveHandoff('.plans/add-auth', 'Handoff body'));
|
|
73
|
+
|
|
74
|
+
const resolved = await runPlanIO(resolvePlanReference('add-auth'));
|
|
75
|
+
expect(resolved).toBeDefined();
|
|
76
|
+
expect(resolved?.title).toBe('Add Auth');
|
|
77
|
+
expect(resolved?.status).toBe('in-progress');
|
|
78
|
+
expect(resolved?.tasks).toHaveLength(2);
|
|
79
|
+
expect(resolved?.handoff).toBe('Handoff body');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('returns undefined for an unknown slug', async () => {
|
|
83
|
+
const resolved = await runPlanIO(resolvePlanReference('nope'));
|
|
84
|
+
expect(resolved).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
PLAN_TOKEN_PREFIX,
|
|
4
|
+
buildPlanToken,
|
|
5
|
+
parsePlanSlug,
|
|
6
|
+
findActivePlanToken,
|
|
7
|
+
extractPlanReferences,
|
|
8
|
+
firstPlanReference,
|
|
9
|
+
} from '../references/tokens.js';
|
|
10
|
+
|
|
11
|
+
describe('buildPlanToken / parsePlanSlug', () => {
|
|
12
|
+
test('round-trips a slug', () => {
|
|
13
|
+
const token = buildPlanToken('add-auth-middleware');
|
|
14
|
+
expect(token).toBe(`${PLAN_TOKEN_PREFIX}add-auth-middleware`);
|
|
15
|
+
expect(parsePlanSlug(token)).toBe('add-auth-middleware');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('parsePlanSlug returns undefined for non-plan tokens', () => {
|
|
19
|
+
expect(parsePlanSlug('@chat:abc')).toBeUndefined();
|
|
20
|
+
expect(parsePlanSlug('@plan:')).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('findActivePlanToken', () => {
|
|
25
|
+
test('detects a token being typed at the cursor', () => {
|
|
26
|
+
const active = findActivePlanToken('start working on @plan:add-au');
|
|
27
|
+
expect(active?.query).toBe('add-au');
|
|
28
|
+
expect(active?.token).toBe('@plan:add-au');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('detects an empty query right after the prefix', () => {
|
|
32
|
+
const active = findActivePlanToken('do @plan:');
|
|
33
|
+
expect(active?.query).toBe('');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('returns undefined when there is no token at the cursor', () => {
|
|
37
|
+
expect(findActivePlanToken('just some text')).toBeUndefined();
|
|
38
|
+
expect(findActivePlanToken('email me@plan-b.com')).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('does not match when the token is not adjacent to the cursor', () => {
|
|
42
|
+
expect(findActivePlanToken('@plan:foo and more text')).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('extractPlanReferences / firstPlanReference', () => {
|
|
47
|
+
test('extracts all tokens in order', () => {
|
|
48
|
+
const tokens = extractPlanReferences('work on @plan:alpha then @plan:beta');
|
|
49
|
+
expect(tokens).toEqual(['@plan:alpha', '@plan:beta']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('first-wins helper returns only the first slug', () => {
|
|
53
|
+
expect(firstPlanReference('work on @plan:alpha then @plan:beta')).toBe('alpha');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('firstPlanReference returns undefined when no token present', () => {
|
|
57
|
+
expect(firstPlanReference('nothing here')).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('does not treat email-like text as a token', () => {
|
|
61
|
+
expect(extractPlanReferences('reach me@plan-x.dev')).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -57,11 +57,16 @@ import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
|
|
|
57
57
|
import { isSafeCommand, isPlanPath } from './utils.js';
|
|
58
58
|
import { handleListPlans } from './commands/list-plans.js';
|
|
59
59
|
import { handleListInitiatives } from './commands/list-initiatives.js';
|
|
60
|
+
import { createPlanReferenceIndex } from './references/plan-index.js';
|
|
61
|
+
import { registerPlanReferenceAutocomplete } from './references/autocomplete.js';
|
|
62
|
+
import { registerPlanReferenceContext } from './references/context.js';
|
|
60
63
|
|
|
61
64
|
export default function planMode(pi: ExtensionAPI): void {
|
|
62
65
|
const state = new PlanModeState();
|
|
63
66
|
// Build the live Effect runtime once; all storage I/O runs through this bridge.
|
|
64
67
|
const runPlanIO = makePlanRuntime();
|
|
68
|
+
// Cached plan list for `@plan:<slug>` autocomplete; refreshed at session start.
|
|
69
|
+
const planReferenceIndex = createPlanReferenceIndex(runPlanIO);
|
|
65
70
|
|
|
66
71
|
// ── Flag ──────────────────────────────────────────────────────────────────
|
|
67
72
|
pi.registerFlag('plan', {
|
|
@@ -200,6 +205,9 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
200
205
|
registerInitiativeStatusTool(pi, runPlanIO);
|
|
201
206
|
registerReconcilePlansTool(pi, runPlanIO);
|
|
202
207
|
|
|
208
|
+
// Attach a referenced plan (`@plan:<slug>`) as context when present in a prompt.
|
|
209
|
+
registerPlanReferenceContext(pi, runPlanIO);
|
|
210
|
+
|
|
203
211
|
registerAddTaskTool(pi, {
|
|
204
212
|
resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
|
|
205
213
|
onTaskAdded: async (task) => {
|
|
@@ -602,6 +610,10 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
602
610
|
ctx.sessionManager.getEntries() as Array<{ type: string; customType?: string; data?: any }>,
|
|
603
611
|
);
|
|
604
612
|
|
|
613
|
+
// Register `@plan:<slug>` autocomplete and warm its cache.
|
|
614
|
+
await planReferenceIndex.refresh();
|
|
615
|
+
registerPlanReferenceAutocomplete(ctx, planReferenceIndex);
|
|
616
|
+
|
|
605
617
|
// Check for exec-pending handoff from planning session
|
|
606
618
|
const pending = await runPlanIO(readAndClearExecPending());
|
|
607
619
|
if (pending) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@plan:` autocomplete provider — stacks on top of the built-in provider.
|
|
3
|
+
*
|
|
4
|
+
* Lists all plans, ranking in-progress first. Empty query keeps that ordering;
|
|
5
|
+
* a non-empty query prefers exact name-prefix matches and otherwise falls back
|
|
6
|
+
* to a fuzzy search over name + title.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
10
|
+
import type {
|
|
11
|
+
AutocompleteItem,
|
|
12
|
+
AutocompleteProvider,
|
|
13
|
+
AutocompleteSuggestions,
|
|
14
|
+
} from '@earendil-works/pi-tui';
|
|
15
|
+
import { fuzzyFilter } from '@earendil-works/pi-tui';
|
|
16
|
+
import type { PlanListItem } from '../commands/list-plans.js';
|
|
17
|
+
import type { PlanStatus } from '../types.js';
|
|
18
|
+
import { buildPlanToken, findActivePlanToken } from './tokens.js';
|
|
19
|
+
import type { PlanReferenceIndex } from './plan-index.js';
|
|
20
|
+
|
|
21
|
+
const MAX_SUGGESTIONS = 20;
|
|
22
|
+
|
|
23
|
+
const STATUS_ICON: Record<PlanStatus, string> = {
|
|
24
|
+
'in-progress': '🔵',
|
|
25
|
+
done: '✅',
|
|
26
|
+
superseded: '🔄',
|
|
27
|
+
abandoned: '❌',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const STATUS_RANK: Record<PlanStatus, number> = {
|
|
31
|
+
'in-progress': 0,
|
|
32
|
+
done: 1,
|
|
33
|
+
superseded: 2,
|
|
34
|
+
abandoned: 3,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** In-progress first, then newest plans within each status group. */
|
|
38
|
+
function byInProgressFirst(a: PlanListItem, b: PlanListItem): number {
|
|
39
|
+
const rank = STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
40
|
+
if (rank !== 0) return rank;
|
|
41
|
+
return b.created_at.localeCompare(a.created_at);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function filterPlanReferences(items: PlanListItem[], query: string): PlanListItem[] {
|
|
45
|
+
const trimmed = query.trim().toLowerCase();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return [...items].sort(byInProgressFirst).slice(0, MAX_SUGGESTIONS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const prefixMatches = items
|
|
51
|
+
.filter((item) => item.name.toLowerCase().startsWith(trimmed))
|
|
52
|
+
.sort(byInProgressFirst);
|
|
53
|
+
if (prefixMatches.length) return prefixMatches.slice(0, MAX_SUGGESTIONS);
|
|
54
|
+
|
|
55
|
+
return fuzzyFilter(items, trimmed, (item) => `${item.name} ${item.title}`).slice(
|
|
56
|
+
0,
|
|
57
|
+
MAX_SUGGESTIONS,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatPlanSuggestion(item: PlanListItem): AutocompleteItem {
|
|
62
|
+
const progress = item.totalTasks > 0 ? `${item.doneTasks}/${item.totalTasks} tasks` : 'no tasks';
|
|
63
|
+
return {
|
|
64
|
+
value: buildPlanToken(item.name),
|
|
65
|
+
label: `${item.title}`,
|
|
66
|
+
description: `${STATUS_ICON[item.status]} ${item.name} • ${item.status} • ${progress}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createPlanReferenceAutocompleteProvider(
|
|
71
|
+
current: AutocompleteProvider,
|
|
72
|
+
index: PlanReferenceIndex,
|
|
73
|
+
): AutocompleteProvider {
|
|
74
|
+
return {
|
|
75
|
+
async getSuggestions(
|
|
76
|
+
lines,
|
|
77
|
+
cursorLine,
|
|
78
|
+
cursorCol,
|
|
79
|
+
options,
|
|
80
|
+
): Promise<AutocompleteSuggestions | null> {
|
|
81
|
+
const line = lines[cursorLine] ?? '';
|
|
82
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
83
|
+
const active = findActivePlanToken(beforeCursor);
|
|
84
|
+
if (!active) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
85
|
+
|
|
86
|
+
const items = await index.getItems();
|
|
87
|
+
if (options.signal.aborted) return null;
|
|
88
|
+
|
|
89
|
+
const matches = filterPlanReferences(items, active.query);
|
|
90
|
+
if (matches.length === 0) {
|
|
91
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
prefix: active.token,
|
|
96
|
+
items: matches.map(formatPlanSuggestion),
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
101
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
105
|
+
const line = lines[cursorLine] ?? '';
|
|
106
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
107
|
+
if (findActivePlanToken(beforeCursor)) return false;
|
|
108
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function registerPlanReferenceAutocomplete(
|
|
114
|
+
ctx: ExtensionContext,
|
|
115
|
+
index: PlanReferenceIndex,
|
|
116
|
+
): void {
|
|
117
|
+
ctx.ui.addAutocompleteProvider((current) =>
|
|
118
|
+
createPlanReferenceAutocompleteProvider(current, index),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@plan:` context injection.
|
|
3
|
+
*
|
|
4
|
+
* When a submitted message references a plan with `@plan:<slug>`, attach that
|
|
5
|
+
* plan's tasks + handoff as a hidden context message so the agent understands
|
|
6
|
+
* the reference. First-wins: only the first token in the message is resolved.
|
|
7
|
+
*
|
|
8
|
+
* This is *context only* — it never switches execution mode, tools, or model.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Effect } from 'effect';
|
|
12
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
import type { FileSystem } from '../effects/filesystem.js';
|
|
14
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
15
|
+
import type { PlanStatus, TaskRecord, TaskStatus } from '../types.js';
|
|
16
|
+
import { readPlansManifest } from '../storage/plans-manifest.js';
|
|
17
|
+
import { readTasksJsonl } from '../storage/task-storage.js';
|
|
18
|
+
import { loadHandoff } from '../storage/plan-storage.js';
|
|
19
|
+
import { firstPlanReference } from './tokens.js';
|
|
20
|
+
|
|
21
|
+
export interface ResolvedPlanReference {
|
|
22
|
+
name: string;
|
|
23
|
+
title: string;
|
|
24
|
+
status: PlanStatus;
|
|
25
|
+
tasks: TaskRecord[];
|
|
26
|
+
handoff: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TASK_ICON: Record<TaskStatus, string> = {
|
|
30
|
+
pending: '○',
|
|
31
|
+
done: '✓',
|
|
32
|
+
skipped: '⊘',
|
|
33
|
+
blocked: '✗',
|
|
34
|
+
deferred: '⏸',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Resolve a plan slug to its registry entry + tasks + handoff (Effect-based). */
|
|
38
|
+
export function resolvePlanReference(
|
|
39
|
+
slug: string,
|
|
40
|
+
): Effect.Effect<ResolvedPlanReference | undefined, never, FileSystem> {
|
|
41
|
+
return Effect.gen(function* () {
|
|
42
|
+
const manifest = yield* Effect.orElseSucceed(readPlansManifest(), () => []);
|
|
43
|
+
const entry = manifest.find((candidate) => candidate.name === slug);
|
|
44
|
+
if (!entry) return undefined;
|
|
45
|
+
|
|
46
|
+
const dir = `.plans/${slug}`;
|
|
47
|
+
const snapshot = yield* Effect.orElseSucceed(readTasksJsonl(dir), () => undefined);
|
|
48
|
+
const handoff = yield* loadHandoff(dir);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
name: entry.name,
|
|
52
|
+
title: entry.title,
|
|
53
|
+
status: entry.status,
|
|
54
|
+
tasks: snapshot?.tasks ?? [],
|
|
55
|
+
handoff: handoff ?? '',
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build the markdown context pack injected for a resolved plan reference. */
|
|
61
|
+
export function buildPlanContextPack(
|
|
62
|
+
name: string,
|
|
63
|
+
title: string,
|
|
64
|
+
status: PlanStatus,
|
|
65
|
+
tasks: TaskRecord[],
|
|
66
|
+
handoff: string,
|
|
67
|
+
): string {
|
|
68
|
+
const done = tasks.filter((t) => t.status === 'done' || t.status === 'skipped').length;
|
|
69
|
+
const taskLines = tasks.length
|
|
70
|
+
? tasks
|
|
71
|
+
.map((t) => {
|
|
72
|
+
const marker = t.origin === 'discovered' ? ' (discovered)' : '';
|
|
73
|
+
const notes = t.notes ? `\n ${t.notes}` : '';
|
|
74
|
+
return `${t.id}. ${TASK_ICON[t.status]} ${t.description}${marker}${notes}`;
|
|
75
|
+
})
|
|
76
|
+
.join('\n')
|
|
77
|
+
: '_(no tasks)_';
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
`# Referenced plan: ${title} (\`${name}\`)`,
|
|
81
|
+
`The user referenced this plan with \`@plan:${name}\`. Status: **${status}** — ${done}/${tasks.length} tasks done.`,
|
|
82
|
+
`Use this as context. The source of truth is \`.plans/${name}/\` (tasks.jsonl + HANDOFF.md) — inspect it with tools if you need more detail.`,
|
|
83
|
+
'',
|
|
84
|
+
'## Tasks',
|
|
85
|
+
'',
|
|
86
|
+
taskLines,
|
|
87
|
+
'',
|
|
88
|
+
'## Handoff',
|
|
89
|
+
'',
|
|
90
|
+
handoff.trim() || '_(no handoff recorded)_',
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Register the `before_agent_start` handler that attaches a referenced plan as
|
|
96
|
+
* context. Chains alongside plan-mode's own `before_agent_start` handler.
|
|
97
|
+
*/
|
|
98
|
+
export function registerPlanReferenceContext(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
|
|
99
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
100
|
+
const slug = firstPlanReference(event.prompt);
|
|
101
|
+
if (!slug) return undefined;
|
|
102
|
+
|
|
103
|
+
const resolved = await runPlanIO(resolvePlanReference(slug));
|
|
104
|
+
|
|
105
|
+
if (!resolved) {
|
|
106
|
+
if (ctx.hasUI) {
|
|
107
|
+
ctx.ui.notify(`plan-mode: no plan named "${slug}" for @plan:${slug}`, 'warning');
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
message: {
|
|
111
|
+
customType: 'plan-reference-context',
|
|
112
|
+
content: `The prompt referenced @plan:${slug}, but no plan with that name exists in .plans/plans.jsonl.`,
|
|
113
|
+
display: true,
|
|
114
|
+
details: { slug, resolved: false },
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const content = buildPlanContextPack(
|
|
120
|
+
resolved.name,
|
|
121
|
+
resolved.title,
|
|
122
|
+
resolved.status,
|
|
123
|
+
resolved.tasks,
|
|
124
|
+
resolved.handoff,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (ctx.hasUI) {
|
|
128
|
+
ctx.ui.notify(`Attached plan reference: ${resolved.title} (${resolved.name}).`, 'info');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
message: {
|
|
133
|
+
customType: 'plan-reference-context',
|
|
134
|
+
content,
|
|
135
|
+
display: false,
|
|
136
|
+
details: { slug, name: resolved.name, status: resolved.status, resolved: true },
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache of plan list items for `@plan:` autocomplete.
|
|
3
|
+
*
|
|
4
|
+
* Autocomplete `getSuggestions` runs on every keystroke, so it must be cheap.
|
|
5
|
+
* We cache the manifest-derived `PlanListItem[]` and refresh at most once per
|
|
6
|
+
* TTL window — fresh enough to surface plans submitted mid-session without a
|
|
7
|
+
* per-keystroke disk hit.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
11
|
+
import { loadPlanListItems, type PlanListItem } from '../commands/list-plans.js';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TTL_MS = 2_000;
|
|
14
|
+
|
|
15
|
+
export interface PlanReferenceIndex {
|
|
16
|
+
/** Force a reload from disk. */
|
|
17
|
+
refresh(): Promise<void>;
|
|
18
|
+
/** Cached items, reloading first when the cache is stale. */
|
|
19
|
+
getItems(): Promise<PlanListItem[]>;
|
|
20
|
+
/** Cached items without triggering IO (may be stale or empty). */
|
|
21
|
+
peek(): PlanListItem[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createPlanReferenceIndex(
|
|
25
|
+
runPlanIO: RunPlanIO,
|
|
26
|
+
ttlMs: number = DEFAULT_TTL_MS,
|
|
27
|
+
): PlanReferenceIndex {
|
|
28
|
+
let items: PlanListItem[] = [];
|
|
29
|
+
let loadedAt = 0;
|
|
30
|
+
let inflight: Promise<void> | undefined;
|
|
31
|
+
|
|
32
|
+
const load = async (): Promise<void> => {
|
|
33
|
+
items = await runPlanIO(loadPlanListItems());
|
|
34
|
+
loadedAt = Date.now();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const refresh = async (): Promise<void> => {
|
|
38
|
+
// Coalesce concurrent refreshes so rapid keystrokes share one read.
|
|
39
|
+
if (!inflight) {
|
|
40
|
+
inflight = load().finally(() => {
|
|
41
|
+
inflight = undefined;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
await inflight;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
refresh,
|
|
49
|
+
peek: () => items,
|
|
50
|
+
async getItems() {
|
|
51
|
+
if (Date.now() - loadedAt > ttlMs) await refresh();
|
|
52
|
+
return items;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@plan:<slug>` reference tokens.
|
|
3
|
+
*
|
|
4
|
+
* Lets a user reference a plan inside a normal chat message (e.g.
|
|
5
|
+
* "start working on @plan:add-auth"). Mirrors the past-chats `@chat:` /
|
|
6
|
+
* `@session:` token model: a cursor-anchored matcher drives autocomplete, and a
|
|
7
|
+
* global matcher extracts referenced slugs from a submitted prompt.
|
|
8
|
+
*
|
|
9
|
+
* Pure module — no IO, safe to unit test.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const PLAN_TOKEN_PREFIX = '@plan:' as const;
|
|
13
|
+
|
|
14
|
+
/** Plan slugs are kebab-ish identifiers. */
|
|
15
|
+
const SLUG_CHARS = 'A-Za-z0-9_-';
|
|
16
|
+
|
|
17
|
+
export interface ActivePlanToken {
|
|
18
|
+
/** The query typed after the prefix (may be empty). */
|
|
19
|
+
query: string;
|
|
20
|
+
/** The full token under the cursor, including the prefix. */
|
|
21
|
+
token: string;
|
|
22
|
+
/** Offset in the line where the token starts. */
|
|
23
|
+
tokenStart: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildPlanToken(slug: string): string {
|
|
27
|
+
return `${PLAN_TOKEN_PREFIX}${slug}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Extract the slug from a `@plan:<slug>` token, or undefined when invalid. */
|
|
31
|
+
export function parsePlanSlug(token: string): string | undefined {
|
|
32
|
+
if (!token.startsWith(PLAN_TOKEN_PREFIX)) return undefined;
|
|
33
|
+
const slug = token.slice(PLAN_TOKEN_PREFIX.length).trim();
|
|
34
|
+
return slug || undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Token must be at a word boundary: start of line or after whitespace / open
|
|
38
|
+
// bracket. Prevents matching inside emails like `me@plan-x.dev`.
|
|
39
|
+
const ACTIVE_RE = new RegExp(`(?:^|[\\s([{])(${PLAN_TOKEN_PREFIX}([${SLUG_CHARS}]*))$`);
|
|
40
|
+
const GLOBAL_RE = new RegExp(
|
|
41
|
+
`(?:^|[\\s([{])(${PLAN_TOKEN_PREFIX}([${SLUG_CHARS}]+))`,
|
|
42
|
+
'g',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Detect a `@plan:` token being typed immediately before the cursor. */
|
|
46
|
+
export function findActivePlanToken(textBeforeCursor: string): ActivePlanToken | undefined {
|
|
47
|
+
const match = textBeforeCursor.match(ACTIVE_RE);
|
|
48
|
+
if (!match) return undefined;
|
|
49
|
+
const token = match[1] ?? '';
|
|
50
|
+
return {
|
|
51
|
+
query: match[2] ?? '',
|
|
52
|
+
token,
|
|
53
|
+
tokenStart: textBeforeCursor.length - token.length,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** All `@plan:<slug>` tokens in a message, in order, de-duplicated by slug. */
|
|
58
|
+
export function extractPlanReferences(text: string): string[] {
|
|
59
|
+
const tokens: string[] = [];
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
for (const match of text.matchAll(GLOBAL_RE)) {
|
|
62
|
+
const token = match[1];
|
|
63
|
+
const slug = token ? parsePlanSlug(token) : undefined;
|
|
64
|
+
if (token && slug && !seen.has(slug)) {
|
|
65
|
+
seen.add(slug);
|
|
66
|
+
tokens.push(token);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return tokens;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** First referenced slug in a message (first-wins), or undefined. */
|
|
73
|
+
export function firstPlanReference(text: string): string | undefined {
|
|
74
|
+
const [first] = extractPlanReferences(text);
|
|
75
|
+
return first ? parsePlanSlug(first) : undefined;
|
|
76
|
+
}
|
package/package.json
CHANGED