@dreki-gg/pi-plan-mode 0.24.1 → 0.26.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.26.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Make plans more executable by zero-context executors. Plans now stamp the git
8
+ commit they were written against (`base_commit`), and the execution prompt runs
9
+ a drift check — if HEAD has moved, the executor is told to diff, re-read the
10
+ affected files, and proceed with caution. Planning guidance also now asks
11
+ delegation tasks to end their details with a verification gate (a concrete
12
+ command + expected output) and STOP conditions, so success is machine-checkable.
13
+ Fully backward-compatible: older plans without `base_commit` simply skip the
14
+ drift check.
15
+
16
+ ## 0.25.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 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.
21
+
3
22
  ## 0.24.1
4
23
 
5
24
  ### Patch Changes
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { tmpdir } from 'node:os';
3
+ import { readHeadCommit } from '../git.js';
4
+
5
+ describe('readHeadCommit', () => {
6
+ test('returns the HEAD sha inside a git repo', async () => {
7
+ // This monorepo is a git repo, so cwd resolves a real commit.
8
+ const sha = await readHeadCommit(process.cwd());
9
+ expect(sha).toMatch(/^[0-9a-f]{40}$/);
10
+ });
11
+
12
+ test('returns undefined outside a git repo (never throws)', async () => {
13
+ // tmpdir is not a git repo; the helper must swallow the error.
14
+ const sha = await readHeadCommit(tmpdir());
15
+ expect(sha).toBeUndefined();
16
+ });
17
+ });
@@ -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
+ });
@@ -23,6 +23,11 @@ describe('buildPlanModePrompt', () => {
23
23
  expect(prompt).toContain('technical-options');
24
24
  });
25
25
 
