@dreki-gg/pi-plan-mode 0.27.3 → 0.29.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 +41 -0
- package/extensions/plan-mode/__tests__/external-target-tools.test.ts +133 -0
- package/extensions/plan-mode/__tests__/precondition-guard.test.ts +99 -0
- package/extensions/plan-mode/__tests__/prompts.test.ts +7 -0
- package/extensions/plan-mode/__tests__/submit-plan.test.ts +85 -4
- package/extensions/plan-mode/__tests__/target.test.ts +73 -0
- package/extensions/plan-mode/__tests__/ui.test.ts +52 -0
- package/extensions/plan-mode/precondition-guard.ts +83 -0
- package/extensions/plan-mode/prompts.ts +5 -0
- package/extensions/plan-mode/target.ts +107 -0
- package/extensions/plan-mode/tools/add-task.ts +63 -1
- package/extensions/plan-mode/tools/revise-plan.ts +36 -5
- package/extensions/plan-mode/tools/submit-plan.ts +43 -10
- package/extensions/plan-mode/ui.ts +9 -18
- package/package.json +2 -2
- package/skills/planning-context/SKILL.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.29.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ffde28d: Plan into another project's `.plans/` and stop trusting in-memory plan status.
|
|
8
|
+
|
|
9
|
+
- `submit_plan`, `revise_plan`, and `add_task` now accept an optional `target`
|
|
10
|
+
pointing at another repo's root. When set, the plan is filed into that
|
|
11
|
+
project's `.plans/` registry — handy when you're dogfooding project A, hit a
|
|
12
|
+
gap in package B (which you author), and want the plan to live and later
|
|
13
|
+
execute in B. Author-only: an external plan is never pinned as the current
|
|
14
|
+
session's active plan, and a missing/invalid target is rejected up front.
|
|
15
|
+
- The plan-mode status bar no longer renders progress counts (`📋 2/5`) or mode
|
|
16
|
+
badges from in-memory state — that data drifted from disk. The agent reads
|
|
17
|
+
real status from the ledger via `plan_status` instead.
|
|
18
|
+
- `taskman` exposes `makeNodeFileSystemService(root)` and a `root` parameter on
|
|
19
|
+
`makePlanRuntime` / `makeRuntimeLayer`, so the whole `.plans/` registry can be
|
|
20
|
+
rooted at any working directory. Default behaviour (current working directory)
|
|
21
|
+
is unchanged.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [ffde28d]
|
|
26
|
+
- @dreki-gg/taskman@0.4.0
|
|
27
|
+
|
|
28
|
+
## 0.28.0
|
|
29
|
+
|
|
30
|
+
### Minor Changes
|
|
31
|
+
|
|
32
|
+
- Add a precondition gate that stops "delete X, it's unused" plan drift. Any task
|
|
33
|
+
that deletes, removes, renames, or narrows a codebase symbol/export/file/feature
|
|
34
|
+
must now carry a re-runnable proof of its premise — a `Proof: <command>` line
|
|
35
|
+
(grep/ast-grep over every exported symbol, scoped by feature not directory) or
|
|
36
|
+
an explicit `Precondition: none — <reason>` opt-out. The rule is hoisted to the
|
|
37
|
+
top of the planner prompt and enforced deterministically: `submit_plan` refuses
|
|
38
|
+
to persist a plan whose destructive tasks lack proof, naming the offending task
|
|
39
|
+
ids so you can fix and resubmit. No LLM call, no reviewer side effect. At
|
|
40
|
+
execution time the proof is re-run, and a contradicted premise (live consumers,
|
|
41
|
+
a delete that breaks an import) blocks instead of being silently improvised
|
|
42
|
+
around.
|
|
43
|
+
|
|
3
44
|
## 0.27.3
|
|
4
45
|
|
|
5
46
|
### Patch Changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { chdir } from 'node:process';
|
|
3
|
+
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { makePlanRuntime, writeTasksJsonl, upsertPlanEntry } from '@dreki-gg/taskman';
|
|
7
|
+
import type { TaskMeta, TaskRecord } from '@dreki-gg/taskman';
|
|
8
|
+
import { registerRevisePlanTool } from '../tools/revise-plan.js';
|
|
9
|
+
import { registerAddTaskTool } from '../tools/add-task.js';
|
|
10
|
+
|
|
11
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
12
|
+
|
|
13
|
+
interface CapturedTool {
|
|
14
|
+
execute: (
|
|
15
|
+
id: string,
|
|
16
|
+
params: Record<string, unknown>,
|
|
17
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function captureTool(register: (pi: unknown) => void): CapturedTool {
|
|
21
|
+
let tool: CapturedTool | undefined;
|
|
22
|
+
register({ registerTool: (config: CapturedTool) => (tool = config) });
|
|
23
|
+
return tool!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Seed an in-progress plan with one done task into a target repo's .plans/. */
|
|
27
|
+
async function seedPlan(targetDir: string): Promise<void> {
|
|
28
|
+
const io = makePlanRuntime(targetDir);
|
|
29
|
+
const meta: TaskMeta = { _type: 'meta', title: 'Gap', plan_name: 'gap', created_at: now };
|
|
30
|
+
const task: TaskRecord = {
|
|
31
|
+
_type: 'task',
|
|
32
|
+
id: 't-001',
|
|
33
|
+
description: 'done work',
|
|
34
|
+
details: '',
|
|
35
|
+
status: 'done',
|
|
36
|
+
origin: 'plan',
|
|
37
|
+
created_at: now,
|
|
38
|
+
updated_at: now,
|
|
39
|
+
};
|
|
40
|
+
await io(writeTasksJsonl('.plans/gap', meta, [task]));
|
|
41
|
+
await io(upsertPlanEntry('gap', { status: 'in-progress', title: 'Gap' }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const originalCwd = process.cwd();
|
|
45
|
+
let cwdDir: string;
|
|
46
|
+
let targetDir: string;
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
cwdDir = await mkdtemp(join(tmpdir(), 'plan-mode-ext-cwd-'));
|
|
50
|
+
targetDir = await mkdtemp(join(tmpdir(), 'plan-mode-ext-target-'));
|
|
51
|
+
chdir(cwdDir);
|
|
52
|
+
await seedPlan(targetDir);
|
|
53
|
+
});
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
chdir(originalCwd);
|
|
56
|
+
await rm(cwdDir, { recursive: true, force: true });
|
|
57
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('revise_plan — external target', () => {
|
|
61
|
+
test('rewrites the plan in the target repo and does not pin session state', async () => {
|
|
62
|
+
let pinned = false;
|
|
63
|
+
const tool = captureTool((pi) =>
|
|
64
|
+
registerRevisePlanTool(pi as never, makePlanRuntime(), {
|
|
65
|
+
resolvePlan: async () => ({ plan: undefined, candidates: [] }),
|
|
66
|
+
onPlanRevised: () => {
|
|
67
|
+
pinned = true;
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const res = await tool.execute('c', {
|
|
73
|
+
plan: 'gap',
|
|
74
|
+
title: 'Gap (revised)',
|
|
75
|
+
target: targetDir,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const manifest = await readFile(join(targetDir, '.plans', 'plans.jsonl'), 'utf-8');
|
|
79
|
+
expect(manifest).toContain('Gap (revised)');
|
|
80
|
+
expect(res.content?.[0]?.text).toMatch(/revised/);
|
|
81
|
+
expect((res.details as { target?: string }).target).toBe(targetDir);
|
|
82
|
+
expect(pinned).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('add_task — external target', () => {
|
|
87
|
+
test('appends a deferred task to the target plan, bypassing session callbacks', async () => {
|
|
88
|
+
let sessionCalled = false;
|
|
89
|
+
const tool = captureTool((pi) =>
|
|
90
|
+
registerAddTaskTool(pi as never, {
|
|
91
|
+
resolvePlan: async () => {
|
|
92
|
+
sessionCalled = true;
|
|
93
|
+
return { plan: undefined, candidates: [] };
|
|
94
|
+
},
|
|
95
|
+
onTaskAdded: () => {
|
|
96
|
+
sessionCalled = true;
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const res = await tool.execute('c', {
|
|
102
|
+
description: 'Fix the gap edge case',
|
|
103
|
+
reason: 'found while dogfooding',
|
|
104
|
+
plan: 'gap',
|
|
105
|
+
target: targetDir,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const tasks = await readFile(join(targetDir, '.plans', 'gap', 'tasks.jsonl'), 'utf-8');
|
|
109
|
+
expect(tasks).toContain('Fix the gap edge case');
|
|
110
|
+
expect(tasks).toContain('deferred');
|
|
111
|
+
expect(res.content?.[0]?.text).toMatch(/Captured follow-up t-002/);
|
|
112
|
+
expect(sessionCalled).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('soft-skips when the plan does not exist in the target', async () => {
|
|
116
|
+
const tool = captureTool((pi) =>
|
|
117
|
+
registerAddTaskTool(pi as never, {
|
|
118
|
+
resolvePlan: async () => ({ plan: undefined, candidates: [] }),
|
|
119
|
+
onTaskAdded: () => {},
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const res = await tool.execute('c', {
|
|
124
|
+
description: 'x',
|
|
125
|
+
reason: 'y',
|
|
126
|
+
plan: 'nonexistent',
|
|
127
|
+
target: targetDir,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(res.content?.[0]?.text).toMatch(/plan not found/i);
|
|
131
|
+
expect((res.details as { skipped?: boolean }).skipped).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
detectPreconditionGaps,
|
|
4
|
+
isDestructiveTaskText,
|
|
5
|
+
hasPreconditionProof,
|
|
6
|
+
formatPreconditionRejection,
|
|
7
|
+
} from '../precondition-guard.js';
|
|
8
|
+
|
|
9
|
+
describe('isDestructiveTaskText', () => {
|
|
10
|
+
test('fires on destructive verb + symbol target', () => {
|
|
11
|
+
expect(isDestructiveTaskText('Remove the `AuthProvider` export')).toBe(true);
|
|
12
|
+
expect(isDestructiveTaskText('Delete the previewCandidatesOp operation')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('fires on destructive verb + file path', () => {
|
|
16
|
+
expect(isDestructiveTaskText('Delete src/auth/middleware.ts')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('does NOT fire on destructive verb with no codebase target', () => {
|
|
20
|
+
expect(isDestructiveTaskText('Remove the TODO comment')).toBe(false);
|
|
21
|
+
expect(isDestructiveTaskText('Delete the temporary value we created')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('does NOT fire on non-destructive task', () => {
|
|
25
|
+
expect(isDestructiveTaskText('Add a new export to the schema module')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('hasPreconditionProof', () => {
|
|
30
|
+
test('accepts a Precondition: marker (incl. opt-out)', () => {
|
|
31
|
+
expect(hasPreconditionProof('Precondition: none — never imported anywhere')).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
test('accepts a Proof: marker', () => {
|
|
34
|
+
expect(hasPreconditionProof('Proof: ran the grep, zero hits')).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
test('accepts an actual search command', () => {
|
|
37
|
+
expect(hasPreconditionProof('rg "AuthProvider" -l shows no consumers')).toBe(true);
|
|
38
|
+
expect(hasPreconditionProof('ast-grep verifies no callers')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
test('rejects prose with no proof signal', () => {
|
|
41
|
+
expect(hasPreconditionProof('this is unused so delete it')).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('detectPreconditionGaps', () => {
|
|
46
|
+
test('flags a destructive task lacking proof', () => {
|
|
47
|
+
const gaps = detectPreconditionGaps([
|
|
48
|
+
{ id: 't-005', description: 'Delete core viewer schema', details: 'it has zero consumers' },
|
|
49
|
+
]);
|
|
50
|
+
expect(gaps.map((g) => g.id)).toEqual(['t-005']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('passes a destructive task WITH a proof command', () => {
|
|
54
|
+
const gaps = detectPreconditionGaps([
|
|
55
|
+
{
|
|
56
|
+
id: 't-005',
|
|
57
|
+
description: 'Delete the `ViewerConfig` export',
|
|
58
|
+
details: 'Proof: rg "ViewerConfig|Viewer3DConfig|ViewerSceneConfig" -l → no hits',
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
expect(gaps).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('passes a destructive task WITH an explicit opt-out', () => {
|
|
65
|
+
const gaps = detectPreconditionGaps([
|
|
66
|
+
{
|
|
67
|
+
id: 't-003',
|
|
68
|
+
description: 'Remove the legacy preview command',
|
|
69
|
+
details: 'Precondition: none — added this session, never wired to anything',
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
expect(gaps).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('ignores non-destructive tasks entirely', () => {
|
|
76
|
+
const gaps = detectPreconditionGaps([
|
|
77
|
+
{ id: 't-001', description: 'Add auth middleware', details: 'create src/auth/mw.ts' },
|
|
78
|
+
{ id: 't-002', description: 'Remove the TODO comment in README' },
|
|
79
|
+
]);
|
|
80
|
+
expect(gaps).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('reports multiple gaps', () => {
|
|
84
|
+
const gaps = detectPreconditionGaps([
|
|
85
|
+
{ id: 't-001', description: 'Delete src/old.ts' },
|
|
86
|
+
{ id: 't-002', description: 'Rename the `Foo` interface to Bar' },
|
|
87
|
+
]);
|
|
88
|
+
expect(gaps.map((g) => g.id)).toEqual(['t-001', 't-002']);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('formatPreconditionRejection', () => {
|
|
93
|
+
test('names offending ids and the escape hatch', () => {
|
|
94
|
+
const msg = formatPreconditionRejection([{ id: 't-005', matched: 'Delete' }]);
|
|
95
|
+
expect(msg).toContain('t-005');
|
|
96
|
+
expect(msg).toContain('Precondition: none');
|
|
97
|
+
expect(msg).toContain('submit_plan again');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -28,6 +28,13 @@ describe('buildPlanModePrompt', () => {
|
|
|
28
28
|
expect(prompt).toContain('STOP conditions');
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
test('hoists the precondition gate and flags it as enforced', () => {
|
|
32
|
+
expect(prompt).toContain('PRECONDITION GATE');
|
|
33
|
+
expect(prompt).toMatch(/submit_plan will REJECT/i);
|
|
34
|
+
expect(prompt).toContain('Precondition: none');
|
|
35
|
+
expect(prompt).toMatch(/FEATURE, not directory/i);
|
|
36
|
+
});
|
|
37
|
+
|
|
31
38
|
test('mentions handoff instead of context and risks', () => {
|
|
32
39
|
expect(prompt).toContain('handoff');
|
|
33
40
|
expect(prompt).not.toContain('- risks:');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
2
|
import { chdir } from 'node:process';
|
|
3
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { makePlanRuntime } from '@dreki-gg/taskman';
|
|
@@ -17,9 +17,10 @@ interface SubmitParams {
|
|
|
17
17
|
name: string;
|
|
18
18
|
title: string;
|
|
19
19
|
handoff: string;
|
|
20
|
-
tasks: Array<{ id: string; description: string }>;
|
|
20
|
+
tasks: Array<{ id: string; description: string; details?: string }>;
|
|
21
21
|
initiative?: string;
|
|
22
22
|
depends_on_plans?: string[];
|
|
23
|
+
target?: string;
|
|
23
24
|
}
|
|
24
25
|
interface CapturedTool {
|
|
25
26
|
execute: (
|
|
@@ -28,14 +29,14 @@ interface CapturedTool {
|
|
|
28
29
|
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function setup(): CapturedTool {
|
|
32
|
+
function setup(onPlanSubmitted: () => void = () => {}): CapturedTool {
|
|
32
33
|
let tool: CapturedTool | undefined;
|
|
33
34
|
const pi = {
|
|
34
35
|
registerTool: (config: CapturedTool) => {
|
|
35
36
|
tool = config;
|
|
36
37
|
},
|
|
37
38
|
} as unknown as Parameters<typeof registerSubmitPlanTool>[0];
|
|
38
|
-
registerSubmitPlanTool(pi, runPlanIO, { onPlanSubmitted
|
|
39
|
+
registerSubmitPlanTool(pi, runPlanIO, { onPlanSubmitted });
|
|
39
40
|
return tool!;
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -58,6 +59,47 @@ afterEach(async () => {
|
|
|
58
59
|
await rm(dir, { recursive: true, force: true });
|
|
59
60
|
});
|
|
60
61
|
|
|
62
|
+
describe('submit_plan tool — precondition gate', () => {
|
|
63
|
+
test('rejects a destructive task with no proof and persists nothing', async () => {
|
|
64
|
+
const tool = setup();
|
|
65
|
+
const res = await tool.execute(
|
|
66
|
+
'c',
|
|
67
|
+
baseParams({ tasks: [{ id: 't-001', description: 'Delete the `AuthProvider` export' }] }),
|
|
68
|
+
);
|
|
69
|
+
expect(res.content?.[0]?.text).toContain('precondition gate failed');
|
|
70
|
+
expect((res.details as { rejected?: boolean }).rejected).toBe(true);
|
|
71
|
+
// Nothing written to the registry.
|
|
72
|
+
const entries = await runPlanIO(readPlansManifest());
|
|
73
|
+
expect(entries).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('accepts a destructive task that carries a proof command', async () => {
|
|
77
|
+
const tool = setup();
|
|
78
|
+
const res = await tool.execute(
|
|
79
|
+
'c',
|
|
80
|
+
baseParams({
|
|
81
|
+
tasks: [
|
|
82
|
+
{
|
|
83
|
+
id: 't-001',
|
|
84
|
+
description: 'Delete the `AuthProvider` export',
|
|
85
|
+
details: 'Proof: rg "AuthProvider" -l → no consumers',
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
expect((res.details as { rejected?: boolean }).rejected).toBeUndefined();
|
|
91
|
+
const entries = await runPlanIO(readPlansManifest());
|
|
92
|
+
expect(entries).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('accepts a non-destructive task untouched', async () => {
|
|
96
|
+
const tool = setup();
|
|
97
|
+
await tool.execute('c', baseParams());
|
|
98
|
+
const entries = await runPlanIO(readPlansManifest());
|
|
99
|
+
expect(entries).toHaveLength(1);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
61
103
|
describe('submit_plan tool — initiative + plan deps', () => {
|
|
62
104
|
test('persists initiative + depends_on onto the plan manifest entry', async () => {
|
|
63
105
|
await runPlanIO(upsertInitiativeEntry('auth-overhaul', { status: 'in-progress', title: 'Auth' }));
|
|
@@ -95,3 +137,42 @@ describe('submit_plan tool — initiative + plan deps', () => {
|
|
|
95
137
|
expect(result.content?.[0]?.text).not.toMatch(/initiative/i);
|
|
96
138
|
});
|
|
97
139
|
});
|
|
140
|
+
|
|
141
|
+
describe('submit_plan tool — external target', () => {
|
|
142
|
+
test('files the plan into the target repo, not cwd, and does not pin session state', async () => {
|
|
143
|
+
const targetDir = await mkdtemp(join(tmpdir(), 'plan-mode-target-'));
|
|
144
|
+
try {
|
|
145
|
+
let pinned = false;
|
|
146
|
+
const tool = setup(() => {
|
|
147
|
+
pinned = true;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = await tool.execute('c', baseParams({ target: targetDir }));
|
|
151
|
+
|
|
152
|
+
// Plan landed in the target repo's registry.
|
|
153
|
+
const targetManifest = await readFile(join(targetDir, '.plans', 'plans.jsonl'), 'utf-8');
|
|
154
|
+
expect(targetManifest).toContain('auth-jwt');
|
|
155
|
+
const tasks = await readFile(join(targetDir, '.plans', 'auth-jwt', 'tasks.jsonl'), 'utf-8');
|
|
156
|
+
expect(tasks).toContain('t-001');
|
|
157
|
+
|
|
158
|
+
// Nothing leaked into the current working directory.
|
|
159
|
+
await expect(
|
|
160
|
+
readFile(join(dir, '.plans', 'plans.jsonl'), 'utf-8'),
|
|
161
|
+
).rejects.toThrow();
|
|
162
|
+
|
|
163
|
+
// Author-only: the active-plan callback is NOT invoked for external targets.
|
|
164
|
+
expect(pinned).toBe(false);
|
|
165
|
+
expect(result.content?.[0]?.text).toMatch(/filed into/);
|
|
166
|
+
expect((result.details as { target?: string }).target).toBe(targetDir);
|
|
167
|
+
} finally {
|
|
168
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('rejects a target directory that does not exist', async () => {
|
|
173
|
+
const tool = setup();
|
|
174
|
+
await expect(
|
|
175
|
+
tool.execute('c', baseParams({ target: join(dir, 'does-not-exist') })),
|
|
176
|
+
).rejects.toThrow(/does not exist/);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { join, relative } from 'node:path';
|
|
5
|
+
import { mkdir, writeFile as writeFileFs } from 'node:fs/promises';
|
|
6
|
+
import {
|
|
7
|
+
resolvePlanTarget,
|
|
8
|
+
assertTargetReceived,
|
|
9
|
+
assertTargetTaskAppended,
|
|
10
|
+
} from '../target.js';
|
|
11
|
+
|
|
12
|
+
let dir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-target-'));
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await rm(dir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('resolvePlanTarget', () => {
|
|
22
|
+
test('returns undefined for absent / empty / whitespace input', async () => {
|
|
23
|
+
expect(await resolvePlanTarget(undefined)).toBeUndefined();
|
|
24
|
+
expect(await resolvePlanTarget('')).toBeUndefined();
|
|
25
|
+
expect(await resolvePlanTarget(' ')).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('resolves an existing absolute directory', async () => {
|
|
29
|
+
expect(await resolvePlanTarget(dir)).toBe(dir);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('resolves a relative path against cwd', async () => {
|
|
33
|
+
const rel = relative(process.cwd(), dir);
|
|
34
|
+
expect(await resolvePlanTarget(rel)).toBe(dir);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('expands a leading ~ to the home directory', async () => {
|
|
38
|
+
expect(await resolvePlanTarget('~')).toBe(homedir());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('throws when the target does not exist', async () => {
|
|
42
|
+
await expect(resolvePlanTarget(join(dir, 'nope'))).rejects.toThrow(/does not exist/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('throws when the target is a file, not a directory', async () => {
|
|
46
|
+
const file = join(dir, 'file.txt');
|
|
47
|
+
await writeFile(file, 'x');
|
|
48
|
+
await expect(resolvePlanTarget(file)).rejects.toThrow(/not a directory/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('stale-target guards', () => {
|
|
53
|
+
test('assertTargetReceived throws when the target tasks file is absent', async () => {
|
|
54
|
+
await expect(assertTargetReceived(dir, '.plans/gap')).rejects.toThrow(
|
|
55
|
+
/does not support external targets/,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('assertTargetReceived passes when the target tasks file exists', async () => {
|
|
60
|
+
await mkdir(join(dir, '.plans', 'gap'), { recursive: true });
|
|
61
|
+
await writeFileFs(join(dir, '.plans', 'gap', 'tasks.jsonl'), '{"_type":"meta"}\n');
|
|
62
|
+
await expect(assertTargetReceived(dir, '.plans/gap')).resolves.toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('assertTargetTaskAppended throws when the task id is not in the target file', async () => {
|
|
66
|
+
await mkdir(join(dir, '.plans', 'gap'), { recursive: true });
|
|
67
|
+
await writeFileFs(join(dir, '.plans', 'gap', 'tasks.jsonl'), '{"id":"t-001"}\n');
|
|
68
|
+
await expect(assertTargetTaskAppended(dir, '.plans/gap', 't-002')).rejects.toThrow(
|
|
69
|
+
/external targets/,
|
|
70
|
+
);
|
|
71
|
+
await expect(assertTargetTaskAppended(dir, '.plans/gap', 't-001')).resolves.toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { updateUI } from '../ui.js';
|
|
3
|
+
import { PlanModeState } from '../state.js';
|
|
4
|
+
import type { PlanData, TaskRecord } from '../types.js';
|
|
5
|
+
|
|
6
|
+
function makePlan(): PlanData {
|
|
7
|
+
const done: TaskRecord = {
|
|
8
|
+
_type: 'task',
|
|
9
|
+
id: 't-001',
|
|
10
|
+
description: 'Done work',
|
|
11
|
+
details: '',
|
|
12
|
+
status: 'done',
|
|
13
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
14
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
15
|
+
};
|
|
16
|
+
const pending: TaskRecord = { ...done, id: 't-002', status: 'pending' };
|
|
17
|
+
return { title: 'T', planName: 'test', handoff: '# H', tasks: [done, pending] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeCtx() {
|
|
21
|
+
const statusCalls: Array<[string, unknown]> = [];
|
|
22
|
+
const widgetCalls: Array<[string, unknown]> = [];
|
|
23
|
+
const ctx = {
|
|
24
|
+
ui: {
|
|
25
|
+
theme: { fg: (_role: string, text: string) => text },
|
|
26
|
+
setStatus: (id: string, value: unknown) => statusCalls.push([id, value]),
|
|
27
|
+
setWidget: (id: string, value: unknown) => widgetCalls.push([id, value]),
|
|
28
|
+
},
|
|
29
|
+
} as unknown as Parameters<typeof updateUI>[1];
|
|
30
|
+
return { ctx, statusCalls, widgetCalls };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('updateUI', () => {
|
|
34
|
+
test('never renders plan progress from memory — status is always cleared', () => {
|
|
35
|
+
const cases: Array<Partial<PlanModeState>> = [
|
|
36
|
+
{ executing: true, plan: makePlan() },
|
|
37
|
+
{ executing: false, planEnabled: false, plan: makePlan() },
|
|
38
|
+
{ planEnabled: true },
|
|
39
|
+
{},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const overrides of cases) {
|
|
43
|
+
const state = Object.assign(new PlanModeState(), overrides);
|
|
44
|
+
const { ctx, statusCalls, widgetCalls } = makeCtx();
|
|
45
|
+
|
|
46
|
+
updateUI(state, ctx);
|
|
47
|
+
|
|
48
|
+
expect(statusCalls).toEqual([['plan-mode', undefined]]);
|
|
49
|
+
expect(widgetCalls).toEqual([['plan-todos', undefined]]);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic precondition guard for submit_plan.
|
|
3
|
+
*
|
|
4
|
+
* Catches "destructive premise without proof" drift at plan-submission time:
|
|
5
|
+
* a task that deletes/removes/renames a codebase symbol/path but carries no
|
|
6
|
+
* re-runnable consumer-proof command (or an explicit, auditable opt-out).
|
|
7
|
+
*
|
|
8
|
+
* Pure string heuristics — NO LLM, no reviewer agent, no I/O. The trigger is
|
|
9
|
+
* intentionally narrow (destructive verb AND a codebase-scoped target) to
|
|
10
|
+
* avoid false-positive fatigue, which is the failure mode that makes guards
|
|
11
|
+
* get ignored.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A destructive action verb applied to code. */
|
|
15
|
+
const DESTRUCTIVE =
|
|
16
|
+
/\b(delete|deletes|deleting|remove|removes|removing|rename|renames|renaming|drop|drops|dropping|strip|strips|stripping|purge|purges|purging|deprecate|deprecates|deprecating|unregister|unregisters)\b|\b(rip|tear)\s+out\b/i;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A codebase-scoped target: a backticked identifier, a file path, a known code
|
|
20
|
+
* file extension, or a code-construct noun. Generic words like "file" alone are
|
|
21
|
+
* deliberately excluded — file-ish targets must show up as a path/extension/
|
|
22
|
+
* backtick — to keep "delete the temp file" from tripping the guard.
|
|
23
|
+
*/
|
|
24
|
+
const CODEBASE_TARGET =
|
|
25
|
+
/`[^`]+`|\b[\w.-]+\/[\w./-]+|\.(ts|tsx|js|jsx|mjs|cjs|json|md|css|scss|html|py|go|rs|java|rb|sql|ya?ml|toml)\b|\b(export|exports|import|imports|function|class|interface|type|schema|module|component|route|endpoint|operation|command|setting|settings|config|field|enum|const|method|prop|props|symbol|package|namespace|hook|reducer|selector|migration)s?\b/i;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Proof or auditable opt-out signal. A `Precondition:`/`Proof:` marker (the
|
|
29
|
+
* opt-out form is `Precondition: none — <reason>`), or an actual search command
|
|
30
|
+
* that establishes the consumer set.
|
|
31
|
+
*/
|
|
32
|
+
const PROOF = /\b(precondition|proof)\b\s*:|\b(grep|rg|ripgrep|ast-grep|ast_grep)\b/i;
|
|
33
|
+
|
|
34
|
+
export interface PreconditionGap {
|
|
35
|
+
id: string;
|
|
36
|
+
/** The destructive verb phrase that triggered the check. */
|
|
37
|
+
matched: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns true when text describes a destructive change to a codebase target. */
|
|
41
|
+
export function isDestructiveTaskText(text: string): boolean {
|
|
42
|
+
return DESTRUCTIVE.test(text) && CODEBASE_TARGET.test(text);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns true when text carries a proof command or an auditable opt-out. */
|
|
46
|
+
export function hasPreconditionProof(text: string): boolean {
|
|
47
|
+
return PROOF.test(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find tasks that describe a destructive codebase change but carry no proof
|
|
52
|
+
* command or opt-out. Scans description + details together.
|
|
53
|
+
*/
|
|
54
|
+
export function detectPreconditionGaps(
|
|
55
|
+
tasks: ReadonlyArray<{ id: string; description: string; details?: string }>,
|
|
56
|
+
): PreconditionGap[] {
|
|
57
|
+
const gaps: PreconditionGap[] = [];
|
|
58
|
+
for (const task of tasks) {
|
|
59
|
+
const text = `${task.description}\n${task.details ?? ''}`;
|
|
60
|
+
if (!isDestructiveTaskText(text)) continue;
|
|
61
|
+
if (hasPreconditionProof(text)) continue;
|
|
62
|
+
const matched = DESTRUCTIVE.exec(text)?.[0] ?? 'destructive change';
|
|
63
|
+
gaps.push({ id: task.id, matched });
|
|
64
|
+
}
|
|
65
|
+
return gaps;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Human-readable rejection message for the agent to act on. */
|
|
69
|
+
export function formatPreconditionRejection(gaps: PreconditionGap[]): string {
|
|
70
|
+
const list = gaps.map((g) => ` • ${g.id} ("${g.matched}")`).join('\n');
|
|
71
|
+
return (
|
|
72
|
+
`Plan NOT saved — precondition gate failed for ${gaps.length} destructive ` +
|
|
73
|
+
`task(s):\n${list}\n\n` +
|
|
74
|
+
`Each task above deletes/removes/renames a codebase target but carries no ` +
|
|
75
|
+
`precondition proof. In the task's details, add ONE of:\n` +
|
|
76
|
+
` 1. A proof command that establishes the consumer set, scoped by feature ` +
|
|
77
|
+
`not directory — e.g. \`Proof: rg "ExportedSymbol" -l\` (run it for EVERY ` +
|
|
78
|
+
`exported symbol being removed, not just the type/feature name), OR\n` +
|
|
79
|
+
` 2. An explicit, auditable opt-out: \`Precondition: none — <reason it has ` +
|
|
80
|
+
`no consumers>\`.\n\n` +
|
|
81
|
+
`Then call submit_plan again. This is deterministic input validation, not a review.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -21,6 +21,8 @@ Your job is to reach shared understanding before formalizing a plan:
|
|
|
21
21
|
3. Maintain a living .plans/<plan-name>/context.md as you converge — the planning-context skill covers what to capture and how.
|
|
22
22
|
4. Only call submit_plan after the user and agent have converged on the approach.
|
|
23
23
|
|
|
24
|
+
PRECONDITION GATE — blast-radius awareness (anti-rot). This is enforced: submit_plan will REJECT the plan if you skip it. Any task that deletes, removes, renames, or narrows a symbol, export, file, or feature must carry a re-runnable proof of its premise in the task's details. Record the exact command (grep / ast-grep over EVERY exported symbol name being removed — not just the type or feature name) that establishes the consumer set, written as a \`Proof: <command>\` line, plus the expected result. Scope by FEATURE, not directory: follow each symbol upward to its callers, operations, commands, and settings — a "delete the X dir" task that never traces X's consumers is incomplete and will rot. If you genuinely believe there are no consumers, state it as an auditable opt-out: \`Precondition: none — <reason>\`. The executor RE-RUNS this proof; if reality contradicts it, the executor blocks instead of improvising.
|
|
25
|
+
|
|
24
26
|
When you are ready to finalize the plan, call submit_plan with:
|
|
25
27
|
- name: a short kebab-case name (e.g. "add-auth-middleware")
|
|
26
28
|
- title: a human-readable plan title
|
|
@@ -31,6 +33,8 @@ Plan weight:
|
|
|
31
33
|
- **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
34
|
- **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
35
|
|
|
36
|
+
The verification gate proves success *after* a task; the **precondition gate** (above) proves a destructive task's *premise before* acting on it. Both are required where they apply — the precondition gate is enforced by submit_plan.
|
|
37
|
+
|
|
34
38
|
submit_plan is finalization, not the starting point. It records tasks and the handoff — it does not generate HTML.
|
|
35
39
|
|
|
36
40
|
Sizing the work — flat plan vs initiative:
|
|
@@ -73,6 +77,7 @@ Rules:
|
|
|
73
77
|
- Do NOT run diagnostics, linters, test suites, or skills unless a task explicitly asks for it
|
|
74
78
|
- Do NOT explore the codebase beyond what the current task requires
|
|
75
79
|
- Do NOT deviate from the plan — if something seems wrong, call update_task with status "blocked"
|
|
80
|
+
- If a destructive task names a precondition (e.g. "X is unused", "no consumers of Y"), **re-run its proof command FIRST**. If reality contradicts the task — the symbol still has live consumers, the delete would break an import — **STOP and call update_task status "blocked"** with what you found. Do NOT rename, stub, comment out, or otherwise improvise to make the delete "work". Silently improvising around a contradicted premise is the exact failure this rule exists to stop.
|
|
76
81
|
- If you notice worthwhile work OUTSIDE the current plan, call add_task to capture it as a deferred follow-up, then keep going. Do NOT implement discovered work in this run — the user reviews follow-ups later via /plan resume.
|
|
77
82
|
|
|
78
83
|
## Current task
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an optional external `target` working directory for the write tools
|
|
3
|
+
* (submit_plan / revise_plan / add_task).
|
|
4
|
+
*
|
|
5
|
+
* The use-case: while working in repo A you discover a gap in package B (which
|
|
6
|
+
* you author) and want to file the plan straight into B's `.plans/` registry so
|
|
7
|
+
* it becomes a first-class local plan there. The `target` points at B's repo
|
|
8
|
+
* root (NOT its `.plans/` dir).
|
|
9
|
+
*
|
|
10
|
+
* Returns `undefined` when no target is given (caller uses the default,
|
|
11
|
+
* cwd-bound runtime). When a target is given it is expanded (`~`), resolved to
|
|
12
|
+
* an absolute path, and validated to be an existing directory — a missing or
|
|
13
|
+
* non-directory target throws so we never silently create `.plans/` inside a
|
|
14
|
+
* typo'd path.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
19
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
20
|
+
|
|
21
|
+
/** Expand a leading `~` / `~/` to the user's home directory. */
|
|
22
|
+
function expandHome(input: string): string {
|
|
23
|
+
if (input === '~') return homedir();
|
|
24
|
+
if (input.startsWith('~/') || input.startsWith('~\\')) {
|
|
25
|
+
return resolve(homedir(), input.slice(2));
|
|
26
|
+
}
|
|
27
|
+
return input;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve and validate an external target directory.
|
|
32
|
+
*
|
|
33
|
+
* @returns the absolute target dir, or `undefined` when `target` is empty/absent.
|
|
34
|
+
* @throws when the resolved path does not exist or is not a directory.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolvePlanTarget(target?: string): Promise<string | undefined> {
|
|
37
|
+
const trimmed = target?.trim();
|
|
38
|
+
if (!trimmed) return undefined;
|
|
39
|
+
|
|
40
|
+
const expanded = expandHome(trimmed);
|
|
41
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(process.cwd(), expanded);
|
|
42
|
+
|
|
43
|
+
let stats;
|
|
44
|
+
try {
|
|
45
|
+
stats = await stat(absolute);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`target directory does not exist: ${absolute}. Pass the repo root of the project whose .plans/ should receive this plan.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!stats.isDirectory()) {
|
|
52
|
+
throw new Error(`target is not a directory: ${absolute}. Pass the repo root, not a file.`);
|
|
53
|
+
}
|
|
54
|
+
return absolute;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Verify that an external-target write actually landed under `targetDir`.
|
|
59
|
+
*
|
|
60
|
+
* Routing to an external root relies on the loaded `@dreki-gg/taskman` honoring
|
|
61
|
+
* the `root` argument to `makePlanRuntime`. An OLD/stale taskman build silently
|
|
62
|
+
* ignores it and writes to the current working directory instead — reporting
|
|
63
|
+
* success while misfiling the plan. This converts that silent fallback into a
|
|
64
|
+
* loud, actionable failure.
|
|
65
|
+
*
|
|
66
|
+
* @throws when `<targetDir>/<planDir>/tasks.jsonl` was not created.
|
|
67
|
+
*/
|
|
68
|
+
export async function assertTargetReceived(targetDir: string, planDir: string): Promise<void> {
|
|
69
|
+
const marker = resolve(targetDir, planDir, 'tasks.jsonl');
|
|
70
|
+
let body: string;
|
|
71
|
+
try {
|
|
72
|
+
body = await readFile(marker, 'utf-8');
|
|
73
|
+
} catch {
|
|
74
|
+
throw staleTargetError(targetDir, marker);
|
|
75
|
+
}
|
|
76
|
+
if (body.length === 0) throw staleTargetError(targetDir, marker);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Like {@link assertTargetReceived} but for an append: confirm the freshly
|
|
81
|
+
* written `taskId` is actually present in the target plan's tasks file. Reads
|
|
82
|
+
* the file directly (not via a runtime) so a stale taskman cannot mask the
|
|
83
|
+
* fallback by reading from cwd.
|
|
84
|
+
*/
|
|
85
|
+
export async function assertTargetTaskAppended(
|
|
86
|
+
targetDir: string,
|
|
87
|
+
planDir: string,
|
|
88
|
+
taskId: string,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const marker = resolve(targetDir, planDir, 'tasks.jsonl');
|
|
91
|
+
let body = '';
|
|
92
|
+
try {
|
|
93
|
+
body = await readFile(marker, 'utf-8');
|
|
94
|
+
} catch {
|
|
95
|
+
throw staleTargetError(targetDir, marker);
|
|
96
|
+
}
|
|
97
|
+
if (!body.includes(`"${taskId}"`)) throw staleTargetError(targetDir, marker);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function staleTargetError(targetDir: string, marker: string): Error {
|
|
101
|
+
return new Error(
|
|
102
|
+
`External-target write did not land in ${targetDir} (${marker} missing or unchanged). ` +
|
|
103
|
+
'The loaded @dreki-gg/taskman build does not support external targets (root param) — ' +
|
|
104
|
+
'restart pi so it reloads the rebuilt taskman, then retry. ' +
|
|
105
|
+
"Note: the write may have gone to the current project's .plans/ instead — check and clean up there.",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -9,9 +9,17 @@
|
|
|
9
9
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
10
10
|
import { Text } from '@earendil-works/pi-tui';
|
|
11
11
|
import { Type } from 'typebox';
|
|
12
|
+
import { Effect as E } from 'effect';
|
|
12
13
|
import type { TaskRecord } from '../types.js';
|
|
13
14
|
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
nextTaskId,
|
|
17
|
+
makePlanRuntime,
|
|
18
|
+
resolvePlanByName,
|
|
19
|
+
appendDeferredTask,
|
|
20
|
+
loadPlanData,
|
|
21
|
+
} from '@dreki-gg/taskman';
|
|
22
|
+
import { resolvePlanTarget, assertTargetTaskAppended } from '../target.js';
|
|
15
23
|
|
|
16
24
|
export interface AddTaskCallbacks {
|
|
17
25
|
/** Resolve the active plan, attaching from disk when none is in memory. */
|
|
@@ -45,6 +53,12 @@ export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallback
|
|
|
45
53
|
description:
|
|
46
54
|
'Plan name (or .plans/<name>) to target. Required — always scope the capture explicitly so it never lands in the wrong plan.',
|
|
47
55
|
}),
|
|
56
|
+
target: Type.Optional(
|
|
57
|
+
Type.String({
|
|
58
|
+
description:
|
|
59
|
+
"Optional path to ANOTHER project's repo root whose .plans/ holds the plan. Use when capturing a follow-up against a plan you filed into a package you author. The plan must already exist there.",
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
48
62
|
}),
|
|
49
63
|
|
|
50
64
|
async execute(_toolCallId, params) {
|
|
@@ -58,6 +72,54 @@ export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallback
|
|
|
58
72
|
'add_task requires an explicit { plan } — pass the plan name so the follow-up is never misfiled onto an unrelated in-progress plan.',
|
|
59
73
|
);
|
|
60
74
|
}
|
|
75
|
+
// External target: resolve + append against that repo's .plans/ via a
|
|
76
|
+
// target-scoped runtime, bypassing the cwd-bound session callbacks.
|
|
77
|
+
const targetDir = await resolvePlanTarget(params.target);
|
|
78
|
+
if (targetDir) {
|
|
79
|
+
const io = makePlanRuntime(targetDir);
|
|
80
|
+
const resolved = await io(resolvePlanByName({ name: params.plan }));
|
|
81
|
+
if (!resolved.planName || !resolved.planDir) {
|
|
82
|
+
const hint = resolved.candidates.length
|
|
83
|
+
? ` In-progress in ${targetDir}: ${resolved.candidates.join(', ')}.`
|
|
84
|
+
: ` No such plan in ${targetDir}/.plans/.`;
|
|
85
|
+
return {
|
|
86
|
+
content: [
|
|
87
|
+
{ type: 'text' as const, text: `Skipped follow-up capture — plan not found.${hint}` },
|
|
88
|
+
],
|
|
89
|
+
details: { skipped: true, candidates: resolved.candidates, target: targetDir },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const { task, deferred } = await io(
|
|
93
|
+
E.gen(function* () {
|
|
94
|
+
const added = yield* appendDeferredTask(resolved.planDir!, {
|
|
95
|
+
description: params.description,
|
|
96
|
+
reason: params.reason,
|
|
97
|
+
details: params.details,
|
|
98
|
+
depends_on: params.depends_on,
|
|
99
|
+
});
|
|
100
|
+
const reloaded = yield* loadPlanData(resolved.planDir!);
|
|
101
|
+
const count = reloaded?.tasks.filter((t) => t.status === 'deferred').length ?? 1;
|
|
102
|
+
return { task: added, deferred: count };
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
// Fail loudly if an old/stale taskman silently appended to cwd instead.
|
|
106
|
+
await assertTargetTaskAppended(targetDir, resolved.planDir!, task.id);
|
|
107
|
+
return {
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
type: 'text' as const,
|
|
111
|
+
text: `Captured follow-up ${task.id}: ${task.description} into ${targetDir}/${resolved.planDir} (deferred for review). ${deferred} follow-up(s) pending there. Continue with the planned tasks — do not implement this now.`,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
details: {
|
|
115
|
+
task_id: task.id,
|
|
116
|
+
description: task.description,
|
|
117
|
+
reason: params.reason,
|
|
118
|
+
target: targetDir,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
61
123
|
const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
|
|
62
124
|
// No active plan is a tracking miss, not an error — return a soft,
|
|
63
125
|
// non-terminating result so real work continues.
|
|
@@ -26,7 +26,10 @@ import {
|
|
|
26
26
|
import { reconcileInitiativeForPlan, reconcileInitiativeStatus } from '@dreki-gg/taskman';
|
|
27
27
|
import { isPlanFinalizable } from '@dreki-gg/taskman';
|
|
28
28
|
import { toKebabCase } from '@dreki-gg/taskman';
|
|
29
|
+
import { makePlanRuntime, resolvePlanByName, loadPlanData } from '@dreki-gg/taskman';
|
|
29
30
|
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
31
|
+
import { Effect as E } from 'effect';
|
|
32
|
+
import { resolvePlanTarget, assertTargetReceived } from '../target.js';
|
|
30
33
|
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
31
34
|
|
|
32
35
|
export interface RevisePlanCallbacks {
|
|
@@ -53,6 +56,12 @@ export function registerRevisePlanTool(
|
|
|
53
56
|
],
|
|
54
57
|
parameters: Type.Object({
|
|
55
58
|
plan: Type.String({ description: 'Plan name (or .plans/<name>) to revise' }),
|
|
59
|
+
target: Type.Optional(
|
|
60
|
+
Type.String({
|
|
61
|
+
description:
|
|
62
|
+
"Optional path to ANOTHER project's repo root whose .plans/ holds the plan being revised. Use when the plan was filed into a package you author. Author-only: the revised plan is NOT pinned as this session's active plan.",
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
56
65
|
title: Type.Optional(Type.String({ description: 'New human-readable plan title' })),
|
|
57
66
|
handoff: Type.Optional(Type.String({ description: 'New markdown content for HANDOFF.md' })),
|
|
58
67
|
tasks: Type.Optional(
|
|
@@ -92,7 +101,23 @@ export function registerRevisePlanTool(
|
|
|
92
101
|
'revise_plan requires an explicit { plan } — pass the plan name so the rewrite is never applied to an unrelated in-progress plan.',
|
|
93
102
|
);
|
|
94
103
|
}
|
|
95
|
-
|
|
104
|
+
// Resolve an optional external target. When set, resolve + rewrite the
|
|
105
|
+
// plan against that repo's .plans/ via a target-scoped runtime, and do
|
|
106
|
+
// not touch this session's active-plan state (author-only).
|
|
107
|
+
const targetDir = await resolvePlanTarget(params.target);
|
|
108
|
+
const io: RunPlanIO = targetDir ? makePlanRuntime(targetDir) : runPlanIO;
|
|
109
|
+
|
|
110
|
+
const { plan, candidates } = targetDir
|
|
111
|
+
? await io(
|
|
112
|
+
E.gen(function* () {
|
|
113
|
+
const resolved = yield* resolvePlanByName({ name: params.plan });
|
|
114
|
+
if (!resolved.planName || !resolved.planDir)
|
|
115
|
+
return { plan: undefined, candidates: resolved.candidates };
|
|
116
|
+
const loaded = yield* loadPlanData(resolved.planDir);
|
|
117
|
+
return { plan: loaded, candidates: [] as string[] };
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
: await callbacks.resolvePlan({ name: params.plan });
|
|
96
121
|
if (!plan) {
|
|
97
122
|
const notFound: Record<string, unknown> = {
|
|
98
123
|
error: 'not_found',
|
|
@@ -152,7 +177,7 @@ export function registerRevisePlanTool(
|
|
|
152
177
|
const newInitiative = params.initiative ? toKebabCase(params.initiative) : undefined;
|
|
153
178
|
const newDependsOn = params.depends_on_plans?.map(toKebabCase);
|
|
154
179
|
|
|
155
|
-
await
|
|
180
|
+
await io(
|
|
156
181
|
Effect.gen(function* () {
|
|
157
182
|
yield* writeTasksJsonl(planDir, meta, tasks);
|
|
158
183
|
yield* saveHandoff(planDir, newHandoff);
|
|
@@ -176,21 +201,27 @@ export function registerRevisePlanTool(
|
|
|
176
201
|
}),
|
|
177
202
|
);
|
|
178
203
|
|
|
179
|
-
|
|
204
|
+
// Fail loudly if an old/stale taskman silently routed the write to cwd.
|
|
205
|
+
if (targetDir) await assertTargetReceived(targetDir, planDir);
|
|
206
|
+
|
|
207
|
+
// Author-only: only re-pin the active plan when revising in the current
|
|
208
|
+
// project (an external plan stays a local plan in its own repo).
|
|
209
|
+
if (!targetDir) callbacks.onPlanRevised(planDir, revised);
|
|
180
210
|
|
|
181
211
|
const changed = [
|
|
182
212
|
params.title ? 'title' : undefined,
|
|
183
213
|
params.handoff ? 'handoff' : undefined,
|
|
184
214
|
params.tasks ? 'tasks' : undefined,
|
|
185
215
|
].filter(Boolean);
|
|
216
|
+
const location = targetDir ? `${targetDir}/${planDir}` : planDir;
|
|
186
217
|
return {
|
|
187
218
|
content: [
|
|
188
219
|
{
|
|
189
220
|
type: 'text' as const,
|
|
190
|
-
text: `Plan "${newTitle}" revised (${changed.join(', ') || 'no changes'}) in ${
|
|
221
|
+
text: `Plan "${newTitle}" revised (${changed.join(', ') || 'no changes'}) in ${location}.`,
|
|
191
222
|
},
|
|
192
223
|
],
|
|
193
|
-
details: { planDir, plan: revised, changed },
|
|
224
|
+
details: { planDir, plan: revised, changed, target: targetDir },
|
|
194
225
|
};
|
|
195
226
|
},
|
|
196
227
|
|
|
@@ -12,8 +12,11 @@ import { upsertPlanEntry } from '@dreki-gg/taskman';
|
|
|
12
12
|
import { readInitiativesManifest } from '@dreki-gg/taskman';
|
|
13
13
|
import { reconcileInitiativeForPlan } from '@dreki-gg/taskman';
|
|
14
14
|
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
15
|
+
import { makePlanRuntime } from '@dreki-gg/taskman';
|
|
15
16
|
import { toKebabCase } from '@dreki-gg/taskman';
|
|
16
17
|
import { readHeadCommit } from '../git.js';
|
|
18
|
+
import { resolvePlanTarget, assertTargetReceived } from '../target.js';
|
|
19
|
+
import { detectPreconditionGaps, formatPreconditionRejection } from '../precondition-guard.js';
|
|
17
20
|
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
18
21
|
|
|
19
22
|
export interface SubmitPlanCallbacks {
|
|
@@ -75,15 +78,40 @@ export function registerSubmitPlanTool(
|
|
|
75
78
|
}),
|
|
76
79
|
),
|
|
77
80
|
),
|
|
81
|
+
target: Type.Optional(
|
|
82
|
+
Type.String({
|
|
83
|
+
description:
|
|
84
|
+
"Optional path to ANOTHER project's repo root (not its .plans/ dir). When set, the plan is filed into that project's .plans/ registry instead of the current one — useful when you find a gap in a package you author and want the plan to live (and later execute) there. Author-only: the plan is NOT pinned as the active plan in this session.",
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
78
87
|
}),
|
|
79
88
|
|
|
80
89
|
async execute(_toolCallId, params) {
|
|
90
|
+
// Precondition gate (deterministic, no LLM): reject destructive tasks that
|
|
91
|
+
// carry no proof command or auditable opt-out. Nothing is persisted on
|
|
92
|
+
// rejection — the agent fixes the tasks and re-calls submit_plan.
|
|
93
|
+
const gaps = detectPreconditionGaps(params.tasks);
|
|
94
|
+
if (gaps.length > 0) {
|
|
95
|
+
const details: Record<string, unknown> = { rejected: true, preconditionGaps: gaps };
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: 'text' as const, text: formatPreconditionRejection(gaps) }],
|
|
98
|
+
details,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Resolve an optional external target. When set, the plan is filed into
|
|
103
|
+
// that repo's .plans/ registry via a target-scoped runtime, its base
|
|
104
|
+
// commit comes from that repo's HEAD, and we do NOT pin it as this
|
|
105
|
+
// session's active plan (author-only).
|
|
106
|
+
const targetDir = await resolvePlanTarget(params.target);
|
|
107
|
+
const io: RunPlanIO = targetDir ? makePlanRuntime(targetDir) : runPlanIO;
|
|
108
|
+
|
|
81
109
|
const planName = toKebabCase(params.name);
|
|
82
110
|
const planDir = `.plans/${planName}`;
|
|
83
111
|
const initiative = params.initiative ? toKebabCase(params.initiative) : undefined;
|
|
84
112
|
const dependsOnPlans = params.depends_on_plans?.map(toKebabCase);
|
|
85
113
|
const now = new Date().toISOString();
|
|
86
|
-
const baseCommit = await readHeadCommit();
|
|
114
|
+
const baseCommit = await readHeadCommit(targetDir ?? process.cwd());
|
|
87
115
|
const meta: TaskMeta = {
|
|
88
116
|
_type: 'meta',
|
|
89
117
|
title: params.title,
|
|
@@ -109,7 +137,7 @@ export function registerSubmitPlanTool(
|
|
|
109
137
|
base_commit: baseCommit,
|
|
110
138
|
};
|
|
111
139
|
|
|
112
|
-
const unknownInitiative = await
|
|
140
|
+
const unknownInitiative = await io(
|
|
113
141
|
Effect.gen(function* () {
|
|
114
142
|
yield* writeTasksJsonl(planDir, meta, tasks);
|
|
115
143
|
yield* saveHandoff(planDir, params.handoff);
|
|
@@ -127,21 +155,26 @@ export function registerSubmitPlanTool(
|
|
|
127
155
|
}),
|
|
128
156
|
);
|
|
129
157
|
|
|
130
|
-
|
|
158
|
+
// Fail loudly if an old/stale taskman silently routed the write to cwd.
|
|
159
|
+
if (targetDir) await assertTargetReceived(targetDir, planDir);
|
|
160
|
+
|
|
161
|
+
// Author-only: only pin as the active plan when filed in the current
|
|
162
|
+
// project. A plan filed into an external repo is a first-class local plan
|
|
163
|
+
// *there* — pinning a non-local path here would corrupt session state.
|
|
164
|
+
if (!targetDir) callbacks.onPlanSubmitted(planDir, plan);
|
|
131
165
|
|
|
132
166
|
const linkSuffix = initiative
|
|
133
167
|
? ` Linked to initiative "${initiative}"${
|
|
134
168
|
unknownInitiative ? ' (no initiatives.jsonl entry yet — create it with submit_initiative)' : ''
|
|
135
169
|
}.`
|
|
136
170
|
: '';
|
|
171
|
+
const location = targetDir ? `${targetDir}/${planDir}` : planDir;
|
|
172
|
+
const text = targetDir
|
|
173
|
+
? `Plan "${params.title}" filed into ${location} with ${tasks.length} tasks. It is a local plan in that project — execute it from that directory.${linkSuffix}`
|
|
174
|
+
: `Plan "${params.title}" saved with ${tasks.length} tasks in ${planDir}. Execute when ready.${linkSuffix}`;
|
|
137
175
|
return {
|
|
138
|
-
content: [
|
|
139
|
-
|
|
140
|
-
type: 'text' as const,
|
|
141
|
-
text: `Plan "${params.title}" saved with ${tasks.length} tasks in ${planDir}. Execute when ready.${linkSuffix}`,
|
|
142
|
-
},
|
|
143
|
-
],
|
|
144
|
-
details: { planDir, plan, initiative, depends_on_plans: dependsOnPlans },
|
|
176
|
+
content: [{ type: 'text' as const, text }],
|
|
177
|
+
details: { planDir, plan, initiative, depends_on_plans: dependsOnPlans, target: targetDir },
|
|
145
178
|
};
|
|
146
179
|
},
|
|
147
180
|
|
|
@@ -1,27 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plan mode UI — status bar and task widget rendering.
|
|
3
|
+
*
|
|
4
|
+
* The plan-mode status bar entry is intentionally always cleared: any progress
|
|
5
|
+
* indicator would be derived from in-memory `state.plan`, which drifts from the
|
|
6
|
+
* source of truth on disk. The agent reads real status from `tasks.jsonl` via
|
|
7
|
+
* the `plan_status` tool instead of trusting a memory-rendered badge.
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
11
|
import type { PlanModeState } from './state.js';
|
|
7
12
|
|
|
8
|
-
export function updateUI(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const done = state.plan.tasks.filter((task) => task.status === 'done').length;
|
|
13
|
-
const total = state.plan.tasks.length;
|
|
14
|
-
ctx.ui.setStatus('plan-mode', theme.fg('accent', `📋 exec ${done}/${total}`));
|
|
15
|
-
} else if (state.plan && !state.planEnabled) {
|
|
16
|
-
const done = state.plan.tasks.filter((task) => task.status === 'done').length;
|
|
17
|
-
const total = state.plan.tasks.length;
|
|
18
|
-
ctx.ui.setStatus('plan-mode', theme.fg('muted', `📋 ${done}/${total}`));
|
|
19
|
-
} else if (state.planEnabled) {
|
|
20
|
-
ctx.ui.setStatus('plan-mode', theme.fg('warning', '📝 plan'));
|
|
21
|
-
} else {
|
|
22
|
-
ctx.ui.setStatus('plan-mode', undefined);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Task list lives in plan.jsonl — no widget needed.
|
|
13
|
+
export function updateUI(_state: PlanModeState, ctx: ExtensionContext): void {
|
|
14
|
+
// Never render plan state from memory — clear both the status entry and the
|
|
15
|
+
// (unused) task widget.
|
|
16
|
+
ctx.ui.setStatus('plan-mode', undefined);
|
|
26
17
|
ctx.ui.setWidget('plan-todos', undefined);
|
|
27
18
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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"
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@dreki-gg/pi-command-sandbox": "^0.3.0",
|
|
44
|
-
"@dreki-gg/taskman": "^0.
|
|
44
|
+
"@dreki-gg/taskman": "^0.4.0",
|
|
45
45
|
"effect": "^3.21.2"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
@@ -28,6 +28,7 @@ As soon as you understand the intent, write `.plans/<plan-name>/context.md` with
|
|
|
28
28
|
- **Constraints** — technical, product, or process limits that shape the work
|
|
29
29
|
- **Open questions** — anything unresolved; do not submit a plan with silent unknowns
|
|
30
30
|
- **Discarded options** — approaches considered and rejected, with why. This is the highest-value section and the one most often skipped.
|
|
31
|
+
- **Blast radius** (when the plan deletes/removes/renames/narrows anything) — for every symbol, export, file, or feature being removed, record the exact proof command (grep / ast-grep over *every* exported symbol name, not just the type or feature name) and its result. Scope by **feature, not directory**: trace each symbol upward to its callers, operations, commands, and settings. This evidence is what turns "delete, it's unused" from an assertion into a precondition the executor can re-run — see the precondition gate in the plan-mode prompt. A removal task without it will rot.
|
|
31
32
|
|
|
32
33
|
### 3. Style
|
|
33
34
|
|