@hank-warren/pi-plan-mode 0.1.0 → 1.0.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/NOTICE.md +4 -0
- package/README.md +60 -246
- package/package.json +3 -4
- package/src/active-implementation-menu.ts +4 -3
- package/src/command.ts +4 -10
- package/src/completion-tool.ts +3 -1
- package/src/fresh-implementation.ts +19 -26
- package/src/interactive-ui.ts +0 -1
- package/src/plan-action-controller.ts +5 -29
- package/src/plan-action-menus.ts +16 -23
- package/src/plan-export.ts +9 -7
- package/src/plan-file.ts +85 -0
- package/src/plan-launch-menu.ts +18 -78
- package/src/plan-mode.ts +126 -505
- package/src/presentation.ts +21 -40
- package/src/prompt.ts +15 -12
- package/src/settings-menu.ts +4 -181
- package/src/settings.ts +6 -105
- package/src/state.ts +22 -118
- package/src/auto-permissions-delegation.ts +0 -122
- package/src/implementation-retention.ts +0 -122
- package/src/message-transform.ts +0 -232
- package/src/required-tools.ts +0 -22
- package/src/saved-plan-menu.ts +0 -93
- package/src/saved-plan-preflight.ts +0 -39
- package/src/tool-policy.ts +0 -563
- package/src/tool-selection.ts +0 -98
package/src/state.ts
CHANGED
|
@@ -1,39 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
normalizePlanModeCompletion,
|
|
3
|
-
PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
4
|
-
planFromCompletionDetails,
|
|
5
|
-
} from "./completion-tool.js";
|
|
6
|
-
import {
|
|
7
|
-
IMPLEMENTATION_PLAN_RETENTIONS,
|
|
8
|
-
type ImplementationPlanRetention,
|
|
9
|
-
PLAN_MODE_THINKING_LEVELS,
|
|
10
|
-
type PlanModeFixedThinkingLevel,
|
|
11
|
-
} from "./settings.js";
|
|
12
|
-
|
|
13
|
-
export type PlanCompletionSource = typeof PLAN_MODE_COMPLETE_TOOL_NAME | "legacy_proposed_plan";
|
|
14
|
-
|
|
15
|
-
export interface ActiveImplementationPlan {
|
|
16
|
-
id: string;
|
|
17
|
-
plan: string;
|
|
18
|
-
source: PlanCompletionSource;
|
|
19
|
-
startedAt: number;
|
|
20
|
-
retention?: ImplementationPlanRetention;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface SavedPlan {
|
|
24
|
-
plan: string;
|
|
25
|
-
source: PlanCompletionSource;
|
|
26
|
-
}
|
|
1
|
+
import { PLAN_MODE_THINKING_LEVELS, type PlanModeFixedThinkingLevel } from "./settings.js";
|
|
27
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The plan lives on disk, so session state carries only a pointer to it plus
|
|
5
|
+
* the thinking-level bookkeeping needed to restore the user's level on exit.
|
|
6
|
+
*/
|
|
28
7
|
export interface PlanModeState {
|
|
29
8
|
enabled: boolean;
|
|
30
|
-
|
|
31
|
-
|
|
9
|
+
/** Absolute path to the durable plan file, once a plan has been written. */
|
|
10
|
+
planPath?: string;
|
|
11
|
+
/** A completed plan is waiting for the user to choose how to proceed. */
|
|
32
12
|
awaitingAction: boolean;
|
|
33
|
-
savedPlan?: SavedPlan;
|
|
34
|
-
activeImplementation?: ActiveImplementationPlan;
|
|
35
|
-
selectedToolNames?: string[];
|
|
36
|
-
selectedToolKeys?: string[];
|
|
37
13
|
previousThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
38
14
|
appliedThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
39
15
|
manualThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
@@ -43,49 +19,26 @@ type SessionEntry = {
|
|
|
43
19
|
type?: string;
|
|
44
20
|
customType?: string;
|
|
45
21
|
data?: unknown;
|
|
46
|
-
message?: {
|
|
47
|
-
role?: string;
|
|
48
|
-
toolName?: string;
|
|
49
|
-
details?: unknown;
|
|
50
|
-
};
|
|
51
22
|
};
|
|
52
23
|
|
|
53
24
|
export function restorePlanModeState(entries: unknown[], stateEntryType: string): PlanModeState {
|
|
54
25
|
const branch = entries as SessionEntry[];
|
|
55
|
-
let
|
|
26
|
+
let entry: SessionEntry | undefined;
|
|
56
27
|
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
57
28
|
const candidate = branch[index];
|
|
58
29
|
if (candidate?.type === "custom" && candidate.customType === stateEntryType) {
|
|
59
|
-
|
|
30
|
+
entry = candidate;
|
|
60
31
|
break;
|
|
61
32
|
}
|
|
62
33
|
}
|
|
63
|
-
const entry = branch[stateEntryIndex];
|
|
64
34
|
if (!isRecord(entry?.data)) return { enabled: false, awaitingAction: false };
|
|
65
35
|
|
|
66
36
|
const enabled = entry.data.enabled === true;
|
|
67
|
-
const
|
|
68
|
-
const persistedPlan = enabled ? normalizePersistedPlan(entry.data.latestPlan) : undefined;
|
|
69
|
-
const recoveredPlan =
|
|
70
|
-
enabled && !persistedPlan ? latestCompletionPlan(branch.slice(stateEntryIndex + 1)) : undefined;
|
|
71
|
-
const latestPlan = persistedPlan ?? recoveredPlan;
|
|
72
|
-
const activeImplementation = enabled
|
|
73
|
-
? undefined
|
|
74
|
-
: normalizeActiveImplementation(entry.data.activeImplementation);
|
|
75
|
-
const savedPlan =
|
|
76
|
-
enabled || activeImplementation ? undefined : normalizeSavedPlan(entry.data.savedPlan);
|
|
37
|
+
const planPath = absolutePath(entry.data.planPath);
|
|
77
38
|
return {
|
|
78
39
|
enabled,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
? ((persistedPlan ? persistedSource : undefined) ??
|
|
82
|
-
(recoveredPlan ? PLAN_MODE_COMPLETE_TOOL_NAME : undefined))
|
|
83
|
-
: undefined,
|
|
84
|
-
awaitingAction: enabled && latestPlan !== undefined,
|
|
85
|
-
savedPlan,
|
|
86
|
-
activeImplementation,
|
|
87
|
-
selectedToolNames: stringArray(entry.data.selectedToolNames),
|
|
88
|
-
selectedToolKeys: stringArray(entry.data.selectedToolKeys),
|
|
40
|
+
planPath,
|
|
41
|
+
awaitingAction: enabled && entry.data.awaitingAction === true && planPath !== undefined,
|
|
89
42
|
previousThinkingLevel: enabled
|
|
90
43
|
? fixedThinkingLevel(entry.data.previousThinkingLevel)
|
|
91
44
|
: undefined,
|
|
@@ -94,58 +47,15 @@ export function restorePlanModeState(entries: unknown[], stateEntryType: string)
|
|
|
94
47
|
};
|
|
95
48
|
}
|
|
96
49
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (!isRecord(value)) return undefined;
|
|
107
|
-
const id =
|
|
108
|
-
typeof value.id === "string" && /^[A-Za-z0-9._:-]{1,128}$/u.test(value.id)
|
|
109
|
-
? value.id
|
|
110
|
-
: undefined;
|
|
111
|
-
const source = planCompletionSource(value.source);
|
|
112
|
-
const normalized = normalizePlanModeCompletion({ plan: value.plan });
|
|
113
|
-
const startedAt =
|
|
114
|
-
typeof value.startedAt === "number" &&
|
|
115
|
-
Number.isSafeInteger(value.startedAt) &&
|
|
116
|
-
value.startedAt >= 0
|
|
117
|
-
? value.startedAt
|
|
118
|
-
: undefined;
|
|
119
|
-
if (!id || !source || !normalized.ok || startedAt === undefined) return undefined;
|
|
120
|
-
const retention = IMPLEMENTATION_PLAN_RETENTIONS.includes(
|
|
121
|
-
value.retention as ImplementationPlanRetention,
|
|
122
|
-
)
|
|
123
|
-
? (value.retention as ImplementationPlanRetention)
|
|
124
|
-
: "keep";
|
|
125
|
-
return { id, plan: normalized.plan, source, startedAt, retention };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function normalizePersistedPlan(value: unknown) {
|
|
129
|
-
const normalized = normalizePlanModeCompletion({ plan: value });
|
|
130
|
-
return normalized.ok ? normalized.plan : undefined;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function latestCompletionPlan(entries: SessionEntry[]) {
|
|
134
|
-
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
135
|
-
const message = entries[index]?.message;
|
|
136
|
-
if (message?.role !== "toolResult" || message.toolName !== PLAN_MODE_COMPLETE_TOOL_NAME) {
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
const plan = planFromCompletionDetails(message.details);
|
|
140
|
-
if (plan) return plan;
|
|
141
|
-
}
|
|
142
|
-
return undefined;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function planCompletionSource(value: unknown): PlanCompletionSource | undefined {
|
|
146
|
-
return value === PLAN_MODE_COMPLETE_TOOL_NAME || value === "legacy_proposed_plan"
|
|
147
|
-
? value
|
|
148
|
-
: undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Persisted paths are only trusted when they are absolute and free of NUL, so
|
|
52
|
+
* malformed state can never redirect a read or a delete to a relative target.
|
|
53
|
+
*/
|
|
54
|
+
function absolutePath(value: unknown) {
|
|
55
|
+
if (typeof value !== "string") return undefined;
|
|
56
|
+
const normalized = value.trim();
|
|
57
|
+
if (!normalized || normalized.includes("\0") || !normalized.startsWith("/")) return undefined;
|
|
58
|
+
return normalized;
|
|
149
59
|
}
|
|
150
60
|
|
|
151
61
|
function fixedThinkingLevel(value: unknown): PlanModeFixedThinkingLevel | undefined {
|
|
@@ -156,12 +66,6 @@ function fixedThinkingLevel(value: unknown): PlanModeFixedThinkingLevel | undefi
|
|
|
156
66
|
: undefined;
|
|
157
67
|
}
|
|
158
68
|
|
|
159
|
-
function stringArray(value: unknown) {
|
|
160
|
-
return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
|
|
161
|
-
? Array.from(new Set(value))
|
|
162
|
-
: undefined;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
69
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
166
70
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
167
71
|
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { CONFIG_DIR_NAME, getAgentDir, type ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { resolve, join } from "node:path";
|
|
4
|
-
|
|
5
|
-
const AUTO_PERMISSIONS_CONFIG = "pi-auto-permissions/config.json";
|
|
6
|
-
const AUTO_PERMISSIONS_TOOL = "request_override";
|
|
7
|
-
const AUTO_PERMISSIONS_SOURCE_MARKERS = [
|
|
8
|
-
"/@ogulcancelik/pi-auto-permissions/",
|
|
9
|
-
"/@hank-warren/pi-auto-permissions/",
|
|
10
|
-
"/packages/pi-auto-permissions/",
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
interface DelegationContext {
|
|
14
|
-
tools: readonly ToolInfo[];
|
|
15
|
-
trustedGroups: ReadonlySet<string>;
|
|
16
|
-
configPath?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface GuardedRule {
|
|
20
|
-
pattern: RegExp;
|
|
21
|
-
group: string;
|
|
22
|
-
level: "guarded" | "convention";
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Fail-closed compatibility check for @ogulcancelik/pi-auto-permissions 0.1.x
|
|
27
|
-
* and compatible forks (@hank-warren/pi-auto-permissions). Plan mode stands
|
|
28
|
-
* down only when that extension is loaded and its current configuration will
|
|
29
|
-
* guard this exact command.
|
|
30
|
-
*/
|
|
31
|
-
export function shouldDelegateBashToAutoPermissions(
|
|
32
|
-
command: string,
|
|
33
|
-
context: DelegationContext,
|
|
34
|
-
): boolean {
|
|
35
|
-
if (!autoPermissionsIsLoaded(context.tools)) return false;
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const config = readConfig(context.configPath);
|
|
39
|
-
if (!config.enabled) return false;
|
|
40
|
-
return config.rules.some((rule) => {
|
|
41
|
-
if (rule.level !== "guarded" || context.trustedGroups.has(rule.group)) return false;
|
|
42
|
-
rule.pattern.lastIndex = 0;
|
|
43
|
-
return rule.pattern.test(command);
|
|
44
|
-
});
|
|
45
|
-
} catch {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function autoPermissionsIsLoaded(tools: readonly ToolInfo[]): boolean {
|
|
51
|
-
const tool = tools.find((candidate) => candidate.name === AUTO_PERMISSIONS_TOOL);
|
|
52
|
-
const path = tool?.sourceInfo?.path?.replaceAll("\\", "/");
|
|
53
|
-
if (!path) return false;
|
|
54
|
-
return AUTO_PERMISSIONS_SOURCE_MARKERS.some((marker) => path.includes(marker));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function readConfig(configPath?: string) {
|
|
58
|
-
const path = configPath
|
|
59
|
-
? resolve(configPath)
|
|
60
|
-
: process.env.PI_AUTO_PERMISSIONS_CONFIG
|
|
61
|
-
? resolve(process.env.PI_AUTO_PERMISSIONS_CONFIG)
|
|
62
|
-
: join(getAgentDir(), AUTO_PERMISSIONS_CONFIG);
|
|
63
|
-
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
64
|
-
if (!isRecord(raw)) throw new Error("Auto Permissions config must be an object");
|
|
65
|
-
if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") {
|
|
66
|
-
throw new Error("Auto Permissions enabled must be boolean");
|
|
67
|
-
}
|
|
68
|
-
if (raw.rules !== undefined && !Array.isArray(raw.rules)) {
|
|
69
|
-
throw new Error("Auto Permissions rules must be an array");
|
|
70
|
-
}
|
|
71
|
-
return {
|
|
72
|
-
enabled: raw.enabled !== false,
|
|
73
|
-
rules: (raw.rules ?? []).map(compileRule),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function compileRule(value: unknown, index: number): GuardedRule {
|
|
78
|
-
if (!isRecord(value)) throw new Error(`Auto Permissions rules[${index}] must be an object`);
|
|
79
|
-
const pattern = nonEmptyString(value.pattern);
|
|
80
|
-
const group = nonEmptyString(value.group);
|
|
81
|
-
const label = nonEmptyString(value.label);
|
|
82
|
-
if (!pattern || !group || !label) {
|
|
83
|
-
throw new Error(`Auto Permissions rules[${index}] is incomplete`);
|
|
84
|
-
}
|
|
85
|
-
const flags = value.flags ?? "i";
|
|
86
|
-
if (typeof flags !== "string") throw new Error(`Auto Permissions rules[${index}] flags invalid`);
|
|
87
|
-
const level = value.level ?? "guarded";
|
|
88
|
-
if (level !== "guarded" && level !== "convention") {
|
|
89
|
-
throw new Error(`Auto Permissions rules[${index}] level invalid`);
|
|
90
|
-
}
|
|
91
|
-
const message = nonEmptyString(value.message);
|
|
92
|
-
if (value.message !== undefined && !message) {
|
|
93
|
-
throw new Error(`Auto Permissions rules[${index}] message invalid`);
|
|
94
|
-
}
|
|
95
|
-
if (level === "convention" && !message) {
|
|
96
|
-
throw new Error(`Auto Permissions rules[${index}] convention message required`);
|
|
97
|
-
}
|
|
98
|
-
return { pattern: new RegExp(pattern, flags), group, level };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function snapshotAutoPermissionsTrustedGroups(cwd: string, projectTrusted: boolean) {
|
|
102
|
-
const groups = new Set<string>();
|
|
103
|
-
if (!projectTrusted) return groups;
|
|
104
|
-
try {
|
|
105
|
-
const content = readFileSync(join(cwd, CONFIG_DIR_NAME, "trusted-ops"), "utf8");
|
|
106
|
-
for (const line of content.split("\n")) {
|
|
107
|
-
const value = line.trim();
|
|
108
|
-
if (value && !value.startsWith("#")) groups.add(value);
|
|
109
|
-
}
|
|
110
|
-
} catch {
|
|
111
|
-
// Match Auto Permissions: missing or unreadable means no trusted groups.
|
|
112
|
-
}
|
|
113
|
-
return groups;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function nonEmptyString(value: unknown) {
|
|
117
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
121
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
122
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
injectActiveImplementationContext,
|
|
3
|
-
isEmptyAssistantMessage,
|
|
4
|
-
messageContainsExactPlanModeImplementationHandoff,
|
|
5
|
-
messageContainsInactivePlanModeArtifact,
|
|
6
|
-
messageContainsLegacyPlanModeContextArtifact,
|
|
7
|
-
messageContainsPlanModeImplementationContextArtifact,
|
|
8
|
-
messageContainsPlanModeImplementationHandoff,
|
|
9
|
-
stripPlanModeCompletionCallsFromMessage,
|
|
10
|
-
stripProposedPlanBlocksFromMessage,
|
|
11
|
-
} from "./message-transform.js";
|
|
12
|
-
import type { ImplementationPlanRetention } from "./settings.js";
|
|
13
|
-
import type { ActiveImplementationPlan, PlanModeState } from "./state.js";
|
|
14
|
-
|
|
15
|
-
export function retentionLabel(retention: ImplementationPlanRetention) {
|
|
16
|
-
return {
|
|
17
|
-
keep: "Keep plan active",
|
|
18
|
-
"clear-on-start": "Use plan for handoff only",
|
|
19
|
-
"clear-after-first-run": "Clear after first implementation run",
|
|
20
|
-
}[retention];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function implementationRetentionPreview(retention: ImplementationPlanRetention) {
|
|
24
|
-
return {
|
|
25
|
-
keep: "After Implement: Keep plan active until /plan exit.",
|
|
26
|
-
"clear-on-start":
|
|
27
|
-
"After Implement: Use the plan for the implementation handoff only, then clear it.",
|
|
28
|
-
"clear-after-first-run": "After Implement: Clear after the first implementation run settles.",
|
|
29
|
-
}[retention];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface ImplementationContextResult {
|
|
33
|
-
messages: unknown[];
|
|
34
|
-
clearActiveImplementationId?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface ImplementationRetentionCoordinator {
|
|
38
|
-
restore(activeImplementation: ActiveImplementationPlan | undefined): void;
|
|
39
|
-
transformContext(messages: unknown[], state: PlanModeState): ImplementationContextResult;
|
|
40
|
-
implementationSettled(
|
|
41
|
-
activeImplementation: ActiveImplementationPlan | undefined,
|
|
42
|
-
): string | undefined;
|
|
43
|
-
reset(): void;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function createImplementationRetentionCoordinator(): ImplementationRetentionCoordinator {
|
|
47
|
-
let implementationWithDeliveredContext: string | undefined;
|
|
48
|
-
let restoredImplementationAwaitingContext: string | undefined;
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
restore(activeImplementation) {
|
|
52
|
-
restoredImplementationAwaitingContext =
|
|
53
|
-
activeImplementation && activeImplementation.retention !== "keep"
|
|
54
|
-
? activeImplementation.id
|
|
55
|
-
: undefined;
|
|
56
|
-
},
|
|
57
|
-
transformContext(messages, state) {
|
|
58
|
-
const messagesWithoutPlanContext = messages.filter(
|
|
59
|
-
(message) =>
|
|
60
|
-
!messageContainsLegacyPlanModeContextArtifact(message) &&
|
|
61
|
-
!messageContainsPlanModeImplementationContextArtifact(message),
|
|
62
|
-
);
|
|
63
|
-
if (state.enabled) {
|
|
64
|
-
return {
|
|
65
|
-
messages: messagesWithoutPlanContext.filter(
|
|
66
|
-
(message) => !messageContainsPlanModeImplementationHandoff(message),
|
|
67
|
-
),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const activeImplementation = state.activeImplementation;
|
|
72
|
-
const inactiveMessages = activeImplementation
|
|
73
|
-
? messagesWithoutPlanContext
|
|
74
|
-
: messagesWithoutPlanContext.filter(
|
|
75
|
-
(message) => !messageContainsPlanModeImplementationHandoff(message),
|
|
76
|
-
);
|
|
77
|
-
const filteredMessages = inactiveMessages
|
|
78
|
-
.filter((message) => !messageContainsInactivePlanModeArtifact(message))
|
|
79
|
-
.map(stripProposedPlanBlocksFromMessage)
|
|
80
|
-
.map(stripPlanModeCompletionCallsFromMessage)
|
|
81
|
-
.filter((message) => !isEmptyAssistantMessage(message));
|
|
82
|
-
if (!activeImplementation) return { messages: filteredMessages };
|
|
83
|
-
|
|
84
|
-
const contextualMessages = injectActiveImplementationContext(
|
|
85
|
-
filteredMessages,
|
|
86
|
-
activeImplementation,
|
|
87
|
-
);
|
|
88
|
-
// A busy /plan implement queues its handoff behind an older run. Do not arm cleanup
|
|
89
|
-
// until that exact handoff reaches context; a restored session has no older run to drain.
|
|
90
|
-
const deliveredCurrentHandoff =
|
|
91
|
-
restoredImplementationAwaitingContext === activeImplementation.id ||
|
|
92
|
-
filteredMessages.some((message) =>
|
|
93
|
-
messageContainsExactPlanModeImplementationHandoff(message, activeImplementation.plan),
|
|
94
|
-
);
|
|
95
|
-
if (!deliveredCurrentHandoff) return { messages: contextualMessages };
|
|
96
|
-
restoredImplementationAwaitingContext = undefined;
|
|
97
|
-
|
|
98
|
-
if (activeImplementation.retention === "clear-after-first-run") {
|
|
99
|
-
implementationWithDeliveredContext = activeImplementation.id;
|
|
100
|
-
}
|
|
101
|
-
return {
|
|
102
|
-
messages: contextualMessages,
|
|
103
|
-
clearActiveImplementationId:
|
|
104
|
-
activeImplementation.retention === "clear-on-start" ? activeImplementation.id : undefined,
|
|
105
|
-
};
|
|
106
|
-
},
|
|
107
|
-
implementationSettled(activeImplementation) {
|
|
108
|
-
if (
|
|
109
|
-
activeImplementation?.retention !== "clear-after-first-run" ||
|
|
110
|
-
implementationWithDeliveredContext !== activeImplementation.id
|
|
111
|
-
) {
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
implementationWithDeliveredContext = undefined;
|
|
115
|
-
return activeImplementation.id;
|
|
116
|
-
},
|
|
117
|
-
reset() {
|
|
118
|
-
implementationWithDeliveredContext = undefined;
|
|
119
|
-
restoredImplementationAwaitingContext = undefined;
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
}
|
package/src/message-transform.ts
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import { PLAN_MODE_COMPLETE_TOOL_NAME } from "./completion-tool.js";
|
|
2
|
-
import type { ActiveImplementationPlan } from "./state.js";
|
|
3
|
-
|
|
4
|
-
const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
|
|
5
|
-
export const PLAN_IMPLEMENTATION_CONTEXT_MESSAGE_TYPE = "plan-mode-implementation-context";
|
|
6
|
-
const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
|
|
7
|
-
const PLAN_IMPLEMENTATION_HANDOFF_PREFIX =
|
|
8
|
-
"Plan mode is now disabled. Full tool access is restored. Implement this proposed plan now:";
|
|
9
|
-
const PROPOSED_PLAN_PATTERN =
|
|
10
|
-
/^<proposed_plan>[\t ]*\r?\n([\s\S]*?)\r?\n<\/proposed_plan>[\t ]*$/gm;
|
|
11
|
-
const PROPOSED_PLAN_BLOCK_PATTERN =
|
|
12
|
-
/^<proposed_plan>[\t ]*\r?\n[\s\S]*?\r?\n<\/proposed_plan>[\t ]*$/gm;
|
|
13
|
-
|
|
14
|
-
export type ProposedPlanParseResult =
|
|
15
|
-
| { kind: "absent" }
|
|
16
|
-
| { kind: "valid"; plan: string }
|
|
17
|
-
| { kind: "empty" }
|
|
18
|
-
| { kind: "multiple" }
|
|
19
|
-
| { kind: "malformed" }
|
|
20
|
-
| { kind: "unclosed" };
|
|
21
|
-
|
|
22
|
-
type SessionMessage = {
|
|
23
|
-
role?: string;
|
|
24
|
-
content?: unknown;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
type TextBlock = {
|
|
28
|
-
type?: string;
|
|
29
|
-
text?: string;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export function parseProposedPlan(text: string): ProposedPlanParseResult {
|
|
33
|
-
const openingCount = text.match(/<proposed_plan>/gi)?.length ?? 0;
|
|
34
|
-
const closingCount = text.match(/<\/proposed_plan>/gi)?.length ?? 0;
|
|
35
|
-
if (openingCount === 0 && closingCount === 0) return { kind: "absent" };
|
|
36
|
-
if (openingCount > 1 || closingCount > 1) return { kind: "multiple" };
|
|
37
|
-
if (openingCount === 1 && closingCount === 0) return { kind: "unclosed" };
|
|
38
|
-
if (openingCount !== 1 || closingCount !== 1) return { kind: "malformed" };
|
|
39
|
-
|
|
40
|
-
const matches = Array.from(text.matchAll(PROPOSED_PLAN_PATTERN));
|
|
41
|
-
if (matches.length !== 1) return { kind: "malformed" };
|
|
42
|
-
const plan = matches[0]?.[1]?.trim() ?? "";
|
|
43
|
-
return plan ? { kind: "valid", plan } : { kind: "empty" };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function extractProposedPlan(text: string) {
|
|
47
|
-
const result = parseProposedPlan(text);
|
|
48
|
-
return result.kind === "valid" ? result.plan : undefined;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function invalidPlanMessage(kind: "empty" | "multiple" | "malformed" | "unclosed") {
|
|
52
|
-
const detail = {
|
|
53
|
-
empty: "the block is empty",
|
|
54
|
-
multiple: "more than one plan block was produced",
|
|
55
|
-
malformed: "the tags must be on their own lines",
|
|
56
|
-
unclosed: "the closing tag is missing",
|
|
57
|
-
}[kind];
|
|
58
|
-
return `Proposed plan is not ready: ${detail}. Continue Plan mode and produce one complete non-empty <proposed_plan> block.`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function latestAssistantText(messages: unknown) {
|
|
62
|
-
if (!Array.isArray(messages)) return "";
|
|
63
|
-
for (const entry of [...messages].reverse()) {
|
|
64
|
-
const message = (entry as { message?: SessionMessage })?.message ?? (entry as SessionMessage);
|
|
65
|
-
if (message?.role !== "assistant") continue;
|
|
66
|
-
const text = messageText(message);
|
|
67
|
-
if (text) return text;
|
|
68
|
-
}
|
|
69
|
-
return "";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
|
|
73
|
-
return unwrapSessionMessage(message).customType === PLAN_CONTEXT_MESSAGE_TYPE;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function messageContainsPlanModeImplementationContextArtifact(message: unknown) {
|
|
77
|
-
return unwrapSessionMessage(message).customType === PLAN_IMPLEMENTATION_CONTEXT_MESSAGE_TYPE;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function injectActiveImplementationContext(
|
|
81
|
-
messages: unknown[],
|
|
82
|
-
activeImplementation: ActiveImplementationPlan,
|
|
83
|
-
) {
|
|
84
|
-
let foundCurrentHandoff = false;
|
|
85
|
-
const messagesWithoutStaleContext = messages.filter((message) => {
|
|
86
|
-
if (messageContainsPlanModeImplementationContextArtifact(message)) return false;
|
|
87
|
-
if (!messageContainsPlanModeImplementationHandoff(message)) return true;
|
|
88
|
-
if (
|
|
89
|
-
!foundCurrentHandoff &&
|
|
90
|
-
messageContainsExactPlanModeImplementationHandoff(message, activeImplementation.plan)
|
|
91
|
-
) {
|
|
92
|
-
foundCurrentHandoff = true;
|
|
93
|
-
return true;
|
|
94
|
-
}
|
|
95
|
-
return false;
|
|
96
|
-
});
|
|
97
|
-
if (foundCurrentHandoff) return messagesWithoutStaleContext;
|
|
98
|
-
|
|
99
|
-
let insertionIndex = 0;
|
|
100
|
-
while (isSummaryMessage(messagesWithoutStaleContext[insertionIndex])) insertionIndex += 1;
|
|
101
|
-
const contextMessage = {
|
|
102
|
-
role: "custom" as const,
|
|
103
|
-
customType: PLAN_IMPLEMENTATION_CONTEXT_MESSAGE_TYPE,
|
|
104
|
-
content: `[ACTIVE IMPLEMENTATION PLAN]\n\nThe user approved the exact implementation plan below. Continue following it until the user explicitly clears or supersedes it. The exact plan is the remainder of this message:\n\n${activeImplementation.plan}`,
|
|
105
|
-
display: false,
|
|
106
|
-
timestamp: activeImplementation.startedAt,
|
|
107
|
-
};
|
|
108
|
-
return [
|
|
109
|
-
...messagesWithoutStaleContext.slice(0, insertionIndex),
|
|
110
|
-
contextMessage,
|
|
111
|
-
...messagesWithoutStaleContext.slice(insertionIndex),
|
|
112
|
-
];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function messageContainsInactivePlanModeArtifact(message: unknown) {
|
|
116
|
-
const candidate = unwrapSessionMessage(message);
|
|
117
|
-
return (
|
|
118
|
-
candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE ||
|
|
119
|
-
(candidate.role === "toolResult" && candidate.toolName === PLAN_MODE_COMPLETE_TOOL_NAME)
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function messageContainsPlanModeImplementationHandoff(message: unknown) {
|
|
124
|
-
const candidate = unwrapSessionMessage(message);
|
|
125
|
-
return (
|
|
126
|
-
candidate.role === "user" &&
|
|
127
|
-
contentText(candidate.content).trimStart().startsWith(PLAN_IMPLEMENTATION_HANDOFF_PREFIX)
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function messageContainsExactPlanModeImplementationHandoff(message: unknown, plan: string) {
|
|
132
|
-
const candidate = unwrapSessionMessage(message);
|
|
133
|
-
if (candidate.role !== "user") return false;
|
|
134
|
-
return (
|
|
135
|
-
contentText(candidate.content).trim() ===
|
|
136
|
-
`${PLAN_IMPLEMENTATION_HANDOFF_PREFIX}\n\n${plan}`.trim()
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function isSummaryMessage(message: unknown) {
|
|
141
|
-
const role = unwrapSessionMessage(message)?.role;
|
|
142
|
-
return role === "compactionSummary" || role === "branchSummary";
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
|
|
146
|
-
return replaceAssistantContent(message, stripProposedPlanBlocksFromContent);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function stripPlanModeCompletionCallsFromMessage<T>(message: T): T {
|
|
150
|
-
return replaceAssistantContent(message, (content) => {
|
|
151
|
-
if (!Array.isArray(content)) return content;
|
|
152
|
-
const nextContent = content.filter((block) => {
|
|
153
|
-
const candidate = block as { type?: string; name?: string };
|
|
154
|
-
return !(candidate.type === "toolCall" && candidate.name === PLAN_MODE_COMPLETE_TOOL_NAME);
|
|
155
|
-
});
|
|
156
|
-
return nextContent.length === content.length ? content : nextContent;
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export function isEmptyAssistantMessage(message: unknown) {
|
|
161
|
-
const candidate = unwrapSessionMessage(message);
|
|
162
|
-
return (
|
|
163
|
-
candidate.role === "assistant" &&
|
|
164
|
-
Array.isArray(candidate.content) &&
|
|
165
|
-
candidate.content.length === 0
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function replaceAssistantContent<T>(message: T, transform: (content: unknown) => unknown): T {
|
|
170
|
-
const candidate = unwrapSessionMessage(message);
|
|
171
|
-
if (candidate.role !== "assistant") return message;
|
|
172
|
-
|
|
173
|
-
const content = transform(candidate.content);
|
|
174
|
-
if (content === candidate.content) return message;
|
|
175
|
-
|
|
176
|
-
if (isSessionMessageEntry(message)) {
|
|
177
|
-
return { ...message, message: { ...candidate, content } };
|
|
178
|
-
}
|
|
179
|
-
return { ...candidate, content } as T;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function unwrapSessionMessage(message: unknown) {
|
|
183
|
-
const entry = message as { message?: unknown } | null | undefined;
|
|
184
|
-
return (entry?.message ?? message ?? {}) as {
|
|
185
|
-
role?: string;
|
|
186
|
-
customType?: string;
|
|
187
|
-
toolName?: string;
|
|
188
|
-
content?: unknown;
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
|
|
193
|
-
return typeof message === "object" && message !== null && "message" in message;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function stripProposedPlanBlocksFromContent(content: unknown) {
|
|
197
|
-
if (typeof content === "string") return stripProposedPlanBlocks(content);
|
|
198
|
-
if (!Array.isArray(content)) return content;
|
|
199
|
-
|
|
200
|
-
let changed = false;
|
|
201
|
-
const nextContent = content.map((block) => {
|
|
202
|
-
const textBlock = block as TextBlock;
|
|
203
|
-
if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
|
|
204
|
-
|
|
205
|
-
const text = stripProposedPlanBlocks(textBlock.text);
|
|
206
|
-
if (text === textBlock.text) return block;
|
|
207
|
-
|
|
208
|
-
changed = true;
|
|
209
|
-
return { ...textBlock, text };
|
|
210
|
-
});
|
|
211
|
-
return changed ? nextContent : content;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function stripProposedPlanBlocks(text: string) {
|
|
215
|
-
return text.replace(PROPOSED_PLAN_BLOCK_PATTERN, "");
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function messageText(message: SessionMessage) {
|
|
219
|
-
return contentText(message.content);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function contentText(content: unknown): string {
|
|
223
|
-
if (typeof content === "string") return content;
|
|
224
|
-
if (!Array.isArray(content)) return "";
|
|
225
|
-
return content
|
|
226
|
-
.map((block) => {
|
|
227
|
-
const textBlock = block as TextBlock;
|
|
228
|
-
return textBlock.type === "text" && typeof textBlock.text === "string" ? textBlock.text : "";
|
|
229
|
-
})
|
|
230
|
-
.filter(Boolean)
|
|
231
|
-
.join("\n");
|
|
232
|
-
}
|
package/src/required-tools.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { PLAN_MODE_COMPLETE_TOOL_NAME } from "./completion-tool.js";
|
|
2
|
-
import { PLAN_MODE_QUESTION_TOOL_NAME } from "./question-tool.js";
|
|
3
|
-
import { unique } from "./tool-selection.js";
|
|
4
|
-
|
|
5
|
-
export function withRequiredPlanModeTools(toolNames: string[]) {
|
|
6
|
-
return unique([
|
|
7
|
-
...withoutRequiredPlanModeTools(toolNames),
|
|
8
|
-
PLAN_MODE_QUESTION_TOOL_NAME,
|
|
9
|
-
PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
10
|
-
]);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function withoutPlanModeQuestionTool(toolNames: string[]) {
|
|
14
|
-
return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function withoutRequiredPlanModeTools(toolNames: string[]) {
|
|
18
|
-
return toolNames.filter(
|
|
19
|
-
(toolName) =>
|
|
20
|
-
toolName !== PLAN_MODE_QUESTION_TOOL_NAME && toolName !== PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
21
|
-
);
|
|
22
|
-
}
|