26
+ test('instructs delegation tasks to include a verification gate', () => {
27
+ expect(prompt).toContain('verification gate');
28
+ expect(prompt).toContain('STOP conditions');
29
+ });
30
+
26
31
  test('mentions handoff instead of context and risks', () => {
27
32
  expect(prompt).toContain('handoff');
28
33
  expect(prompt).not.toContain('- risks:');
@@ -72,6 +77,24 @@ describe('buildExecutionPrompt', () => {
72
77
  expect(prompt).toContain('Details: Full instructions here');
73
78
  });
74
79
 
80
+ test('includes a drift check when base_commit is present', () => {
81
+ const plan: PlanData = {
82
+ title: 'Test',
83
+ planName: 'test',
84
+ handoff: '# H',
85
+ tasks: [makeTask()],
86
+ base_commit: 'deadbeef',
87
+ };
88
+ const prompt = buildExecutionPrompt(plan)!;
89
+ expect(prompt).toContain('Drift check');
90
+ expect(prompt).toContain('deadbeef');
91
+ });
92
+
93
+ test('omits the drift check when base_commit is absent', () => {
94
+ const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask()] };
95
+ expect(buildExecutionPrompt(plan)!).not.toContain('Drift check');
96
+ });
97
+
75
98
  test('returns undefined when no pending tasks', () => {
76
99
  const plan: PlanData = {
77
100
  title: 'Test',
@@ -113,6 +113,21 @@ describe('task meta schema', () => {
113
113
  ).toBe(true);
114
114
  });
115
115
 
116
+ test('accepts a meta record with base_commit', () => {
117
+ expect(
118
+ isOk(
119
+ { _type: 'meta', title: 'Refactor', plan_name: 'refactor', created_at: now, base_commit: 'abc123' },
120
+ decodeTaskMeta,
121
+ ),
122
+ ).toBe(true);
123
+ });
124
+
125
+ test('accepts a meta record without base_commit (back-compat)', () => {
126
+ expect(
127
+ isOk({ _type: 'meta', title: 'Refactor', plan_name: 'refactor', created_at: now }, decodeTaskMeta),
128
+ ).toBe(true);
129
+ });
130
+
116
131
  test('rejects malformed meta records', () => {
117
132
  expect(isOk({ _type: 'meta', title: 'Refactor' }, decodeTaskMeta)).toBe(false);
118
133
  expect(
@@ -47,6 +47,13 @@ describe('tasks.jsonl storage', () => {
47
47
  await expect(run(readTasksJsonl(dir))).resolves.toEqual({ meta, tasks: [task] });
48
48
  });
49
49
 
50
+ test('round trips base_commit on the meta record', async () => {
51
+ const metaWithCommit: TaskMeta = { ...meta, base_commit: 'deadbeefcafe' };
52
+ await run(writeTasksJsonl(dir, metaWithCommit, [task]));
53
+ const result = await run(readTasksJsonl(dir));
54
+ expect(result?.meta.base_commit).toBe('deadbeefcafe');
55
+ });
56
+
50
57
  test('round trips tasks without details (lightweight checklist)', async () => {
51
58
  const lightweight: TaskRecord = {
52
59
  _type: 'task',
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Read-only git helpers. Failure-tolerant by design: plan-mode must never block
3
+ * or throw because git metadata is unavailable (no repo, detached HEAD, no
4
+ * commits yet). Runs on Node via execFile — no Bun APIs.
5
+ */
6
+
7
+ import { execFile } from 'node:child_process';
8
+
9
+ /**
10
+ * Resolve the current HEAD commit SHA, or undefined when it cannot be read.
11
+ * Never rejects — callers treat undefined as "no drift baseline".
12
+ */
13
+ export function readHeadCommit(cwd: string = process.cwd()): Promise<string | undefined> {
14
+ return new Promise((resolve) => {
15
+ execFile('git', ['rev-parse', 'HEAD'], { cwd }, (error, stdout) => {
16
+ if (error) return resolve(undefined);
17
+ const sha = stdout.trim();
18
+ resolve(sha.length > 0 ? sha : undefined);
19
+ });
20
+ });
21
+ }
@@ -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) {
@@ -614,6 +626,7 @@ export default function planMode(pi: ExtensionAPI): void {
614
626
  planName: snapshot.meta.plan_name,
615
627
  handoff: (await runPlanIO(loadHandoff(pending.planDir))) ?? '',
616
628
  tasks: snapshot.tasks,
629
+ base_commit: snapshot.meta.base_commit,
617
630
  }
618
631
  : undefined;
619
632
  }
@@ -28,7 +28,7 @@ When you are ready to finalize the plan, call submit_plan with:
28
28
  - tasks: an array of tasks with id (e.g. "t-001"), description (≤60 chars), optional details, and optional depends_on task IDs
29
29
 
30
30
  Plan weight:
31
- - **Delegation plans** (different agent/human executes): include full details in each task so an executor with zero context can follow them.
31
+ - **Delegation plans** (different agent/human executes): include full details in each task so an executor with zero context can follow them. End each task's details with a **verification gate** — a concrete command and its expected output (e.g. \`bun test\` → all pass) so the executor can prove success without judgement, plus any **STOP conditions** ("if X, stop and report" instead of improvising when reality doesn't match the plan).
32
32
  - **Self-execution plans** (you plan and execute in the same session): use lightweight checklist-style tasks — just id + description, skip details. The handoff doc carries the real context.
33
33
 
34
34
  submit_plan is finalization, not the starting point. It records tasks and the handoff — it does not generate HTML.
@@ -59,6 +59,10 @@ export function buildExecutionPrompt(plan: PlanData): string | undefined {
59
59
  const currentTask = remaining[0];
60
60
  const currentDetails = currentTask.details ? `\nDetails: ${currentTask.details}` : '';
61
61
 
62
+ const driftCheck = plan.base_commit
63
+ ? `\n## Drift check (do this FIRST)\nThis plan was written against git commit ${plan.base_commit}. Before editing, run \`git rev-parse HEAD\`. If it differs, the codebase has moved since the plan was written: run \`git diff ${plan.base_commit} --stat\`, re-read any files the current task touches, and proceed with caution — adjust to what the code actually looks like now. This is a warning, not a stop.\n`
64
+ : '';
65
+
62
66
  return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
63
67
 
64
68
  You are executing a structured plan. Your ONLY job is to implement the plan tasks below, one at a time.
@@ -73,6 +77,7 @@ Rules:
73
77
 
74
78
  ## Current task
75
79
  ${currentTask.id}: ${currentTask.description}${currentDetails}
80
+ ${driftCheck}
76
81
 
77
82
  ## Handoff
78
83
  ${plan.handoff}
@@ -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
+ }
@@ -57,6 +57,7 @@ async function attach(
57
57
  planName: snapshot.meta.plan_name,
58
58
  handoff: (await runPlanIO(loadHandoff(dir))) ?? '',
59
59
  tasks: snapshot.tasks,
60
+ base_commit: snapshot.meta.base_commit,
60
61
  };
61
62
  state.plan = plan;
62
63
  state.planDir = dir;
@@ -82,6 +82,7 @@ export async function resumePlan(
82
82
  planName: snapshot.meta.plan_name,
83
83
  handoff: (await runPlanIO(loadHandoff(dir))) ?? '',
84
84
  tasks: snapshot.tasks,
85
+ base_commit: snapshot.meta.base_commit,
85
86
  };
86
87
 
87
88
  const doneCount = state.plan.tasks.filter(
@@ -31,6 +31,8 @@ export const TaskMetaSchema = Schema.Struct({
31
31
  title: Schema.String,
32
32
  plan_name: Schema.String,
33
33
  created_at: Schema.String,
34
+ /** Optional git commit the plan was written against (back-compat: absent on older plans). */
35
+ base_commit: Schema.optional(Schema.String),
34
36
  });
35
37
 
36
38
  /** A single tasks.jsonl line is either the meta record or a task record. */
@@ -129,12 +129,14 @@ export function registerRevisePlanTool(
129
129
  title: newTitle,
130
130
  plan_name: plan.planName,
131
131
  created_at: plan.tasks[0]?.created_at ?? now,
132
+ base_commit: plan.base_commit,
132
133
  };
133
134
  const revised: PlanData = {
134
135
  title: newTitle,
135
136
  planName: plan.planName,
136
137
  handoff: newHandoff,
137
138
  tasks,
139
+ base_commit: plan.base_commit,
138
140
  };
139
141
  const planDir = `.plans/${plan.planName}`;
140
142
 
@@ -13,6 +13,7 @@ import { readInitiativesManifest } from '../storage/initiatives-manifest.js';
13
13
  import { reconcileInitiativeForPlan } from '../initiative.js';
14
14
  import type { RunPlanIO } from '../effects/runtime.js';
15
15
  import { toKebabCase } from '../utils.js';
16
+ import { readHeadCommit } from '../git.js';
16
17
  import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
17
18
 
18
19
  export interface SubmitPlanCallbacks {
@@ -33,6 +34,7 @@ export function registerSubmitPlanTool(
33
34
  'Only call submit_plan after shared understanding has been reached with the user.',
34
35
  'Each task needs an id like t-001, a short description, and optional depends_on task IDs.',
35
36
  "When a different agent or human will execute the plan, include detailed implementation instructions in each task's details field.",
37
+ "For delegation tasks (those with details), end the details with a verification gate: a concrete command and its expected output, plus any STOP conditions, so a zero-context executor can prove success without judgement.",
36
38
  '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.',
37
39
  'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
38
40
  'For visual/UI work, preview a prototype with preview_prototype during planning — before submit_plan, not as part of it.',
@@ -81,11 +83,13 @@ export function registerSubmitPlanTool(
81
83
  const initiative = params.initiative ? toKebabCase(params.initiative) : undefined;
82
84
  const dependsOnPlans = params.depends_on_plans?.map(toKebabCase);
83
85
  const now = new Date().toISOString();
86
+ const baseCommit = await readHeadCommit();
84
87
  const meta: TaskMeta = {
85
88
  _type: 'meta',
86
89
  title: params.title,
87
90
  plan_name: planName,
88
91
  created_at: now,
92
+ base_commit: baseCommit,
89
93
  };
90
94
  const tasks: TaskRecord[] = params.tasks.map((task) => ({
91
95
  _type: 'task',
@@ -97,7 +101,13 @@ export function registerSubmitPlanTool(
97
101
  created_at: now,
98
102
  updated_at: now,
99
103
  }));
100
- const plan: PlanData = { title: params.title, planName, handoff: params.handoff, tasks };
104
+ const plan: PlanData = {
105
+ title: params.title,
106
+ planName,
107
+ handoff: params.handoff,
108
+ tasks,
109
+ base_commit: baseCommit,
110
+ };
101
111
 
102
112
  const unknownInitiative = await runPlanIO(
103
113
  Effect.gen(function* () {
@@ -35,6 +35,12 @@ export interface TaskMeta {
35
35
  title: string;
36
36
  plan_name: string;
37
37
  created_at: string;
38
+ /**
39
+ * Git commit (HEAD) the plan was written against, captured at submit time.
40
+ * Optional for back-compat: older tasks.jsonl files predate this field, and
41
+ * it stays undefined when git metadata is unavailable (no repo, no commits).
42
+ */
43
+ base_commit?: string;
38
44
  }
39
45
 
40
46
  export interface PlanData {
@@ -42,6 +48,8 @@ export interface PlanData {
42
48
  planName: string;
43
49
  handoff: string;
44
50
  tasks: TaskRecord[];
51
+ /** Git commit the plan was written against; powers the executor drift check. */
52
+ base_commit?: string;
45
53
  }
46
54
 
47
55
  export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.24.1",
3
+ "version": "0.26.0",
4
4
  "description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
5
5
  "keywords": [
6
6
  "pi-package"