@dreki-gg/pi-plan-mode 0.6.3 → 0.7.2
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 +34 -0
- package/extensions/plan-mode/index.ts +203 -250
- package/extensions/plan-mode/plans-json.ts +1 -5
- package/extensions/plan-mode/tools/submit-plan.ts +144 -0
- package/extensions/plan-mode/tools/update-step.ts +143 -0
- package/extensions/plan-mode/types.ts +25 -0
- package/extensions/plan-mode/utils.test.ts +6 -0
- package/extensions/plan-mode/utils.ts +1 -82
- package/package.json +2 -7
- package/node_modules/@dreki-gg/pi-command-sandbox/dist/index.d.mts +0 -104
- package/node_modules/@dreki-gg/pi-command-sandbox/dist/index.mjs +0 -396
- package/node_modules/@dreki-gg/pi-command-sandbox/package.json +0 -42
|
@@ -45,8 +45,4 @@ export function serializePlansJson(manifest: PlansManifest): string {
|
|
|
45
45
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
export function extractPlanTitle(planContent: string): string {
|
|
50
|
-
const match = planContent.match(/^#\s+(.+)$/m);
|
|
51
|
-
return match ? match[1].trim() : 'Untitled plan';
|
|
52
|
-
}
|
|
48
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* submit_plan tool — available during the plan phase.
|
|
3
|
+
*
|
|
4
|
+
* The planner calls this to submit a structured plan with typed steps.
|
|
5
|
+
* Writes `.plans/<name>/plan.json` and updates the plans.json manifest.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
10
|
+
import { Type } from 'typebox';
|
|
11
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { readPlansJson, serializePlansJson } from '../plans-json.js';
|
|
13
|
+
import { toKebabCase } from '../utils.js';
|
|
14
|
+
import type { PlanData, PlanStep } from '../types.js';
|
|
15
|
+
|
|
16
|
+
export interface SubmitPlanCallbacks {
|
|
17
|
+
onPlanSubmitted: (planDir: string, plan: PlanData) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCallbacks): void {
|
|
21
|
+
pi.registerTool({
|
|
22
|
+
name: 'submit_plan',
|
|
23
|
+
label: 'Submit Plan',
|
|
24
|
+
description:
|
|
25
|
+
'Submit a structured plan with a title, context, numbered steps, and risks. ' +
|
|
26
|
+
'Each step has a short description (for progress display) and detailed implementation instructions. ' +
|
|
27
|
+
'This finalizes the plan and writes it to .plans/<name>/plan.json.',
|
|
28
|
+
promptSnippet:
|
|
29
|
+
'Submit a structured plan with title, context, steps (description + details), and risks',
|
|
30
|
+
promptGuidelines: [
|
|
31
|
+
'Call submit_plan once when you have finished analyzing the codebase and are ready to finalize your plan.',
|
|
32
|
+
'Each submit_plan step description should be a short label (≤60 chars). Put full implementation instructions in the details field.',
|
|
33
|
+
'The submit_plan context field must include all codebase findings, relevant file paths, APIs, patterns, and constraints so an executor with zero prior context can implement the plan.',
|
|
34
|
+
],
|
|
35
|
+
parameters: Type.Object({
|
|
36
|
+
name: Type.String({
|
|
37
|
+
description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")',
|
|
38
|
+
}),
|
|
39
|
+
title: Type.String({ description: 'Human-readable plan title' }),
|
|
40
|
+
context: Type.String({
|
|
41
|
+
description:
|
|
42
|
+
'Complete codebase context: relevant file paths, APIs, patterns, constraints, and gotchas',
|
|
43
|
+
}),
|
|
44
|
+
steps: Type.Array(
|
|
45
|
+
Type.Object({
|
|
46
|
+
description: Type.String({
|
|
47
|
+
description: 'Short step label for progress display (≤60 chars)',
|
|
48
|
+
}),
|
|
49
|
+
details: Type.String({
|
|
50
|
+
description: 'Full implementation instructions for this step',
|
|
51
|
+
}),
|
|
52
|
+
}),
|
|
53
|
+
{ minItems: 1 },
|
|
54
|
+
),
|
|
55
|
+
risks: Type.String({ description: 'Open questions, assumptions, and risks' }),
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
async execute(_toolCallId, params) {
|
|
59
|
+
const planName = toKebabCase(params.name);
|
|
60
|
+
const planDir = `.plans/${planName}`;
|
|
61
|
+
|
|
62
|
+
const steps: PlanStep[] = params.steps.map((s) => ({
|
|
63
|
+
description: s.description.slice(0, 60),
|
|
64
|
+
details: s.details,
|
|
65
|
+
status: 'pending' as const,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
const plan: PlanData = {
|
|
69
|
+
title: params.title,
|
|
70
|
+
context: params.context,
|
|
71
|
+
steps,
|
|
72
|
+
risks: params.risks,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Write plan.json
|
|
76
|
+
await mkdir(planDir, { recursive: true });
|
|
77
|
+
await writeFile(`${planDir}/plan.json`, JSON.stringify(plan, null, 2) + '\n', 'utf-8');
|
|
78
|
+
|
|
79
|
+
// Update plans.json manifest
|
|
80
|
+
const manifest = await readPlansJson();
|
|
81
|
+
const now = new Date().toISOString();
|
|
82
|
+
manifest[planName] = {
|
|
83
|
+
status: 'in-progress',
|
|
84
|
+
title: params.title,
|
|
85
|
+
created: manifest[planName]?.created ?? now,
|
|
86
|
+
completed: null,
|
|
87
|
+
};
|
|
88
|
+
await writeFile('.plans/plans.json', serializePlansJson(manifest), 'utf-8');
|
|
89
|
+
|
|
90
|
+
callbacks.onPlanSubmitted(planDir, plan);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: 'text' as const,
|
|
96
|
+
text: `Plan "${params.title}" saved with ${steps.length} steps. Waiting for user to review and execute.`,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
details: { planDir, plan },
|
|
100
|
+
terminate: true,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
renderCall(args, theme) {
|
|
105
|
+
const name = (args as { name?: string }).name ?? 'plan';
|
|
106
|
+
const title = (args as { title?: string }).title ?? '';
|
|
107
|
+
let content = theme.fg('toolTitle', theme.bold('submit_plan '));
|
|
108
|
+
content += theme.fg('accent', name);
|
|
109
|
+
if (title) {
|
|
110
|
+
content += ' ' + theme.fg('dim', `"${title}"`);
|
|
111
|
+
}
|
|
112
|
+
return new Text(content, 0, 0);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
renderResult(result, _options, theme) {
|
|
116
|
+
const details = result.details as { plan?: PlanData } | undefined;
|
|
117
|
+
const plan = details?.plan;
|
|
118
|
+
if (!plan) {
|
|
119
|
+
return new Text(theme.fg('success', '✓ Plan saved'), 0, 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lines: string[] = [];
|
|
123
|
+
|
|
124
|
+
// Title
|
|
125
|
+
lines.push(theme.fg('success', '✓ ') + theme.fg('accent', theme.bold(plan.title)));
|
|
126
|
+
lines.push('');
|
|
127
|
+
|
|
128
|
+
// Steps
|
|
129
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
130
|
+
const step = plan.steps[i];
|
|
131
|
+
const num = theme.fg('muted', `${i + 1}.`);
|
|
132
|
+
lines.push(` ${num} ${step.description}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Risks
|
|
136
|
+
if (plan.risks) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push(theme.fg('warning', '⚠ Risks: ') + theme.fg('dim', plan.risks));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return new Text(lines.join('\n'), 0, 0);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update_step tool — available during the execution phase.
|
|
3
|
+
*
|
|
4
|
+
* The executor calls this to mark a plan step as done, skipped, or blocked.
|
|
5
|
+
* On "blocked", returns a stop signal so the executor pauses for user intervention.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { StringEnum } from '@earendil-works/pi-ai';
|
|
10
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
11
|
+
import { Type } from 'typebox';
|
|
12
|
+
import type { PlanData } from '../types.js';
|
|
13
|
+
|
|
14
|
+
export interface UpdateStepCallbacks {
|
|
15
|
+
getPlan: () => PlanData | undefined;
|
|
16
|
+
onStepUpdated: (step: number, status: 'done' | 'skipped' | 'blocked', notes?: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerUpdateStepTool(pi: ExtensionAPI, callbacks: UpdateStepCallbacks): void {
|
|
20
|
+
pi.registerTool({
|
|
21
|
+
name: 'update_step',
|
|
22
|
+
label: 'Update Step',
|
|
23
|
+
description:
|
|
24
|
+
'Mark a plan step as done, skipped, or blocked. ' +
|
|
25
|
+
'Call this after completing each step. ' +
|
|
26
|
+
'If a step is blocked, execution will pause for user intervention.',
|
|
27
|
+
promptSnippet: 'Mark a plan step as done, skipped, or blocked',
|
|
28
|
+
promptGuidelines: [
|
|
29
|
+
'Call update_step after completing each plan step to mark it done before moving to the next.',
|
|
30
|
+
'Use update_step with status "skipped" if a step is unnecessary after inspecting the code.',
|
|
31
|
+
'Use update_step with status "blocked" and explain the reason in notes if a step cannot be completed — execution will pause for user input.',
|
|
32
|
+
],
|
|
33
|
+
parameters: Type.Object({
|
|
34
|
+
step: Type.Number({ description: 'Step number (1-indexed)' }),
|
|
35
|
+
status: StringEnum(['done', 'skipped', 'blocked'] as const),
|
|
36
|
+
notes: Type.Optional(
|
|
37
|
+
Type.String({ description: 'What was done, why skipped, or why blocked' }),
|
|
38
|
+
),
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
async execute(_toolCallId, params) {
|
|
42
|
+
const plan = callbacks.getPlan();
|
|
43
|
+
if (!plan) {
|
|
44
|
+
throw new Error('No active plan. Cannot update step.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const stepIdx = params.step - 1;
|
|
48
|
+
if (stepIdx < 0 || stepIdx >= plan.steps.length) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Invalid step number ${params.step}. Plan has ${plan.steps.length} steps.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const step = plan.steps[stepIdx];
|
|
55
|
+
if (step.status !== 'pending') {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Step ${params.step} is already "${step.status}". Only pending steps can be updated.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
callbacks.onStepUpdated(params.step, params.status, params.notes);
|
|
62
|
+
|
|
63
|
+
const details = {
|
|
64
|
+
step: params.step,
|
|
65
|
+
status: params.status,
|
|
66
|
+
notes: params.notes,
|
|
67
|
+
description: step.description,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (params.status === 'blocked') {
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{
|
|
74
|
+
type: 'text' as const,
|
|
75
|
+
text: `Step ${params.step} blocked. Execution paused — waiting for user input.`,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
details,
|
|
79
|
+
terminate: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build progress info
|
|
84
|
+
const done = plan.steps.filter((s) => s.status === 'done').length;
|
|
85
|
+
const skipped = plan.steps.filter((s) => s.status === 'skipped').length;
|
|
86
|
+
const resolved = done + skipped;
|
|
87
|
+
|
|
88
|
+
const statusEmoji = params.status === 'done' ? '✓' : '⊘';
|
|
89
|
+
let text = `${statusEmoji} Step ${params.step} ${params.status}. Progress: ${resolved}/${plan.steps.length}`;
|
|
90
|
+
if (params.notes) {
|
|
91
|
+
text += ` — ${params.notes}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Find next pending step
|
|
95
|
+
const next = plan.steps.find((s) => s.status === 'pending');
|
|
96
|
+
if (next) {
|
|
97
|
+
const nextIdx = plan.steps.indexOf(next) + 1;
|
|
98
|
+
text += `\n\nNext step ${nextIdx}: ${next.description}`;
|
|
99
|
+
} else {
|
|
100
|
+
text += '\n\nAll steps resolved!';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: 'text' as const, text }],
|
|
105
|
+
details,
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
renderCall(args, theme) {
|
|
110
|
+
const step = (args as { step?: number }).step ?? '?';
|
|
111
|
+
const status = (args as { status?: string }).status ?? '';
|
|
112
|
+
let content = theme.fg('toolTitle', theme.bold('update_step '));
|
|
113
|
+
content += theme.fg('muted', `#${step}`);
|
|
114
|
+
if (status) {
|
|
115
|
+
const color = status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error';
|
|
116
|
+
content += ' ' + theme.fg(color, status);
|
|
117
|
+
}
|
|
118
|
+
return new Text(content, 0, 0);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
renderResult(result, _options, theme) {
|
|
122
|
+
const details = result.details as {
|
|
123
|
+
step?: number;
|
|
124
|
+
status?: string;
|
|
125
|
+
description?: string;
|
|
126
|
+
} | undefined;
|
|
127
|
+
|
|
128
|
+
if (!details) {
|
|
129
|
+
return new Text(theme.fg('dim', 'Updated'), 0, 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const statusMap: Record<string, string> = {
|
|
133
|
+
done: theme.fg('success', '✓'),
|
|
134
|
+
skipped: theme.fg('warning', '⊘'),
|
|
135
|
+
blocked: theme.fg('error', '✗'),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const icon = statusMap[details.status ?? ''] ?? '';
|
|
139
|
+
const desc = details.description ?? '';
|
|
140
|
+
return new Text(`${icon} Step ${details.step}: ${desc}`, 0, 0);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for plan mode.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface PlanStep {
|
|
6
|
+
description: string;
|
|
7
|
+
details: string;
|
|
8
|
+
status: 'pending' | 'done' | 'skipped' | 'blocked';
|
|
9
|
+
notes?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PlanData {
|
|
13
|
+
title: string;
|
|
14
|
+
context: string;
|
|
15
|
+
steps: PlanStep[];
|
|
16
|
+
risks: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PersistedState {
|
|
20
|
+
planEnabled: boolean;
|
|
21
|
+
executing: boolean;
|
|
22
|
+
planDir: string | undefined;
|
|
23
|
+
plan: PlanData | undefined;
|
|
24
|
+
executionStartIdx: number | undefined;
|
|
25
|
+
}
|
|
@@ -12,6 +12,12 @@ describe('isSafeCommand', () => {
|
|
|
12
12
|
expect(isSafeCommand('mkdir .plans/my-plan')).toBe(true);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
test('command with 2>/dev/null stderr redirect', () => {
|
|
16
|
+
expect(
|
|
17
|
+
isSafeCommand('cat .release-please-manifest.json 2>/dev/null; echo "---"; cat release-please-config.json 2>/dev/null'),
|
|
18
|
+
).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
15
21
|
test('curl with 2>/dev/null pipe chain', () => {
|
|
16
22
|
expect(
|
|
17
23
|
isSafeCommand(
|
|
@@ -6,12 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import { isSafeCommand as baseSafeCommand } from '@dreki-gg/pi-command-sandbox';
|
|
8
8
|
|
|
9
|
-
export interface TodoItem {
|
|
10
|
-
step: number;
|
|
11
|
-
text: string;
|
|
12
|
-
completed: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
9
|
/**
|
|
16
10
|
* Check if a command is safe for plan mode.
|
|
17
11
|
*
|
|
@@ -20,7 +14,7 @@ export interface TodoItem {
|
|
|
20
14
|
*/
|
|
21
15
|
export function isSafeCommand(command: string): boolean {
|
|
22
16
|
return baseSafeCommand(command, {
|
|
23
|
-
allowCommand: (cmd) => isMkdirPlans(cmd)
|
|
17
|
+
allowCommand: (cmd) => isMkdirPlans(cmd),
|
|
24
18
|
});
|
|
25
19
|
}
|
|
26
20
|
|
|
@@ -29,81 +23,6 @@ function isMkdirPlans(command: string): boolean {
|
|
|
29
23
|
return /^\s*mkdir\s+(-p\s+)?\.plans(\/|\\|\s|$)/.test(command);
|
|
30
24
|
}
|
|
31
25
|
|
|
32
|
-
/**
|
|
33
|
-
* Allow curl commands that only redirect stderr to /dev/null.
|
|
34
|
-
* shell-quote parses `2>/dev/null` as a stdout redirect, but it's
|
|
35
|
-
* actually a stderr redirect which is safe for read-only mode.
|
|
36
|
-
*/
|
|
37
|
-
function isCurlWithStderrRedirect(command: string): boolean {
|
|
38
|
-
return (
|
|
39
|
-
/^\s*curl\b/.test(command) &&
|
|
40
|
-
/2>\/dev\/null/.test(command) &&
|
|
41
|
-
!/>(?!\/dev\/null)/.test(command.replace(/2>\/dev\/null/g, ''))
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ── Plan extraction ─────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
export function cleanStepText(text: string): string {
|
|
48
|
-
let cleaned = text
|
|
49
|
-
.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1')
|
|
50
|
-
.replace(/`([^`]+)`/g, '$1')
|
|
51
|
-
.replace(/\s+/g, ' ')
|
|
52
|
-
.trim();
|
|
53
|
-
|
|
54
|
-
if (cleaned.length > 0) {
|
|
55
|
-
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
|
56
|
-
}
|
|
57
|
-
if (cleaned.length > 60) {
|
|
58
|
-
cleaned = `${cleaned.slice(0, 57)}...`;
|
|
59
|
-
}
|
|
60
|
-
return cleaned;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function extractTodoItems(message: string): TodoItem[] {
|
|
64
|
-
const items: TodoItem[] = [];
|
|
65
|
-
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
|
|
66
|
-
if (!headerMatch) return items;
|
|
67
|
-
|
|
68
|
-
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
|
|
69
|
-
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
|
70
|
-
|
|
71
|
-
for (const match of planSection.matchAll(numberedPattern)) {
|
|
72
|
-
const text = match[2]
|
|
73
|
-
.trim()
|
|
74
|
-
.replace(/\*{1,2}$/, '')
|
|
75
|
-
.trim();
|
|
76
|
-
|
|
77
|
-
if (text.length <= 5) continue;
|
|
78
|
-
if (text.startsWith('`') || text.startsWith('/') || text.startsWith('-')) continue;
|
|
79
|
-
|
|
80
|
-
const cleaned = cleanStepText(text);
|
|
81
|
-
if (cleaned.length <= 3) continue;
|
|
82
|
-
|
|
83
|
-
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return items;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function extractDoneSteps(message: string): number[] {
|
|
90
|
-
const steps: number[] = [];
|
|
91
|
-
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
|
|
92
|
-
const step = Number(match[1]);
|
|
93
|
-
if (Number.isFinite(step)) steps.push(step);
|
|
94
|
-
}
|
|
95
|
-
return steps;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
|
99
|
-
const doneSteps = extractDoneSteps(text);
|
|
100
|
-
for (const step of doneSteps) {
|
|
101
|
-
const item = items.find((t) => t.step === step);
|
|
102
|
-
if (item) item.completed = true;
|
|
103
|
-
}
|
|
104
|
-
return doneSteps.length;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
26
|
// ── Plan name utilities ─────────────────────────────────────────────────────
|
|
108
27
|
|
|
109
28
|
export function toKebabCase(name: string): string {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.2",
|
|
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"
|
|
@@ -24,8 +24,6 @@
|
|
|
24
24
|
"package.json"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"prepack": "node --experimental-strip-types scripts/prepack.js",
|
|
28
|
-
"postpack": "rm -rf node_modules/@dreki-gg",
|
|
29
27
|
"typecheck": "tsc --noEmit",
|
|
30
28
|
"lint": "oxlint extensions bin",
|
|
31
29
|
"format": "oxfmt --write extensions bin",
|
|
@@ -36,11 +34,8 @@
|
|
|
36
34
|
"./extensions/plan-mode"
|
|
37
35
|
]
|
|
38
36
|
},
|
|
39
|
-
"bundledDependencies": [
|
|
40
|
-
"@dreki-gg/pi-command-sandbox"
|
|
41
|
-
],
|
|
42
37
|
"dependencies": {
|
|
43
|
-
"@dreki-gg/pi-command-sandbox": "0.
|
|
38
|
+
"@dreki-gg/pi-command-sandbox": "^0.2.0"
|
|
44
39
|
},
|
|
45
40
|
"devDependencies": {
|
|
46
41
|
"@types/node": "24",
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
//#region src/sandbox.d.ts
|
|
2
|
-
/**
|
|
3
|
-
* Core sandbox logic — determines whether a shell command is safe to execute.
|
|
4
|
-
*/
|
|
5
|
-
interface SandboxOptions {
|
|
6
|
-
/**
|
|
7
|
-
* Extra patterns to allow beyond the built-in SAFE_PATTERNS.
|
|
8
|
-
* Checked against the reconstructed command string for each segment.
|
|
9
|
-
*/
|
|
10
|
-
extraSafe?: RegExp[];
|
|
11
|
-
/**
|
|
12
|
-
* Extra patterns to block beyond the built-in DESTRUCTIVE_PATTERNS.
|
|
13
|
-
* Checked against the reconstructed command string for each segment.
|
|
14
|
-
*/
|
|
15
|
-
extraDestructive?: RegExp[];
|
|
16
|
-
/**
|
|
17
|
-
* Custom predicate to allow specific full commands before normal checks.
|
|
18
|
-
* Return `true` to allow the command, `false` to continue with normal checks.
|
|
19
|
-
*/
|
|
20
|
-
allowCommand?: (command: string) => boolean;
|
|
21
|
-
/**
|
|
22
|
-
* Whether to allow stdout redirects (`>`, `>>`).
|
|
23
|
-
* Default: false (blocked).
|
|
24
|
-
*/
|
|
25
|
-
allowRedirects?: boolean;
|
|
26
|
-
/**
|
|
27
|
-
* Whether to allow command substitution ($(...) and backticks).
|
|
28
|
-
* Default: false (blocked).
|
|
29
|
-
*/
|
|
30
|
-
allowCommandSubstitution?: boolean;
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Check if a shell command is safe to execute in a sandboxed mode.
|
|
34
|
-
*
|
|
35
|
-
* The command is parsed into segments using shell-quote, and each segment
|
|
36
|
-
* is checked independently against the destructive and safe pattern lists.
|
|
37
|
-
* A command is safe only if ALL segments pass.
|
|
38
|
-
*
|
|
39
|
-
* For a segment to pass:
|
|
40
|
-
* 1. It must NOT match any destructive pattern
|
|
41
|
-
* 2. It MUST match at least one safe pattern
|
|
42
|
-
* 3. It must NOT contain redirects (unless explicitly allowed)
|
|
43
|
-
*
|
|
44
|
-
* Additionally, command substitution ($() and backticks) is blocked by default.
|
|
45
|
-
*/
|
|
46
|
-
declare function isSafeCommand(command: string, options?: SandboxOptions): boolean;
|
|
47
|
-
//#endregion
|
|
48
|
-
//#region src/parser.d.ts
|
|
49
|
-
/**
|
|
50
|
-
* Shell command parser using shell-quote.
|
|
51
|
-
*
|
|
52
|
-
* Splits a full shell command string into individual command segments,
|
|
53
|
-
* properly handling &&, ||, ;, |, pipes, quotes, and subshells.
|
|
54
|
-
*/
|
|
55
|
-
interface ParsedSegment {
|
|
56
|
-
/** The reconstructed command string for this segment. */
|
|
57
|
-
command: string;
|
|
58
|
-
/** Whether this segment contains a redirect operator. */
|
|
59
|
-
hasRedirect: boolean;
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Parse a shell command into individual command segments.
|
|
63
|
-
*
|
|
64
|
-
* Uses shell-quote to properly tokenize the input, then splits on
|
|
65
|
-
* command separators (&&, ||, ;, |). Each segment is returned as a
|
|
66
|
-
* reconstructed command string.
|
|
67
|
-
*
|
|
68
|
-
* Subshell grouping operators `(` and `)` are stripped — the inner
|
|
69
|
-
* commands are still validated individually.
|
|
70
|
-
*
|
|
71
|
-
* @example
|
|
72
|
-
* ```ts
|
|
73
|
-
* parseCommandSegments('cd foo && ls -la')
|
|
74
|
-
* // → [{ command: 'cd foo', hasRedirect: false },
|
|
75
|
-
* // { command: 'ls -la', hasRedirect: false }]
|
|
76
|
-
*
|
|
77
|
-
* parseCommandSegments('echo "hello && world"')
|
|
78
|
-
* // → [{ command: 'echo "hello && world"', hasRedirect: false }]
|
|
79
|
-
*
|
|
80
|
-
* parseCommandSegments('curl -s url 2>/dev/null | head')
|
|
81
|
-
* // → [{ command: 'curl -s url 2>/dev/null', hasRedirect: false },
|
|
82
|
-
* // { command: 'head', hasRedirect: false }]
|
|
83
|
-
* ```
|
|
84
|
-
*/
|
|
85
|
-
declare function parseCommandSegments(input: string): ParsedSegment[];
|
|
86
|
-
/**
|
|
87
|
-
* Check if the parsed tokens contain command substitution patterns.
|
|
88
|
-
*
|
|
89
|
-
* Command substitution ($(...) or `...`) can hide arbitrary commands
|
|
90
|
-
* and should be blocked in sandboxed modes.
|
|
91
|
-
*/
|
|
92
|
-
declare function hasCommandSubstitution(input: string): boolean;
|
|
93
|
-
//#endregion
|
|
94
|
-
//#region src/patterns.d.ts
|
|
95
|
-
/**
|
|
96
|
-
* Destructive and safe command patterns for shell sandboxing.
|
|
97
|
-
*
|
|
98
|
-
* DESTRUCTIVE_PATTERNS — if any segment matches, the command is blocked.
|
|
99
|
-
* SAFE_PATTERNS — a segment must match at least one to be allowed.
|
|
100
|
-
*/
|
|
101
|
-
declare const DESTRUCTIVE_PATTERNS: RegExp[];
|
|
102
|
-
declare const SAFE_PATTERNS: RegExp[];
|
|
103
|
-
//#endregion
|
|
104
|
-
export { DESTRUCTIVE_PATTERNS, type ParsedSegment, SAFE_PATTERNS, type SandboxOptions, hasCommandSubstitution, isSafeCommand, parseCommandSegments };
|