@docyrus/docyrus 0.0.25 → 0.0.26

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.
@@ -0,0 +1,1197 @@
1
+ import type {
2
+ AgentEndEvent,
3
+ ExtensionAPI,
4
+ ExtensionCommandContext,
5
+ ExtensionContext,
6
+ ThinkingLevel,
7
+ ToolCallEvent,
8
+ UserBashEvent,
9
+ } from "@mariozechner/pi-coding-agent";
10
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
11
+ import type { Api, Model } from "@mariozechner/pi-ai";
12
+ import { Container, Text } from "@mariozechner/pi-tui";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import fs from "node:fs/promises";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import {
18
+ buildAskUserProtocolInstructions,
19
+ formatAskUserResponsePrompt,
20
+ parseAskUserRequestFromText,
21
+ type IAskUserQuestion,
22
+ type IAskUserResponse,
23
+ } from "../shared/askUserProtocol";
24
+
25
+ const PLAN_OUTPUT_ROOT_SEGMENTS = [".docyrus", "plans"] as const;
26
+ const PLAN_STATE_TYPE = "plan-session";
27
+ const PLAN_ANCHOR_TYPE = "plan-anchor";
28
+ const PLAN_CONFIG_FILE = ".pi/plan-policy.json";
29
+ const PLAN_WIDGET_KEY = "plan-mode";
30
+ const PLAN_BRANCH_LABEL = "plan";
31
+ const ALLOWED_TODO_ACTIONS = new Set(["list", "list-all", "get"]);
32
+ const ALL_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh"] satisfies ThinkingLevel[]);
33
+
34
+ const PLAN_MODE_SYSTEM_PROMPT = [
35
+ "You are in Docyrus `/plan` mode.",
36
+ "",
37
+ "This is planning only.",
38
+ "Do not implement code, do not mutate repository files, and do not perform remote Docyrus mutations.",
39
+ "Use inspection, analysis, and verification commands only.",
40
+ "",
41
+ "Respond with an actionable Markdown plan using these exact top-level sections in this order:",
42
+ "## Summary",
43
+ "## Implementation Changes",
44
+ "## Test Plan",
45
+ "## Assumptions",
46
+ "",
47
+ "Keep the plan decision-complete for implementation, call out assumptions explicitly, and do not start implementation.",
48
+ ].join("\n");
49
+
50
+ const END_PLAN_SUMMARY_PROMPT = `You are returning from a planning-only branch.
51
+
52
+ Summarize the plan branch as a concise, implementation-ready plan.
53
+
54
+ Requirements:
55
+ - Do not implement anything.
56
+ - Preserve the important decisions made on the plan branch.
57
+ - Output Markdown with these exact top-level sections:
58
+ - Summary
59
+ - Implementation Changes
60
+ - Test Plan
61
+ - Assumptions
62
+ - Keep it concise and actionable.`;
63
+
64
+ export interface IPlanModelEntry {
65
+ model: string;
66
+ thinkingLevel?: ThinkingLevel;
67
+ }
68
+
69
+ export interface IPlanProfileOverride {
70
+ match: string;
71
+ models?: IPlanModelEntry[];
72
+ }
73
+
74
+ export interface IPlanPolicy {
75
+ models?: IPlanModelEntry[];
76
+ profiles?: Record<string, IPlanProfileOverride>;
77
+ }
78
+
79
+ export interface IPlanSessionState {
80
+ active: boolean;
81
+ mode?: "plan" | "architect";
82
+ originId?: string;
83
+ branchStartId?: string;
84
+ artifactPath?: string;
85
+ task?: string;
86
+ sourceModel?: string;
87
+ sourceThinking?: ThinkingLevel;
88
+ planModel?: string;
89
+ planThinking?: ThinkingLevel;
90
+ startedAt?: string;
91
+ }
92
+
93
+ export interface IReadPlanPolicyResult {
94
+ policy: IPlanPolicy;
95
+ path?: string;
96
+ error?: string;
97
+ }
98
+
99
+ export type IParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
100
+
101
+ let currentPlanState: IPlanSessionState | undefined;
102
+
103
+ export interface IPlanningStartParams {
104
+ pi: ExtensionAPI;
105
+ ctx: ExtensionCommandContext;
106
+ mode: "plan" | "architect";
107
+ task?: string;
108
+ artifactPath: string;
109
+ initialPrompt: string;
110
+ profileName?: string;
111
+ }
112
+
113
+ function expandUserPath(inputPath: string): string {
114
+ if (inputPath === "~") {
115
+ return os.homedir();
116
+ }
117
+ if (inputPath.startsWith("~/")) {
118
+ return path.join(os.homedir(), inputPath.slice(2));
119
+ }
120
+ return inputPath;
121
+ }
122
+
123
+ function resolveAgentRootPath(): string {
124
+ const agentDir = process.env.PI_CODING_AGENT_DIR?.trim();
125
+ return agentDir && agentDir.length > 0 ? expandUserPath(agentDir) : path.join(os.homedir(), ".pi", "agent");
126
+ }
127
+
128
+ function resolveGlobalPlanPolicyPath(): string {
129
+ return path.join(resolveAgentRootPath(), "plan-policy.json");
130
+ }
131
+
132
+ function isRecord(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ export function parsePlanTask(rawArgs: string): string | null {
137
+ const normalized = rawArgs.trim();
138
+ return normalized.length > 0 ? normalized : null;
139
+ }
140
+
141
+ export function slugifyPlanTask(task: string): string {
142
+ const slug = task
143
+ .toLowerCase()
144
+ .replace(/[^a-z0-9]+/g, "-")
145
+ .replace(/^-+|-+$/g, "")
146
+ .slice(0, 60);
147
+
148
+ return slug || "plan";
149
+ }
150
+
151
+ export function formatPlanTimestamp(date: Date): string {
152
+ const year = `${date.getFullYear()}`;
153
+ const month = `${date.getMonth() + 1}`.padStart(2, "0");
154
+ const day = `${date.getDate()}`.padStart(2, "0");
155
+ const hour = `${date.getHours()}`.padStart(2, "0");
156
+ const minute = `${date.getMinutes()}`.padStart(2, "0");
157
+ const second = `${date.getSeconds()}`.padStart(2, "0");
158
+ return `${year}${month}${day}-${hour}${minute}${second}`;
159
+ }
160
+
161
+ export function createPlanArtifactPath(params: {
162
+ cwd: string;
163
+ task: string;
164
+ date?: Date;
165
+ }): string {
166
+ const timestamp = formatPlanTimestamp(params.date ?? new Date());
167
+ const slug = slugifyPlanTask(params.task);
168
+ return path.join(params.cwd, ...PLAN_OUTPUT_ROOT_SEGMENTS, `${timestamp}-${slug}.md`);
169
+ }
170
+
171
+ export function parsePlanModelSelector(value: unknown): IParseResult<string> {
172
+ if (typeof value !== "string") {
173
+ return { ok: false, error: "expected model selector provider/modelId" };
174
+ }
175
+ if (value.trim() !== value) {
176
+ return { ok: false, error: "expected model selector provider/modelId" };
177
+ }
178
+ const slashIndex = value.indexOf("/");
179
+ if (slashIndex <= 0 || slashIndex >= value.length - 1) {
180
+ return { ok: false, error: "expected model selector provider/modelId" };
181
+ }
182
+ const provider = value.slice(0, slashIndex);
183
+ const modelId = value.slice(slashIndex + 1);
184
+ if (/\s/.test(provider) || /\s/.test(modelId)) {
185
+ return { ok: false, error: "expected model selector provider/modelId" };
186
+ }
187
+ return { ok: true, value };
188
+ }
189
+
190
+ function parseThinkingLevel(value: unknown): IParseResult<ThinkingLevel> {
191
+ if (typeof value !== "string" || !ALL_THINKING_LEVELS.has(value as ThinkingLevel)) {
192
+ return { ok: false, error: "expected one of: off, minimal, low, medium, high, xhigh" };
193
+ }
194
+ return { ok: true, value: value as ThinkingLevel };
195
+ }
196
+
197
+ function parsePlanModelEntry(value: unknown): IParseResult<IPlanModelEntry> {
198
+ if (typeof value === "string") {
199
+ const parsedSelector = parsePlanModelSelector(value);
200
+ if (!parsedSelector.ok) {
201
+ return parsedSelector;
202
+ }
203
+ return {
204
+ ok: true,
205
+ value: {
206
+ model: parsedSelector.value,
207
+ },
208
+ };
209
+ }
210
+
211
+ if (!isRecord(value)) {
212
+ return { ok: false, error: "expected model selector string or { model, thinkingLevel } object" };
213
+ }
214
+
215
+ const knownKeys = new Set(["model", "thinkingLevel"]);
216
+ for (const key of Object.keys(value)) {
217
+ if (!knownKeys.has(key)) {
218
+ return { ok: false, error: `unknown key: ${key}` };
219
+ }
220
+ }
221
+
222
+ const parsedSelector = parsePlanModelSelector(value.model);
223
+ if (!parsedSelector.ok) {
224
+ return { ok: false, error: `model entry: ${parsedSelector.error}` };
225
+ }
226
+
227
+ const entry: IPlanModelEntry = {
228
+ model: parsedSelector.value,
229
+ };
230
+
231
+ if (value.thinkingLevel !== undefined) {
232
+ const parsedThinking = parseThinkingLevel(value.thinkingLevel);
233
+ if (!parsedThinking.ok) {
234
+ return { ok: false, error: `model entry thinkingLevel: ${parsedThinking.error}` };
235
+ }
236
+ entry.thinkingLevel = parsedThinking.value;
237
+ }
238
+
239
+ return { ok: true, value: entry };
240
+ }
241
+
242
+ function parsePlanModelEntries(value: unknown): IParseResult<IPlanModelEntry[]> {
243
+ if (!Array.isArray(value)) {
244
+ return { ok: false, error: "expected array of model entries" };
245
+ }
246
+ if (value.length === 0) {
247
+ return { ok: false, error: "models array must not be empty" };
248
+ }
249
+
250
+ const entries: IPlanModelEntry[] = [];
251
+ for (const item of value) {
252
+ const parsed = parsePlanModelEntry(item);
253
+ if (!parsed.ok) {
254
+ return parsed;
255
+ }
256
+ entries.push(parsed.value);
257
+ }
258
+
259
+ return { ok: true, value: entries };
260
+ }
261
+
262
+ function parsePlanProfileOverride(name: string, value: unknown): IParseResult<IPlanProfileOverride> {
263
+ if (!isRecord(value)) {
264
+ return { ok: false, error: `profiles.${name}: expected object` };
265
+ }
266
+
267
+ const knownKeys = new Set(["match", "models"]);
268
+ for (const key of Object.keys(value)) {
269
+ if (!knownKeys.has(key)) {
270
+ return { ok: false, error: `profiles.${name}: unknown key: ${key}` };
271
+ }
272
+ }
273
+
274
+ const parsedMatch = parsePlanModelSelector(value.match);
275
+ if (!parsedMatch.ok) {
276
+ return { ok: false, error: `profiles.${name}.match: ${parsedMatch.error}` };
277
+ }
278
+
279
+ const profile: IPlanProfileOverride = {
280
+ match: parsedMatch.value,
281
+ };
282
+
283
+ if (value.models !== undefined) {
284
+ const parsedModels = parsePlanModelEntries(value.models);
285
+ if (!parsedModels.ok) {
286
+ return { ok: false, error: `profiles.${name}.models: ${parsedModels.error}` };
287
+ }
288
+ profile.models = parsedModels.value;
289
+ }
290
+
291
+ return { ok: true, value: profile };
292
+ }
293
+
294
+ function parsePlanProfiles(value: unknown): IParseResult<Record<string, IPlanProfileOverride>> {
295
+ if (!isRecord(value)) {
296
+ return { ok: false, error: "profiles: expected object" };
297
+ }
298
+
299
+ const profiles: Record<string, IPlanProfileOverride> = {};
300
+ for (const [name, entry] of Object.entries(value)) {
301
+ const parsed = parsePlanProfileOverride(name, entry);
302
+ if (!parsed.ok) {
303
+ return parsed;
304
+ }
305
+ profiles[name] = parsed.value;
306
+ }
307
+
308
+ return { ok: true, value: profiles };
309
+ }
310
+
311
+ export function parsePlanPolicy(value: unknown): IParseResult<IPlanPolicy> {
312
+ if (!isRecord(value)) {
313
+ return { ok: false, error: "expected object" };
314
+ }
315
+
316
+ const knownKeys = new Set(["models", "profiles"]);
317
+ for (const key of Object.keys(value)) {
318
+ if (!knownKeys.has(key)) {
319
+ return { ok: false, error: `unknown key: ${key}` };
320
+ }
321
+ }
322
+
323
+ const policy: IPlanPolicy = {};
324
+
325
+ if (value.models !== undefined) {
326
+ const parsedModels = parsePlanModelEntries(value.models);
327
+ if (!parsedModels.ok) {
328
+ return { ok: false, error: `models: ${parsedModels.error}` };
329
+ }
330
+ policy.models = parsedModels.value;
331
+ }
332
+
333
+ if (value.profiles !== undefined) {
334
+ const parsedProfiles = parsePlanProfiles(value.profiles);
335
+ if (!parsedProfiles.ok) {
336
+ return parsedProfiles;
337
+ }
338
+ policy.profiles = parsedProfiles.value;
339
+ }
340
+
341
+ return { ok: true, value: policy };
342
+ }
343
+
344
+ function readPlanPolicyConfigFile(configPath: string): IReadPlanPolicyResult {
345
+ try {
346
+ const raw = readFileSync(configPath, "utf8");
347
+ const parsed = parsePlanPolicy(JSON.parse(raw) as unknown);
348
+ if (!parsed.ok) {
349
+ return { policy: {}, path: configPath, error: `Invalid ${configPath}: ${parsed.error}` };
350
+ }
351
+ return { policy: parsed.value, path: configPath };
352
+ } catch (error) {
353
+ const message = error instanceof Error ? error.message : String(error);
354
+ return { policy: {}, path: configPath, error: `Invalid ${configPath}: ${message}` };
355
+ }
356
+ }
357
+
358
+ export function readPlanPolicy(cwd: string): IReadPlanPolicyResult {
359
+ const projectPath = path.join(cwd, PLAN_CONFIG_FILE);
360
+ if (existsSync(projectPath)) {
361
+ return readPlanPolicyConfigFile(projectPath);
362
+ }
363
+
364
+ const globalPath = resolveGlobalPlanPolicyPath();
365
+ if (existsSync(globalPath)) {
366
+ return readPlanPolicyConfigFile(globalPath);
367
+ }
368
+
369
+ return { policy: {} };
370
+ }
371
+
372
+ export function findMatchingPlanProfile(
373
+ profiles: Record<string, IPlanProfileOverride> | undefined,
374
+ sourceModel: string | undefined,
375
+ ): { name: string; override: IPlanProfileOverride } | undefined {
376
+ if (!profiles || !sourceModel) {
377
+ return undefined;
378
+ }
379
+
380
+ for (const name of Object.keys(profiles).sort()) {
381
+ const profile = profiles[name];
382
+ if (profile && profile.match === sourceModel) {
383
+ return { name, override: profile };
384
+ }
385
+ }
386
+
387
+ return undefined;
388
+ }
389
+
390
+ export function resolveEffectivePlanPolicy(params: {
391
+ basePolicy: IPlanPolicy;
392
+ sourceModel: string | undefined;
393
+ }): {
394
+ profileName: string | undefined;
395
+ modelEntries: IPlanModelEntry[];
396
+ } {
397
+ const profile = findMatchingPlanProfile(params.basePolicy.profiles, params.sourceModel);
398
+ return {
399
+ profileName: profile?.name,
400
+ modelEntries: profile?.override.models ?? params.basePolicy.models ?? [],
401
+ };
402
+ }
403
+
404
+ function formatModelSelector(model: { provider: string; id: string }): string {
405
+ return `${model.provider}/${model.id}`;
406
+ }
407
+
408
+ function splitModelSelector(selector: string): IParseResult<{ provider: string; modelId: string }> {
409
+ const parsed = parsePlanModelSelector(selector);
410
+ if (!parsed.ok) {
411
+ return parsed;
412
+ }
413
+
414
+ const slashIndex = parsed.value.indexOf("/");
415
+ return {
416
+ ok: true,
417
+ value: {
418
+ provider: parsed.value.slice(0, slashIndex),
419
+ modelId: parsed.value.slice(slashIndex + 1),
420
+ },
421
+ };
422
+ }
423
+
424
+ function getCurrentModelSelector(ctx: ExtensionContext): string | undefined {
425
+ return ctx.model ? formatModelSelector(ctx.model) : undefined;
426
+ }
427
+
428
+ async function tryResolveConfiguredModel(
429
+ ctx: ExtensionContext,
430
+ selector: string,
431
+ ): Promise<Model<Api> | undefined> {
432
+ const parsed = splitModelSelector(selector);
433
+ if (!parsed.ok) {
434
+ return undefined;
435
+ }
436
+
437
+ const model = ctx.modelRegistry.find(parsed.value.provider, parsed.value.modelId);
438
+ if (!model) {
439
+ return undefined;
440
+ }
441
+
442
+ try {
443
+ const apiKey = await ctx.modelRegistry.getApiKey(model);
444
+ if (!apiKey) {
445
+ return undefined;
446
+ }
447
+ } catch {
448
+ return undefined;
449
+ }
450
+
451
+ return model as Model<Api>;
452
+ }
453
+
454
+ async function resolveConfiguredPlanModel(
455
+ ctx: ExtensionContext,
456
+ modelEntries: IPlanModelEntry[],
457
+ ): Promise<{ entry: IPlanModelEntry; model: Model<Api> } | undefined> {
458
+ for (const entry of modelEntries) {
459
+ const model = await tryResolveConfiguredModel(ctx, entry.model);
460
+ if (model) {
461
+ return { entry, model };
462
+ }
463
+ }
464
+
465
+ return undefined;
466
+ }
467
+
468
+ function buildPlanPromptOverlay(state: IPlanSessionState): string {
469
+ const task = state.task?.trim() || "(unknown task)";
470
+ const model = state.planModel ? `Planning model: ${state.planModel}` : "Planning model: current session model";
471
+ const artifact = state.artifactPath ? `Artifact path: ${state.artifactPath}` : "Artifact path: unavailable";
472
+ const modeLabel = state.mode === "architect" ? "Docyrus `/architect` planning mode." : "Docyrus `/plan` mode.";
473
+ return [
474
+ modeLabel,
475
+ PLAN_MODE_SYSTEM_PROMPT,
476
+ "",
477
+ buildAskUserProtocolInstructions(),
478
+ "",
479
+ "Current planning session:",
480
+ `Task: ${task}`,
481
+ model,
482
+ artifact,
483
+ ].join("\n");
484
+ }
485
+
486
+ function buildInitialPlanPrompt(task: string | undefined): string {
487
+ if (!task) {
488
+ return [
489
+ "The user started a planning workflow but did not provide a concrete task yet.",
490
+ "",
491
+ "Before drafting any plan, gather the minimum clarification you need from the user.",
492
+ "If clarification is needed, use the ask_user protocol.",
493
+ ].join("\n");
494
+ }
495
+
496
+ return [
497
+ "Create a decision-complete implementation plan for the following task.",
498
+ "",
499
+ task,
500
+ ].join("\n");
501
+ }
502
+
503
+ function buildInitialArtifactContent(task: string): string {
504
+ return [
505
+ "# Planning Session",
506
+ "",
507
+ "## Task",
508
+ task,
509
+ "",
510
+ "_Waiting for the first planning response..._",
511
+ "",
512
+ ].join("\n");
513
+ }
514
+
515
+ function getPlanState(ctx: ExtensionContext): IPlanSessionState | undefined {
516
+ let state: IPlanSessionState | undefined;
517
+ for (const entry of ctx.sessionManager.getBranch()) {
518
+ if (entry.type === "custom" && entry.customType === PLAN_STATE_TYPE) {
519
+ state = entry.data as IPlanSessionState | undefined;
520
+ }
521
+ }
522
+
523
+ if (state?.active) {
524
+ return state;
525
+ }
526
+
527
+ return undefined;
528
+ }
529
+
530
+ function setPlanWidget(ctx: ExtensionContext, state: IPlanSessionState | undefined): void {
531
+ if (!ctx.hasUI) {
532
+ return;
533
+ }
534
+
535
+ if (!state?.active) {
536
+ ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
537
+ return;
538
+ }
539
+
540
+ ctx.ui.setWidget(PLAN_WIDGET_KEY, (_tui, theme) => {
541
+ const lines = [
542
+ theme.fg("accent", theme.bold("Plan mode active")),
543
+ theme.fg("muted", `Model: ${state.planModel ?? "current session model"}`),
544
+ theme.fg("muted", `Artifact: ${state.artifactPath ?? "(none)"}`),
545
+ theme.fg("warning", "Exit with /end-plan"),
546
+ ];
547
+
548
+ const container = new Container();
549
+ container.addChild(new DynamicBorder((value: string) => theme.fg("accent", value)));
550
+ container.addChild(new Text(lines.join("\n"), 0, 0));
551
+ return container;
552
+ });
553
+ }
554
+
555
+ async function ensureArtifactFile(artifactPath: string, task: string): Promise<void> {
556
+ await fs.mkdir(path.dirname(artifactPath), { recursive: true });
557
+ await fs.writeFile(artifactPath, buildInitialArtifactContent(task), "utf8");
558
+ }
559
+
560
+ function appendPlanState(pi: ExtensionAPI, state: IPlanSessionState): void {
561
+ pi.appendEntry(PLAN_STATE_TYPE, state);
562
+ }
563
+
564
+ function clearPlanState(pi: ExtensionAPI): void {
565
+ pi.appendEntry(PLAN_STATE_TYPE, { active: false });
566
+ }
567
+
568
+ function getLatestUserEntryId(ctx: ExtensionContext): string | undefined {
569
+ const branch = ctx.sessionManager.getBranch();
570
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
571
+ const entry = branch[index];
572
+ if (entry.type === "message" && entry.message.role === "user") {
573
+ return entry.id;
574
+ }
575
+ }
576
+
577
+ return undefined;
578
+ }
579
+
580
+ function extractLastAssistantText(messages: Array<{ role?: string; content?: unknown }>): string | null {
581
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
582
+ const message = messages[index];
583
+ if (message?.role !== "assistant") {
584
+ continue;
585
+ }
586
+
587
+ if (typeof message.content === "string") {
588
+ return message.content.trim() || null;
589
+ }
590
+
591
+ if (Array.isArray(message.content)) {
592
+ const text = message.content
593
+ .filter((block): block is { type: "text"; text: string } => {
594
+ return Boolean(
595
+ block &&
596
+ typeof block === "object" &&
597
+ "type" in block &&
598
+ "text" in block &&
599
+ block.type === "text" &&
600
+ typeof block.text === "string",
601
+ );
602
+ })
603
+ .map((block) => block.text)
604
+ .join("\n")
605
+ .trim();
606
+
607
+ return text || null;
608
+ }
609
+
610
+ return null;
611
+ }
612
+
613
+ return null;
614
+ }
615
+
616
+ async function writePlanArtifactFromEvent(event: AgentEndEvent, ctx: ExtensionContext): Promise<void> {
617
+ const state = getPlanState(ctx);
618
+ if (!state?.active || !state.artifactPath || state.mode === "architect") {
619
+ return;
620
+ }
621
+
622
+ const text = extractLastAssistantText(event.messages ?? []);
623
+ if (!text || parseAskUserRequestFromText(text)) {
624
+ return;
625
+ }
626
+
627
+ try {
628
+ await fs.mkdir(path.dirname(state.artifactPath), { recursive: true });
629
+ await fs.writeFile(state.artifactPath, `${text.trim()}\n`, "utf8");
630
+ } catch (error) {
631
+ if (ctx.hasUI) {
632
+ const message = error instanceof Error ? error.message : String(error);
633
+ ctx.ui.notify(`Failed to write plan artifact: ${message}`, "error");
634
+ }
635
+ }
636
+ }
637
+
638
+ export function shouldBlockTodoAction(action: string | undefined): boolean {
639
+ return !!action && !ALLOWED_TODO_ACTIONS.has(action);
640
+ }
641
+
642
+ export function isPotentiallyMutatingShellCommand(command: string): boolean {
643
+ const trimmed = command.trim();
644
+ if (!trimmed) {
645
+ return false;
646
+ }
647
+
648
+ const patterns = [
649
+ /(^|[;&|]\s*)git\s+(add|commit|push|pull|merge|rebase|cherry-pick|reset|clean|restore|checkout|switch|stash|am|apply)\b/i,
650
+ /(^|[;&|]\s*)(rm|mv|cp|mkdir|touch|install|chmod|chown)\b/i,
651
+ /(^|[;&|]\s*)(npm|pnpm|yarn|bun)\s+(add|install|remove|unlink|link|update|upgrade|publish|version)\b/i,
652
+ /(^|[;&|]\s*)(pip|pip3)\s+(install|uninstall)\b/i,
653
+ /(^|[;&|]\s*)cargo\s+(add|install|remove)\b/i,
654
+ /(^|[;&|]\s*)go\s+(get|mod\s+tidy)\b/i,
655
+ /(^|[;&|]\s*)brew\s+(install|upgrade|tap|untap)\b/i,
656
+ /(^|[;&|]\s*)sed\s+-i\b/i,
657
+ /(^|[;&|]\s*)perl\s+-i\b/i,
658
+ /(^|[;&|]\s*)tee\b/i,
659
+ /(^|[;&|]\s*)docyrus\s+(apps|ds|studio)\s+(create|update|delete|restore|permanent-delete|bulk-create-data-sources|create-field|update-field|delete-field|create-fields-batch|update-fields-batch|delete-fields-batch|create-enums|update-enums|delete-enums)\b/i,
660
+ ];
661
+
662
+ if (patterns.some((pattern) => pattern.test(trimmed))) {
663
+ return true;
664
+ }
665
+
666
+ if (/\s(?:\d*>>?)(?:\s|$)/.test(trimmed)) {
667
+ return true;
668
+ }
669
+
670
+ return false;
671
+ }
672
+
673
+ function buildBlockedToolReason(detail: string): string {
674
+ return `A planning session is active. ${detail} Exit with /end-plan or /end-architect before implementing.`;
675
+ }
676
+
677
+ function buildBlockedUserBashResult(detail: string) {
678
+ return {
679
+ result: {
680
+ output: `${buildBlockedToolReason(detail)}\n`,
681
+ exitCode: 1,
682
+ cancelled: false,
683
+ truncated: false,
684
+ },
685
+ };
686
+ }
687
+
688
+ async function applyModelSelector(
689
+ pi: ExtensionAPI,
690
+ ctx: ExtensionContext,
691
+ selector: string | undefined,
692
+ thinkingLevel: ThinkingLevel | undefined,
693
+ ): Promise<boolean> {
694
+ if (!selector) {
695
+ if (thinkingLevel) {
696
+ pi.setThinkingLevel(thinkingLevel);
697
+ }
698
+ return true;
699
+ }
700
+
701
+ const parts = splitModelSelector(selector);
702
+ if (!parts.ok) {
703
+ if (ctx.hasUI) {
704
+ ctx.ui.notify(`Invalid plan model selector ${selector}`, "warning");
705
+ }
706
+ return false;
707
+ }
708
+
709
+ const model = ctx.modelRegistry.find(parts.value.provider, parts.value.modelId);
710
+ if (!model) {
711
+ if (ctx.hasUI) {
712
+ ctx.ui.notify(`Plan model ${selector} is not available in the current registry`, "warning");
713
+ }
714
+ return false;
715
+ }
716
+
717
+ const changed = getCurrentModelSelector(ctx) !== selector;
718
+ const ok = changed ? await pi.setModel(model) : true;
719
+ if (!ok) {
720
+ if (ctx.hasUI) {
721
+ ctx.ui.notify(`No API key available for plan model ${selector}`, "warning");
722
+ }
723
+ return false;
724
+ }
725
+
726
+ if (thinkingLevel) {
727
+ pi.setThinkingLevel(thinkingLevel);
728
+ }
729
+
730
+ return true;
731
+ }
732
+
733
+ async function applyResolvedPlanModel(
734
+ pi: ExtensionAPI,
735
+ ctx: ExtensionContext,
736
+ resolved: { entry: IPlanModelEntry; model: Model<Api> } | undefined,
737
+ ): Promise<{ planModel?: string; planThinking?: ThinkingLevel }> {
738
+ if (!resolved) {
739
+ return {
740
+ planModel: getCurrentModelSelector(ctx),
741
+ planThinking: pi.getThinkingLevel(),
742
+ };
743
+ }
744
+
745
+ const ok = await pi.setModel(resolved.model);
746
+ if (!ok) {
747
+ if (ctx.hasUI) {
748
+ ctx.ui.notify(`No API key available for plan model ${resolved.entry.model}`, "warning");
749
+ }
750
+ return {
751
+ planModel: getCurrentModelSelector(ctx),
752
+ planThinking: pi.getThinkingLevel(),
753
+ };
754
+ }
755
+
756
+ if (resolved.entry.thinkingLevel) {
757
+ pi.setThinkingLevel(resolved.entry.thinkingLevel);
758
+ }
759
+
760
+ return {
761
+ planModel: formatModelSelector(resolved.model),
762
+ planThinking: resolved.entry.thinkingLevel ?? pi.getThinkingLevel(),
763
+ };
764
+ }
765
+
766
+ async function restoreSourceModel(
767
+ pi: ExtensionAPI,
768
+ ctx: ExtensionContext,
769
+ state: IPlanSessionState | undefined,
770
+ ): Promise<void> {
771
+ if (!state) {
772
+ return;
773
+ }
774
+
775
+ const restored = await applyModelSelector(pi, ctx, state.sourceModel, state.sourceThinking);
776
+ if (!restored && state.sourceThinking) {
777
+ pi.setThinkingLevel(state.sourceThinking);
778
+ }
779
+ }
780
+
781
+ async function syncPlanState(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
782
+ const nextState = getPlanState(ctx);
783
+ const previousState = currentPlanState;
784
+ currentPlanState = nextState;
785
+ setPlanWidget(ctx, nextState);
786
+
787
+ if (nextState?.active) {
788
+ const currentSelector = getCurrentModelSelector(ctx);
789
+ const currentThinking = pi.getThinkingLevel();
790
+ if (nextState.planModel !== currentSelector || nextState.planThinking !== currentThinking) {
791
+ await applyModelSelector(pi, ctx, nextState.planModel, nextState.planThinking);
792
+ }
793
+ return;
794
+ }
795
+
796
+ if (previousState?.active) {
797
+ await restoreSourceModel(pi, ctx, previousState);
798
+ }
799
+ }
800
+
801
+ function formatModelChain(entries: IPlanModelEntry[]): string {
802
+ if (entries.length === 0) {
803
+ return "(none)";
804
+ }
805
+ return entries
806
+ .map((entry) => entry.thinkingLevel ? `${entry.model} (thinking: ${entry.thinkingLevel})` : entry.model)
807
+ .join(" -> ");
808
+ }
809
+
810
+ export async function startPlanningWorkflow(params: IPlanningStartParams): Promise<boolean> {
811
+ const { pi, ctx, mode, artifactPath, initialPrompt } = params;
812
+ if (getPlanState(ctx)) {
813
+ if (ctx.hasUI) {
814
+ ctx.ui.notify("A planning session is already active. End it before starting another one.", "warning");
815
+ }
816
+ return false;
817
+ }
818
+
819
+ const sourceModel = getCurrentModelSelector(ctx);
820
+ const sourceThinking = pi.getThinkingLevel();
821
+ const policyResult = readPlanPolicy(ctx.cwd);
822
+ if (policyResult.error && ctx.hasUI) {
823
+ ctx.ui.notify(policyResult.error, "warning");
824
+ }
825
+
826
+ const { profileName, modelEntries } = resolveEffectivePlanPolicy({
827
+ basePolicy: policyResult.policy,
828
+ sourceModel,
829
+ });
830
+ const resolvedPlanModel = await resolveConfiguredPlanModel(ctx, modelEntries);
831
+ if (!resolvedPlanModel && modelEntries.length > 0) {
832
+ ctx.ui.notify(
833
+ `No configured planning models could be resolved (${formatModelChain(modelEntries)}). Staying on the current model.`,
834
+ "warning",
835
+ );
836
+ }
837
+
838
+ try {
839
+ await ensureArtifactFile(artifactPath, params.task ?? mode);
840
+ } catch (error) {
841
+ if (ctx.hasUI) {
842
+ const message = error instanceof Error ? error.message : String(error);
843
+ ctx.ui.notify(`Failed to create plan artifact ${artifactPath}: ${message}`, "error");
844
+ }
845
+ return false;
846
+ }
847
+
848
+ let originId = ctx.sessionManager.getLeafId() ?? undefined;
849
+ if (!originId) {
850
+ pi.appendEntry(PLAN_ANCHOR_TYPE, { createdAt: new Date().toISOString() });
851
+ originId = ctx.sessionManager.getLeafId() ?? undefined;
852
+ }
853
+
854
+ if (!originId) {
855
+ if (ctx.hasUI) {
856
+ ctx.ui.notify("Failed to determine the current branch position for the planning workflow.", "error");
857
+ }
858
+ return false;
859
+ }
860
+
861
+ let branchStartTargetId = getLatestUserEntryId(ctx);
862
+ if (branchStartTargetId && branchStartTargetId === originId) {
863
+ pi.appendEntry(PLAN_ANCHOR_TYPE, { createdAt: new Date().toISOString() });
864
+ originId = ctx.sessionManager.getLeafId() ?? originId;
865
+ }
866
+
867
+ if (branchStartTargetId && branchStartTargetId !== originId) {
868
+ try {
869
+ const result = await ctx.navigateTree(branchStartTargetId, {
870
+ summarize: false,
871
+ label: PLAN_BRANCH_LABEL,
872
+ });
873
+ if (result.cancelled) {
874
+ return false;
875
+ }
876
+ } catch (error) {
877
+ if (ctx.hasUI) {
878
+ const message = error instanceof Error ? error.message : String(error);
879
+ ctx.ui.notify(`Failed to start the planning branch: ${message}`, "error");
880
+ }
881
+ return false;
882
+ }
883
+ }
884
+
885
+ const branchStartId = ctx.sessionManager.getLeafId() ?? branchStartTargetId;
886
+ const appliedPlanModel = await applyResolvedPlanModel(pi, ctx, resolvedPlanModel);
887
+ const state: IPlanSessionState = {
888
+ active: true,
889
+ mode,
890
+ originId,
891
+ branchStartId,
892
+ artifactPath,
893
+ task: params.task,
894
+ sourceModel,
895
+ sourceThinking,
896
+ planModel: appliedPlanModel.planModel,
897
+ planThinking: appliedPlanModel.planThinking,
898
+ startedAt: new Date().toISOString(),
899
+ };
900
+
901
+ appendPlanState(pi, state);
902
+ currentPlanState = state;
903
+ setPlanWidget(ctx, state);
904
+
905
+ if (ctx.hasUI) {
906
+ const profileHint = profileName ? ` (profile: ${profileName})` : "";
907
+ const modeLabel = mode === "architect" ? "Architect mode" : "Plan mode";
908
+ ctx.ui.notify(`${modeLabel} active${profileHint}. Writing updates to ${artifactPath}`, "info");
909
+ }
910
+
911
+ pi.sendUserMessage(initialPrompt);
912
+ return true;
913
+ }
914
+
915
+ async function planHandler(pi: ExtensionAPI, ctx: ExtensionCommandContext, args: string): Promise<void> {
916
+ const task = parsePlanTask(args) ?? undefined;
917
+ const artifactPath = createPlanArtifactPath({
918
+ cwd: ctx.cwd,
919
+ task: task ?? "plan",
920
+ date: new Date(),
921
+ });
922
+
923
+ await startPlanningWorkflow({
924
+ pi,
925
+ ctx,
926
+ mode: "plan",
927
+ task,
928
+ artifactPath,
929
+ initialPrompt: buildInitialPlanPrompt(task),
930
+ });
931
+ }
932
+
933
+ export async function endPlanningWorkflow(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
934
+ const state = getPlanState(ctx);
935
+ if (!state?.active || !state.originId) {
936
+ if (ctx.hasUI) {
937
+ ctx.ui.notify("No active planning session was found on the current branch.", "info");
938
+ }
939
+ return;
940
+ }
941
+
942
+ clearPlanState(pi);
943
+
944
+ try {
945
+ const result = await ctx.navigateTree(state.originId, {
946
+ summarize: true,
947
+ customInstructions: END_PLAN_SUMMARY_PROMPT,
948
+ replaceInstructions: true,
949
+ label: PLAN_BRANCH_LABEL,
950
+ });
951
+
952
+ if (result.cancelled) {
953
+ currentPlanState = undefined;
954
+ setPlanWidget(ctx, undefined);
955
+ await restoreSourceModel(pi, ctx, state);
956
+ if (ctx.hasUI) {
957
+ ctx.ui.notify("Planning session ended in place after navigation was cancelled.", "info");
958
+ }
959
+ return;
960
+ }
961
+ } catch (error) {
962
+ currentPlanState = undefined;
963
+ setPlanWidget(ctx, undefined);
964
+ await restoreSourceModel(pi, ctx, state);
965
+ if (ctx.hasUI) {
966
+ const message = error instanceof Error ? error.message : String(error);
967
+ ctx.ui.notify(`Failed to return from the planning branch: ${message}`, "error");
968
+ }
969
+ return;
970
+ }
971
+
972
+ currentPlanState = undefined;
973
+ setPlanWidget(ctx, undefined);
974
+ await restoreSourceModel(pi, ctx, state);
975
+
976
+ if (ctx.hasUI) {
977
+ const artifactSuffix = state.artifactPath ? ` Artifact: ${state.artifactPath}` : "";
978
+ ctx.ui.notify(`Planning session ended.${artifactSuffix}`, "info");
979
+ }
980
+ }
981
+
982
+ async function collectAskUserResponse(ctx: ExtensionContext, request: { title: string; message: string; questions: IAskUserQuestion[] }): Promise<IAskUserResponse | undefined> {
983
+ if (!ctx.hasUI) {
984
+ return undefined;
985
+ }
986
+
987
+ const answers: Record<string, string | boolean | string[]> = {};
988
+
989
+ for (const question of request.questions) {
990
+ if (question.type === "boolean") {
991
+ const confirmed = await ctx.ui.confirm(question.label, question.description ?? request.message);
992
+ answers[question.id] = confirmed;
993
+ continue;
994
+ }
995
+
996
+ if (question.type === "singleSelect" && question.options && question.options.length > 0) {
997
+ const selectedLabel = await ctx.ui.select(
998
+ question.label,
999
+ question.options.map((option) => option.label),
1000
+ );
1001
+ const selected = question.options.find((option) => option.label === selectedLabel);
1002
+ if (selected) {
1003
+ answers[question.id] = selected.value;
1004
+ }
1005
+ continue;
1006
+ }
1007
+
1008
+ const prefill = Array.isArray(question.defaultValue)
1009
+ ? question.defaultValue.join(", ")
1010
+ : typeof question.defaultValue === "string"
1011
+ ? question.defaultValue
1012
+ : "";
1013
+ const input = await ctx.ui.editor(question.label, prefill);
1014
+ if (input === undefined) {
1015
+ return {
1016
+ action: "cancel",
1017
+ answers,
1018
+ };
1019
+ }
1020
+
1021
+ if (question.type === "multiSelect") {
1022
+ answers[question.id] = input
1023
+ .split(",")
1024
+ .map((value) => value.trim())
1025
+ .filter(Boolean);
1026
+ } else {
1027
+ answers[question.id] = input.trim();
1028
+ }
1029
+ }
1030
+
1031
+ return {
1032
+ action: "submit",
1033
+ answers,
1034
+ };
1035
+ }
1036
+
1037
+ async function planPolicyHandler(_pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
1038
+ const branchState = getPlanState(ctx);
1039
+ const sourceModel = branchState?.sourceModel ?? getCurrentModelSelector(ctx);
1040
+ const currentModel = getCurrentModelSelector(ctx);
1041
+ const policyResult = readPlanPolicy(ctx.cwd);
1042
+ if (policyResult.error) {
1043
+ ctx.ui.notify(policyResult.error, "warning");
1044
+ }
1045
+
1046
+ const { profileName, modelEntries } = resolveEffectivePlanPolicy({
1047
+ basePolicy: policyResult.policy,
1048
+ sourceModel,
1049
+ });
1050
+ const resolved = await resolveConfiguredPlanModel(ctx, modelEntries);
1051
+ const resolvedSelector = resolved ? formatModelSelector(resolved.model) : branchState?.planModel ?? currentModel ?? "(unresolved)";
1052
+ const resolvedThinking = resolved?.entry.thinkingLevel ?? branchState?.planThinking;
1053
+
1054
+ const lines = [
1055
+ `status: ${branchState?.active ? "active" : "inactive"}`,
1056
+ `config: ${policyResult.path ?? "(none)"}`,
1057
+ `source model: ${sourceModel ?? "unknown"}`,
1058
+ `current model: ${currentModel ?? "unknown"}`,
1059
+ `profile: ${profileName ?? "none"}`,
1060
+ `configured models: ${formatModelChain(modelEntries)}`,
1061
+ `resolved planning model: ${resolvedThinking ? `${resolvedSelector} (thinking: ${resolvedThinking})` : resolvedSelector}`,
1062
+ ];
1063
+
1064
+ if (branchState?.artifactPath) {
1065
+ lines.push(`artifact: ${branchState.artifactPath}`);
1066
+ }
1067
+
1068
+ ctx.ui.notify(lines.join("\n"), "info");
1069
+ }
1070
+
1071
+ function handleToolCallDuringPlan(event: ToolCallEvent, ctx: ExtensionContext): { block: boolean; reason: string } | undefined {
1072
+ if (!getPlanState(ctx)?.active) {
1073
+ return undefined;
1074
+ }
1075
+
1076
+ if (event.toolName === "edit" || event.toolName === "write") {
1077
+ return {
1078
+ block: true,
1079
+ reason: buildBlockedToolReason(`The ${event.toolName} tool is disabled during planning.`),
1080
+ };
1081
+ }
1082
+
1083
+ if (event.toolName === "bash" && isRecord(event.input) && typeof event.input.command === "string") {
1084
+ if (isPotentiallyMutatingShellCommand(event.input.command)) {
1085
+ return {
1086
+ block: true,
1087
+ reason: buildBlockedToolReason(`Mutating bash commands are disabled during planning (${event.input.command}).`),
1088
+ };
1089
+ }
1090
+ }
1091
+
1092
+ if (event.toolName === "todo" && isRecord(event.input) && typeof event.input.action === "string") {
1093
+ if (shouldBlockTodoAction(event.input.action)) {
1094
+ return {
1095
+ block: true,
1096
+ reason: buildBlockedToolReason(`Todo action "${event.input.action}" is disabled during planning.`),
1097
+ };
1098
+ }
1099
+ }
1100
+
1101
+ return undefined;
1102
+ }
1103
+
1104
+ function handleUserBashDuringPlan(event: UserBashEvent, ctx: ExtensionContext) {
1105
+ if (!getPlanState(ctx)?.active) {
1106
+ return undefined;
1107
+ }
1108
+
1109
+ if (!isPotentiallyMutatingShellCommand(event.command)) {
1110
+ return undefined;
1111
+ }
1112
+
1113
+ return buildBlockedUserBashResult(`Mutating shell commands are disabled during planning (${event.command}).`);
1114
+ }
1115
+
1116
+ export default function planExtension(pi: ExtensionAPI) {
1117
+ pi.registerCommand("plan", {
1118
+ description: "Start a planning-only branch with an optional dedicated planning model",
1119
+ handler: async(args, ctx) => {
1120
+ await planHandler(pi, ctx, args);
1121
+ },
1122
+ });
1123
+
1124
+ pi.registerCommand("end-plan", {
1125
+ description: "Leave plan mode, summarize the plan branch, and return to the original branch",
1126
+ handler: async(_args, ctx) => {
1127
+ await endPlanningWorkflow(pi, ctx);
1128
+ },
1129
+ });
1130
+
1131
+ pi.registerCommand("end-architect", {
1132
+ description: "Leave architect mode, summarize the architect branch, and return to the original branch",
1133
+ handler: async(_args, ctx) => {
1134
+ await endPlanningWorkflow(pi, ctx);
1135
+ },
1136
+ });
1137
+
1138
+ pi.registerCommand("plan-policy", {
1139
+ description: "Show effective planning-model policy and the resolved planning model",
1140
+ handler: async(_args, ctx) => {
1141
+ await planPolicyHandler(pi, ctx);
1142
+ },
1143
+ });
1144
+
1145
+ pi.on("before_agent_start", (event, ctx) => {
1146
+ const state = getPlanState(ctx);
1147
+ if (!state?.active) {
1148
+ return;
1149
+ }
1150
+
1151
+ return {
1152
+ systemPrompt: [event.systemPrompt, buildPlanPromptOverlay(state)].filter(Boolean).join("\n\n"),
1153
+ };
1154
+ });
1155
+
1156
+ pi.on("tool_call", (event, ctx) => {
1157
+ return handleToolCallDuringPlan(event, ctx);
1158
+ });
1159
+
1160
+ pi.on("user_bash", (event, ctx) => {
1161
+ return handleUserBashDuringPlan(event, ctx);
1162
+ });
1163
+
1164
+ pi.on("agent_end", async(event, ctx) => {
1165
+ const state = getPlanState(ctx);
1166
+ if (state?.active) {
1167
+ const text = extractLastAssistantText(event.messages ?? []);
1168
+ const askUserRequest = text ? parseAskUserRequestFromText(text) : undefined;
1169
+ if (askUserRequest && ctx.hasUI) {
1170
+ const response = await collectAskUserResponse(ctx, askUserRequest);
1171
+ if (response) {
1172
+ pi.sendUserMessage(formatAskUserResponsePrompt(response));
1173
+ }
1174
+ return;
1175
+ }
1176
+ }
1177
+
1178
+ await writePlanArtifactFromEvent(event, ctx);
1179
+ });
1180
+
1181
+ pi.on("session_start", async(_event, ctx) => {
1182
+ await syncPlanState(pi, ctx);
1183
+ });
1184
+
1185
+ pi.on("session_switch", async(_event, ctx) => {
1186
+ await syncPlanState(pi, ctx);
1187
+ });
1188
+
1189
+ pi.on("session_tree", async(_event, ctx) => {
1190
+ await syncPlanState(pi, ctx);
1191
+ });
1192
+
1193
+ pi.on("session_shutdown", async(_event, ctx) => {
1194
+ currentPlanState = undefined;
1195
+ setPlanWidget(ctx, undefined);
1196
+ });
1197
+ }