@dreki-gg/pi-plan-mode 0.27.3 → 0.28.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 +16 -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 +42 -1
- package/extensions/plan-mode/precondition-guard.ts +83 -0
- package/extensions/plan-mode/prompts.ts +5 -0
- package/extensions/plan-mode/tools/submit-plan.ts +13 -0
- package/package.json +1 -1
- package/skills/planning-context/SKILL.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.28.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add a precondition gate that stops "delete X, it's unused" plan drift. Any task
|
|
8
|
+
that deletes, removes, renames, or narrows a codebase symbol/export/file/feature
|
|
9
|
+
must now carry a re-runnable proof of its premise — a `Proof: <command>` line
|
|
10
|
+
(grep/ast-grep over every exported symbol, scoped by feature not directory) or
|
|
11
|
+
an explicit `Precondition: none — <reason>` opt-out. The rule is hoisted to the
|
|
12
|
+
top of the planner prompt and enforced deterministically: `submit_plan` refuses
|
|
13
|
+
to persist a plan whose destructive tasks lack proof, naming the offending task
|
|
14
|
+
ids so you can fix and resubmit. No LLM call, no reviewer side effect. At
|
|
15
|
+
execution time the proof is re-run, and a contradicted premise (live consumers,
|
|
16
|
+
a delete that breaks an import) blocks instead of being silently improvised
|
|
17
|
+
around.
|
|
18
|
+
|
|
3
19
|
## 0.27.3
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
|
@@ -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:');
|
|
@@ -17,7 +17,7 @@ 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
23
|
}
|
|
@@ -58,6 +58,47 @@ afterEach(async () => {
|
|
|
58
58
|
await rm(dir, { recursive: true, force: true });
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
describe('submit_plan tool — precondition gate', () => {
|
|
62
|
+
test('rejects a destructive task with no proof and persists nothing', async () => {
|
|
63
|
+
const tool = setup();
|
|
64
|
+
const res = await tool.execute(
|
|
65
|
+
'c',
|
|
66
|
+
baseParams({ tasks: [{ id: 't-001', description: 'Delete the `AuthProvider` export' }] }),
|
|
67
|
+
);
|
|
68
|
+
expect(res.content?.[0]?.text).toContain('precondition gate failed');
|
|
69
|
+
expect((res.details as { rejected?: boolean }).rejected).toBe(true);
|
|
70
|
+
// Nothing written to the registry.
|
|
71
|
+
const entries = await runPlanIO(readPlansManifest());
|
|
72
|
+
expect(entries).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('accepts a destructive task that carries a proof command', async () => {
|
|
76
|
+
const tool = setup();
|
|
77
|
+
const res = await tool.execute(
|
|
78
|
+
'c',
|
|
79
|
+
baseParams({
|
|
80
|
+
tasks: [
|
|
81
|
+
{
|
|
82
|
+
id: 't-001',
|
|
83
|
+
description: 'Delete the `AuthProvider` export',
|
|
84
|
+
details: 'Proof: rg "AuthProvider" -l → no consumers',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
expect((res.details as { rejected?: boolean }).rejected).toBeUndefined();
|
|
90
|
+
const entries = await runPlanIO(readPlansManifest());
|
|
91
|
+
expect(entries).toHaveLength(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('accepts a non-destructive task untouched', async () => {
|
|
95
|
+
const tool = setup();
|
|
96
|
+
await tool.execute('c', baseParams());
|
|
97
|
+
const entries = await runPlanIO(readPlansManifest());
|
|
98
|
+
expect(entries).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
61
102
|
describe('submit_plan tool — initiative + plan deps', () => {
|
|
62
103
|
test('persists initiative + depends_on onto the plan manifest entry', async () => {
|
|
63
104
|
await runPlanIO(upsertInitiativeEntry('auth-overhaul', { status: 'in-progress', title: 'Auth' }));
|
|
@@ -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
|
|
@@ -14,6 +14,7 @@ import { reconcileInitiativeForPlan } from '@dreki-gg/taskman';
|
|
|
14
14
|
import type { RunPlanIO } from '@dreki-gg/taskman';
|
|
15
15
|
import { toKebabCase } from '@dreki-gg/taskman';
|
|
16
16
|
import { readHeadCommit } from '../git.js';
|
|
17
|
+
import { detectPreconditionGaps, formatPreconditionRejection } from '../precondition-guard.js';
|
|
17
18
|
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
18
19
|
|
|
19
20
|
export interface SubmitPlanCallbacks {
|
|
@@ -78,6 +79,18 @@ export function registerSubmitPlanTool(
|
|
|
78
79
|
}),
|
|
79
80
|
|
|
80
81
|
async execute(_toolCallId, params) {
|
|
82
|
+
// Precondition gate (deterministic, no LLM): reject destructive tasks that
|
|
83
|
+
// carry no proof command or auditable opt-out. Nothing is persisted on
|
|
84
|
+
// rejection — the agent fixes the tasks and re-calls submit_plan.
|
|
85
|
+
const gaps = detectPreconditionGaps(params.tasks);
|
|
86
|
+
if (gaps.length > 0) {
|
|
87
|
+
const details: Record<string, unknown> = { rejected: true, preconditionGaps: gaps };
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: 'text' as const, text: formatPreconditionRejection(gaps) }],
|
|
90
|
+
details,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
const planName = toKebabCase(params.name);
|
|
82
95
|
const planDir = `.plans/${planName}`;
|
|
83
96
|
const initiative = params.initiative ? toKebabCase(params.initiative) : undefined;
|
package/package.json
CHANGED
|
@@ -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
|
|