@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 +19 -0
- package/extensions/plan-mode/__tests__/git.test.ts +17 -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/__tests__/prompts.test.ts +23 -0
- package/extensions/plan-mode/__tests__/schema.test.ts +15 -0
- package/extensions/plan-mode/__tests__/task-storage.test.ts +7 -0
- package/extensions/plan-mode/git.ts +21 -0
- package/extensions/plan-mode/index.ts +13 -0
- package/extensions/plan-mode/prompts.ts +6 -1
- 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/extensions/plan-mode/resolve-plan.ts +1 -0
- package/extensions/plan-mode/resume.ts +1 -0
- package/extensions/plan-mode/schema.ts +2 -0
- package/extensions/plan-mode/tools/revise-plan.ts +2 -0
- package/extensions/plan-mode/tools/submit-plan.ts +11 -1
- package/extensions/plan-mode/types.ts +8 -0
- package/package.json +1 -1
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
|
+
}
|
|
@@ -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 = {
|
|
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