@mccune1224/pi-pstack 0.1.0 → 0.2.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.
Files changed (126) hide show
  1. package/README.md +52 -100
  2. package/agents/comment-sicko.md +1 -1
  3. package/agents/poteto-agent.md +2 -2
  4. package/docs/compare.md +122 -0
  5. package/docs/guide/01-setup.md +37 -12
  6. package/docs/guide/02-poteto-mode.md +1 -1
  7. package/docs/guide/03-understand.md +0 -8
  8. package/docs/guide/05-build-and-clean.md +1 -1
  9. package/docs/guide/06-verify-and-ship.md +1 -1
  10. package/docs/guide/07-overnight.md +2 -2
  11. package/docs/guide/08-principles.md +4 -2
  12. package/docs/guide/10-recipes-and-pitfalls.md +2 -2
  13. package/docs/guide/README.md +2 -2
  14. package/extensions/compaction.test.ts +132 -0
  15. package/extensions/compaction.ts +183 -0
  16. package/extensions/context-guard.test.ts +53 -0
  17. package/extensions/context-guard.ts +65 -0
  18. package/extensions/doctor.test.ts +85 -0
  19. package/extensions/doctor.ts +100 -0
  20. package/extensions/index.ts +289 -26
  21. package/extensions/settings.test.ts +71 -16
  22. package/extensions/settings.ts +57 -9
  23. package/extensions/skill-budget.test.ts +110 -0
  24. package/extensions/skill-budget.ts +101 -0
  25. package/package.json +9 -4
  26. package/scripts/check-pi-isms.mjs +106 -0
  27. package/scripts/lib/pi-delta-rules.mjs +110 -0
  28. package/scripts/lib/pi-delta-rules.test.mjs +100 -0
  29. package/scripts/lib/upstream-vendor.mjs +32 -0
  30. package/scripts/pi-deltas.json +754 -0
  31. package/scripts/sync-upstream.mjs +117 -0
  32. package/scripts/verify-guards.mjs +132 -0
  33. package/scripts/verify-sync.mjs +206 -0
  34. package/skills/architect/SKILL.md +10 -10
  35. package/skills/architect/references/rationale-template.md +2 -2
  36. package/skills/architect/references/runner-prompt.md +7 -7
  37. package/skills/arena/SKILL.md +11 -11
  38. package/skills/automate-me/SKILL.md +21 -26
  39. package/skills/blast-radius/SKILL.md +5 -5
  40. package/skills/create-verification-skill/SKILL.md +5 -5
  41. package/skills/figure-it-out/SKILL.md +11 -13
  42. package/skills/how/SKILL.md +30 -104
  43. package/skills/how/references/explainer-prompt.md +6 -6
  44. package/skills/how/references/explorer-prompt.md +1 -1
  45. package/skills/interrogate/SKILL.md +13 -14
  46. package/skills/interrogate/references/code-quality-review.md +1 -1
  47. package/skills/interrogate/references/lead-judgment.md +1 -1
  48. package/skills/interrogate/references/rubric.md +2 -2
  49. package/skills/maintain-verification-skill/SKILL.md +5 -5
  50. package/skills/no-comments/SKILL.md +3 -3
  51. package/skills/poteto-mode/SKILL.md +9 -8
  52. package/skills/poteto-mode/playbooks/authoring-a-skill.md +4 -4
  53. package/skills/poteto-mode/playbooks/autonomous-run.md +4 -4
  54. package/skills/poteto-mode/playbooks/autopilot-full.md +9 -9
  55. package/skills/poteto-mode/playbooks/autopilot-stack.md +8 -8
  56. package/skills/poteto-mode/playbooks/babysit.md +17 -17
  57. package/skills/poteto-mode/playbooks/bug-fix.md +6 -6
  58. package/skills/poteto-mode/playbooks/eval.md +6 -8
  59. package/skills/poteto-mode/playbooks/feature.md +7 -7
  60. package/skills/poteto-mode/playbooks/hillclimb.md +10 -10
  61. package/skills/poteto-mode/playbooks/investigation.md +3 -3
  62. package/skills/poteto-mode/playbooks/multi-phase-plan.md +26 -25
  63. package/skills/poteto-mode/playbooks/opening-a-pr.md +17 -13
  64. package/skills/poteto-mode/playbooks/orchestrate.md +37 -39
  65. package/skills/poteto-mode/playbooks/pause-safely.md +4 -4
  66. package/skills/poteto-mode/playbooks/perf-issue.md +9 -9
  67. package/skills/poteto-mode/playbooks/prototype.md +5 -5
  68. package/skills/poteto-mode/playbooks/refactoring.md +9 -9
  69. package/skills/poteto-mode/playbooks/runtime-forensics.md +3 -3
  70. package/skills/poteto-mode/playbooks/session-pickup.md +4 -6
  71. package/skills/poteto-mode/playbooks/shipping.md +11 -14
  72. package/skills/poteto-mode/playbooks/trace-forensics.md +4 -4
  73. package/skills/poteto-mode/playbooks/visual-parity.md +3 -3
  74. package/skills/poteto-mode/playbooks/worktree-cleanup.md +4 -4
  75. package/skills/poteto-mode/references/bugbot-triage.md +3 -3
  76. package/skills/poteto-mode/scripts/worktree-audit.mjs +0 -7
  77. package/skills/principle-attack-the-premise/SKILL.md +23 -0
  78. package/skills/principle-boundary-discipline/SKILL.md +2 -2
  79. package/skills/principle-build-the-lever/SKILL.md +5 -5
  80. package/skills/principle-encode-lessons-in-structure/SKILL.md +3 -3
  81. package/skills/principle-experience-first/SKILL.md +5 -5
  82. package/skills/principle-fix-root-causes/SKILL.md +4 -4
  83. package/skills/principle-foundational-thinking/SKILL.md +3 -3
  84. package/skills/principle-guard-the-context-window/SKILL.md +2 -2
  85. package/skills/principle-laziness-protocol/SKILL.md +2 -2
  86. package/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +1 -1
  87. package/skills/principle-minimize-reader-load/SKILL.md +2 -2
  88. package/skills/principle-model-the-domain/SKILL.md +3 -3
  89. package/skills/principle-never-block-on-the-human/SKILL.md +4 -5
  90. package/skills/principle-prove-it-works/SKILL.md +3 -3
  91. package/skills/principle-redesign-from-first-principles/SKILL.md +3 -3
  92. package/skills/principle-separate-before-serializing-shared-state/SKILL.md +3 -3
  93. package/skills/principle-sequence-verifiable-units/SKILL.md +3 -3
  94. package/skills/principle-subtract-before-you-add/SKILL.md +3 -4
  95. package/skills/principle-test-behavior-not-implementation/SKILL.md +25 -0
  96. package/skills/principle-type-system-discipline/SKILL.md +5 -5
  97. package/skills/recall/SKILL.md +7 -7
  98. package/skills/reflect/SKILL.md +14 -20
  99. package/skills/reflect/references/divergent-reviewer.md +3 -3
  100. package/skills/reflect/references/judgment-reviewer.md +3 -3
  101. package/skills/reflect/references/synthesizer.md +2 -2
  102. package/skills/reflect/references/tooling-reviewer.md +4 -6
  103. package/skills/setup-pstack/SKILL.md +61 -49
  104. package/skills/show-me-your-work/SKILL.md +17 -17
  105. package/skills/show-me-your-work/scripts/log.mjs +2 -8
  106. package/skills/swarm/SKILL.md +6 -6
  107. package/skills/tdd/SKILL.md +2 -2
  108. package/skills/teach/SKILL.md +7 -7
  109. package/skills/technical-writing/SKILL.md +5 -8
  110. package/skills/typescript-best-practices/SKILL.md +6 -3
  111. package/skills/typescript-best-practices/references/patterns.md +35 -14
  112. package/skills/unslop/SKILL.md +8 -20
  113. package/skills/why/SKILL.md +27 -100
  114. package/skills/why/references/epistemics.md +6 -6
  115. package/skills/why/references/investigator-prompt.md +5 -5
  116. package/skills/why/references/source-playbook.md +1 -1
  117. package/skills/why/references/sources/databricks.md +7 -7
  118. package/skills/why/references/sources/datadog.md +5 -5
  119. package/skills/why/references/sources/incident-postmortem.md +2 -2
  120. package/skills/why/references/sources/linear.md +2 -2
  121. package/skills/why/references/sources/notion.md +3 -3
  122. package/skills/why/references/sources/sentry.md +4 -4
  123. package/skills/why/references/sources/slack.md +2 -2
  124. package/skills/why/references/synthesizer-prompt.md +3 -3
  125. package/skills/how/references/critic-prompt.md +0 -59
  126. package/skills/how/references/critique-rubric.md +0 -58
@@ -1,17 +1,48 @@
1
- import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import {
4
+ CONFIG_DIR_NAME,
5
+ type ExtensionAPI,
6
+ type ExtensionCommandContext,
7
+ type ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { analyzeSkillBudget, readSkillSources } from "./skill-budget.ts";
10
+ import {
11
+ POTETO_COMPACT_INSTRUCTIONS,
12
+ loadConversationHelpers,
13
+ runPotetoCompaction,
14
+ } from "./compaction.ts";
15
+ import {
16
+ type DoctorCheck,
17
+ checkAgentFrontmatter,
18
+ checkModelMap,
19
+ renderReport,
20
+ } from "./doctor.ts";
21
+ import {
22
+ guardAdvice,
23
+ guardLevel,
24
+ isStronger,
25
+ meterLine,
26
+ type GuardLevel,
27
+ } from "./context-guard.ts";
28
+ import {
29
+ BUDGETS,
3
30
  MODE_ENTRY_TYPE,
4
31
  POTETO_DIRECTIVE,
5
32
  ROLE_KEYS,
6
33
  availableModels,
34
+ applyBudget,
7
35
  buildTier,
8
36
  lastModeEnabled,
9
37
  loadSettings,
10
38
  parseArgs,
11
39
  parseModelId,
40
+ resolvedMapping,
12
41
  saveSettings,
13
42
  settingsPath,
14
43
  summaryText,
44
+ budgetLabel,
45
+ type BudgetKey,
15
46
  type ModeEntry,
16
47
  type ModelMap,
17
48
  type ModelSpec,
@@ -19,6 +50,22 @@ import {
19
50
  type Scope,
20
51
  } from "./settings.ts";
21
52
 
53
+ const SKILLS_DIR = join(import.meta.dirname, "..", "skills");
54
+ const AGENTS_DIR = join(import.meta.dirname, "..", "agents");
55
+ const DELTAS_PATH = join(import.meta.dirname, "..", "scripts", "pi-deltas.json");
56
+
57
+ const PACKAGE_AGENTS = ["poteto-agent.md", "comment-sicko.md"];
58
+
59
+ const PLAYBOOK_COMPLETIONS: Array<{ value: string; label: string }> = (() => {
60
+ try {
61
+ return readdirSync(join(SKILLS_DIR, "poteto-mode", "playbooks"))
62
+ .filter((file) => file.endsWith(".md"))
63
+ .map((file) => ({ value: file.replace(/\.md$/, ""), label: file.replace(/\.md$/, "") }));
64
+ } catch {
65
+ return [];
66
+ }
67
+ })();
68
+
22
69
  const SETUP_COMPLETIONS = [
23
70
  "-l",
24
71
  "--local",
@@ -27,6 +74,10 @@ const SETUP_COMPLETIONS = [
27
74
  "all-inherit",
28
75
  "light",
29
76
  "custom",
77
+ "unlimited",
78
+ "large",
79
+ "medium",
80
+ "small",
30
81
  "default",
31
82
  "scout",
32
83
  "worker",
@@ -101,6 +152,21 @@ function pstackSkillNames(commands: Array<{ name: string; source: string }>): st
101
152
  .sort();
102
153
  }
103
154
 
155
+ function effectiveModelMap(cwd: string): { map: ModelMap; scope: Scope | "none"; path: string } {
156
+ const userPath = settingsPath(cwd, "user", CONFIG_DIR_NAME);
157
+ const projectPath = settingsPath(cwd, "project", CONFIG_DIR_NAME);
158
+ const projectMap = loadSettings(projectPath);
159
+ if (Object.keys(projectMap).length > 0) {
160
+ return { map: projectMap, scope: "project", path: projectPath };
161
+ }
162
+ const userMap = loadSettings(userPath);
163
+ return {
164
+ map: userMap,
165
+ scope: Object.keys(userMap).length > 0 ? "user" : "none",
166
+ path: userPath,
167
+ };
168
+ }
169
+
104
170
  function modelChoices(ctx: ExtensionCommandContext, currentValue: string) {
105
171
  return availableModels(ctx.modelRegistry.getAvailable()).map((choice) =>
106
172
  choice.id === currentValue ? `${choice.label} [current]` : choice.label,
@@ -124,19 +190,34 @@ async function announceAndReload(
124
190
 
125
191
  async function pstackSetupHandler(args: string, ctx: ExtensionCommandContext): Promise<void> {
126
192
  const parsed = parseArgs(args);
127
- const scopePick =
128
- parsed.scope ??
129
- (ctx.hasUI
193
+ let scope: Scope | undefined = parsed.scope;
194
+ if (!scope) {
195
+ const label = ctx.hasUI
130
196
  ? await ctx.ui.select("Where to save the pstack model map?", [
131
197
  "User (~/.pi/agent/settings.json) - recommended",
132
198
  "Project (.pi/settings.json) - team shared",
133
199
  ])
134
- : "user");
135
- if (!scopePick) return;
136
- const scope: Scope = scopePick.startsWith("Project") ? "project" : "user";
200
+ : "user";
201
+ if (!label) return;
202
+ scope = label.startsWith("Project") ? "project" : "user";
203
+ }
137
204
  const path = settingsPath(ctx.cwd, scope, CONFIG_DIR_NAME);
138
205
  const current = loadSettings(path);
139
206
 
207
+ // Fast paths (-l, a tier, or a bare role) skip the budget question, mirroring
208
+ // upstream's budget-first flow only when the run is fully interactive.
209
+ const hasFastPath = parsed.tier !== undefined || parsed.role !== undefined;
210
+ let budget: BudgetKey | undefined = parsed.budget;
211
+ if (!budget && ctx.hasUI && !hasFastPath) {
212
+ const pick = await ctx.ui.select(
213
+ `Reasoning budget for roles without their own level - now ${budgetLabel(current)}`,
214
+ Object.entries(BUDGETS).map(([key, spec]) => `${key} - ${spec.blurb}`),
215
+ );
216
+ if (!pick) return;
217
+ budget = pick.split(" ")[0] as BudgetKey;
218
+ }
219
+ const withBudget = (map: ModelMap): ModelMap => (budget ? applyBudget(map, budget) : map);
220
+
140
221
  if (parsed.role) {
141
222
  const currentValue =
142
223
  parsed.role === "default"
@@ -157,8 +238,8 @@ async function pstackSetupHandler(args: string, ctx: ExtensionCommandContext): P
157
238
  [parsed.role]: { ...current.agentOverrides?.[parsed.role], model: id },
158
239
  };
159
240
  }
160
- saveSettings(path, map);
161
- await announceAndReload(ctx, path, map, scope);
241
+ saveSettings(path, withBudget(map));
242
+ await announceAndReload(ctx, path, withBudget(map), scope);
162
243
  return;
163
244
  }
164
245
 
@@ -167,7 +248,7 @@ async function pstackSetupHandler(args: string, ctx: ExtensionCommandContext): P
167
248
  ctx.ui.notify(summaryText(current, "none", []), "info");
168
249
  const pick = await ctx.ui.select("Pick a tier", [
169
250
  "All inherit (cheapest) - every role uses the parent model (Recommended)",
170
- "Light tier (scout low, oracle fallback, agents high thinking)",
251
+ "Light tier (scout low thinking, every model still inherits the parent)",
171
252
  "Custom per role",
172
253
  ]);
173
254
  if (!pick) return;
@@ -199,6 +280,7 @@ async function pstackSetupHandler(args: string, ctx: ExtensionCommandContext): P
199
280
  } else {
200
281
  map = buildTier(tier);
201
282
  }
283
+ map = withBudget(map);
202
284
  saveSettings(path, map);
203
285
  await announceAndReload(ctx, path, map, scope);
204
286
  }
@@ -208,16 +290,13 @@ async function pstackStatusHandler(
208
290
  _args: string,
209
291
  ctx: ExtensionCommandContext,
210
292
  ): Promise<void> {
211
- const userPath = settingsPath(ctx.cwd, "user", CONFIG_DIR_NAME);
212
- const projectPath = settingsPath(ctx.cwd, "project", CONFIG_DIR_NAME);
213
- const userMap = loadSettings(userPath);
214
- const projectMap = loadSettings(projectPath);
215
- const projectWins = Object.keys(projectMap).length > 0;
216
- const effective = projectWins ? projectMap : userMap;
217
- const scope = projectWins ? "project" : Object.keys(userMap).length > 0 ? "user" : "none";
293
+ const effective = effectiveModelMap(ctx.cwd);
294
+ const budget = analyzeSkillBudget(readSkillSources(SKILLS_DIR));
218
295
  const text =
219
- summaryText(effective, scope, pstackSkillNames(pi.getCommands())) +
220
- `\nsettings: ${projectWins ? projectPath : userPath}`;
296
+ summaryText(effective.map, effective.scope, pstackSkillNames(pi.getCommands())) +
297
+ `\nsettings: ${effective.path}` +
298
+ `\nprompt cost: ${budget.modelInvocable} of ${budget.total} pstack skills exposed to the model,` +
299
+ ` ${budget.filtered} filtered by disable-model-invocation, ~${budget.estimatedTokens} tokens`;
221
300
  const parent =
222
301
  ctx.model && typeof ctx.model === "object"
223
302
  ? `${(ctx.model as { provider?: string }).provider}/${(ctx.model as { id?: string }).id}`
@@ -252,6 +331,106 @@ function textContent(content: unknown): string | undefined {
252
331
  return parts.length > 0 ? parts.join("\n") : undefined;
253
332
  }
254
333
 
334
+ function upstreamPin(): { ref: string; version: string } | undefined {
335
+ try {
336
+ const spec = JSON.parse(readFileSync(DELTAS_PATH, "utf8")) as {
337
+ upstream?: { ref?: string; version?: string };
338
+ };
339
+ if (!spec.upstream?.ref || !spec.upstream.version) return undefined;
340
+ return { ref: spec.upstream.ref, version: spec.upstream.version };
341
+ } catch {
342
+ return undefined;
343
+ }
344
+ }
345
+
346
+ function doctorChecks(pi: ExtensionAPI, ctx: ExtensionCommandContext): DoctorCheck[] {
347
+ const checks: DoctorCheck[] = [];
348
+
349
+ for (const scope of ["user", "project"] as const) {
350
+ const path = settingsPath(ctx.cwd, scope, CONFIG_DIR_NAME);
351
+ if (!existsSync(path)) {
352
+ checks.push({ name: `settings (${scope})`, status: "ok", detail: `${path} absent, nothing pinned` });
353
+ continue;
354
+ }
355
+ const problems = checkModelMap(loadSettings(path), ROLE_KEYS);
356
+ checks.push({
357
+ name: `settings (${scope})`,
358
+ status: problems.length > 0 ? "warn" : "ok",
359
+ detail: problems.length > 0 ? problems.join("; ") : `${path} parses clean`,
360
+ });
361
+ }
362
+
363
+ for (const file of PACKAGE_AGENTS) {
364
+ const path = join(AGENTS_DIR, file);
365
+ try {
366
+ const problems = checkAgentFrontmatter(file, readFileSync(path, "utf8"));
367
+ checks.push({
368
+ name: `agent ${file}`,
369
+ status: problems.length > 0 ? "fail" : "ok",
370
+ detail: problems.length > 0 ? problems.join("; ") : "frontmatter registers with pi-subagents",
371
+ });
372
+ } catch {
373
+ checks.push({ name: `agent ${file}`, status: "fail", detail: `${path} is missing` });
374
+ }
375
+ }
376
+
377
+ try {
378
+ const playbooks = readdirSync(join(SKILLS_DIR, "poteto-mode", "playbooks")).filter((f) => f.endsWith(".md"));
379
+ checks.push({
380
+ name: "skills",
381
+ status: existsSync(join(SKILLS_DIR, "poteto-mode", "SKILL.md")) ? "ok" : "fail",
382
+ detail: `poteto-mode present, ${playbooks.length} playbooks vendored under skills/`,
383
+ });
384
+ } catch {
385
+ checks.push({ name: "skills", status: "fail", detail: "skills/poteto-mode is missing" });
386
+ }
387
+
388
+ const toolNames = new Set(pi.getAllTools().map((tool) => tool.name));
389
+ for (const companion of COMPANIONS) {
390
+ const present = toolNames.has(companion.tool);
391
+ checks.push({
392
+ name: `companion ${companion.tool}`,
393
+ status: present ? "ok" : companion.required ? "fail" : "warn",
394
+ detail: present ? "registered" : `absent: install ${companion.pkg}`,
395
+ });
396
+ }
397
+
398
+ const pin = upstreamPin();
399
+ checks.push({
400
+ name: "upstream pin",
401
+ status: pin ? "ok" : "warn",
402
+ detail: pin
403
+ ? `pstack ${pin.version} at ${pin.ref.slice(0, 12)}; npm run verify:sync proves the port matches`
404
+ : "scripts/pi-deltas.json unreadable",
405
+ });
406
+
407
+ const effective = effectiveModelMap(ctx.cwd);
408
+ const parent =
409
+ ctx.model && typeof ctx.model === "object"
410
+ ? `${(ctx.model as { provider?: string }).provider ?? "?"}/${(ctx.model as { id?: string }).id ?? "?"}`
411
+ : "unknown";
412
+ checks.push({
413
+ name: "session",
414
+ status: "ok",
415
+ detail: `parent model ${parent}, map ${effective.scope}, ${budgetLabel(effective.map)}`,
416
+ });
417
+
418
+ return checks;
419
+ }
420
+
421
+ async function pstackDoctorHandler(
422
+ pi: ExtensionAPI,
423
+ _args: string,
424
+ ctx: ExtensionCommandContext,
425
+ ): Promise<void> {
426
+ const report = renderReport(doctorChecks(pi, ctx));
427
+ if (ctx.hasUI) {
428
+ ctx.ui.notify(report, "info");
429
+ } else {
430
+ console.log(report);
431
+ }
432
+ }
433
+
255
434
  async function broHandler(
256
435
  pi: ExtensionAPI,
257
436
  _args: string,
@@ -274,6 +453,8 @@ async function broHandler(
274
453
 
275
454
  export default function (pi: ExtensionAPI): void {
276
455
  let potetoMode = false;
456
+ let guardNotified: GuardLevel = "ok";
457
+ let conversationHelpers: Awaited<ReturnType<typeof loadConversationHelpers>> | "unavailable" | undefined;
277
458
 
278
459
  function sessionEntries(ctx: {
279
460
  sessionManager: { getBranch?: () => ModeEntry[]; getEntries: () => ModeEntry[] };
@@ -283,20 +464,37 @@ export default function (pi: ExtensionAPI): void {
283
464
  : ctx.sessionManager.getEntries();
284
465
  }
285
466
 
286
- function setModeStatus(ctx: import("@earendil-works/pi-coding-agent").ExtensionContext): void {
467
+ function widgetLines(ctx: ExtensionContext): string[] {
468
+ const map = effectiveModelMap(ctx.cwd).map;
469
+ const roles = resolvedMapping(map, ROLE_KEYS);
470
+ const inherited = roles.filter((role) => role.model === "inherit").length;
471
+ const summary =
472
+ inherited === roles.length
473
+ ? `${inherited} inherit`
474
+ : `${inherited}/${roles.length} inherit`;
475
+ return [
476
+ `pstack: poteto mode on | roles: ${summary}`,
477
+ meterLine(ctx.getContextUsage(), budgetLabel(map)),
478
+ ];
479
+ }
480
+
481
+ function renderModeUi(ctx: ExtensionContext): void {
287
482
  if (ctx.mode !== "tui") return;
288
483
  ctx.ui.setStatus("pstack-mode", potetoMode ? "pstack: poteto mode" : undefined);
484
+ ctx.ui.setWidget("pstack", potetoMode ? widgetLines(ctx) : undefined, {
485
+ placement: "belowEditor",
486
+ });
289
487
  }
290
488
 
291
- function persistMode(enabled: boolean, ctx?: import("@earendil-works/pi-coding-agent").ExtensionContext): void {
489
+ function persistMode(enabled: boolean, ctx?: ExtensionContext): void {
292
490
  potetoMode = enabled;
293
491
  pi.appendEntry(MODE_ENTRY_TYPE, { enabled });
294
- if (ctx) setModeStatus(ctx);
492
+ if (ctx) renderModeUi(ctx);
295
493
  }
296
494
 
297
495
  pi.on("session_start", async (_event, ctx) => {
298
496
  potetoMode = lastModeEnabled(sessionEntries(ctx));
299
- setModeStatus(ctx);
497
+ renderModeUi(ctx);
300
498
  const toolNames = new Set(pi.getAllTools().map((tool) => tool.name));
301
499
  const missing = COMPANIONS.filter((companion) => !toolNames.has(companion.tool));
302
500
  if (missing.length === 0) return;
@@ -319,6 +517,62 @@ export default function (pi: ExtensionAPI): void {
319
517
  return { systemPrompt: `${event.systemPrompt}\n\n${POTETO_DIRECTIVE}` };
320
518
  });
321
519
 
520
+ // Context guard: meter the context window while the mode is on, warn as it
521
+ // fills, and offer a one-key poteto compaction when it is nearly full.
522
+ pi.on("agent_end", async (_event, ctx) => {
523
+ if (!potetoMode) return;
524
+ renderModeUi(ctx);
525
+ const percent = ctx.getContextUsage()?.percent ?? null;
526
+ const level = guardLevel(percent);
527
+ if (level === "ok") {
528
+ guardNotified = "ok";
529
+ return;
530
+ }
531
+ if (!isStronger(level, guardNotified)) return;
532
+ guardNotified = level;
533
+ const advice = guardAdvice(level, percent);
534
+ if (level === "compact" && ctx.hasUI) {
535
+ const accepted = await ctx.ui.confirm("Context nearly full", `${advice}\n\nCompact now with the poteto summary?`);
536
+ if (accepted) ctx.compact({ customInstructions: POTETO_COMPACT_INSTRUCTIONS });
537
+ return;
538
+ }
539
+ ctx.ui.notify(advice, "warning");
540
+ });
541
+
542
+ pi.on("session_compact", (_event, ctx) => {
543
+ guardNotified = "ok";
544
+ renderModeUi(ctx);
545
+ });
546
+
547
+ // Poteto compaction: while the mode is on, own the summary shape. Any
548
+ // failure falls back to Pi's default compaction.
549
+ pi.on("session_before_compact", async (event, ctx) => {
550
+ if (!potetoMode) return;
551
+ if (conversationHelpers === undefined) {
552
+ conversationHelpers = (await loadConversationHelpers()) ?? "unavailable";
553
+ }
554
+ if (conversationHelpers === "unavailable") return;
555
+ const helpers = conversationHelpers;
556
+ const result = await runPotetoCompaction(
557
+ event.preparation,
558
+ event.customInstructions,
559
+ event.signal,
560
+ {
561
+ model: ctx.model,
562
+ complete: (model, context, options) =>
563
+ ctx.modelRegistry.complete(
564
+ model as Parameters<typeof ctx.modelRegistry.complete>[0],
565
+ context as Parameters<typeof ctx.modelRegistry.complete>[1],
566
+ options as Parameters<typeof ctx.modelRegistry.complete>[2],
567
+ ),
568
+ serialize: helpers.serialize,
569
+ notify: (message, level) => ctx.ui.notify(message, level ?? "warning"),
570
+ },
571
+ );
572
+ if (!result) return;
573
+ return { compaction: result };
574
+ });
575
+
322
576
  const setupCommand = {
323
577
  description: "Configure pstack subagent models for Pi via TUI pickers (fast paths: -l scope, inherit|light|custom tier, or one role). Writes only the subagents.* keys; default is all-inherit.",
324
578
  getArgumentCompletions: completions,
@@ -326,11 +580,16 @@ export default function (pi: ExtensionAPI): void {
326
580
  };
327
581
  pi.registerCommand("pstack-setup", setupCommand);
328
582
  pi.registerCommand("poteto-mode", {
329
- description: "Enable or disable sticky Poteto Mode. Usage: /poteto-mode [task] | /poteto-mode off",
583
+ description: "Enable or disable sticky Poteto Mode. Usage: /poteto-mode [task or playbook] | /poteto-mode off",
330
584
  getArgumentCompletions: (prefix: string) => {
331
585
  const token = prefix.trim().toLowerCase();
332
- if (!token || "off".startsWith(token)) return [{ value: "off", label: "off" }];
333
- return null;
586
+ if (token.includes(" ")) return null;
587
+ if (!token) return [{ value: "off", label: "off" }, ...PLAYBOOK_COMPLETIONS.slice(0, 20)];
588
+ const items = [
589
+ ...("off".startsWith(token) ? [{ value: "off", label: "off" }] : []),
590
+ ...PLAYBOOK_COMPLETIONS.filter((item) => item.value.startsWith(token)),
591
+ ];
592
+ return items.length > 0 ? items : null;
334
593
  },
335
594
  handler: async (args: string, ctx: ExtensionCommandContext) => {
336
595
  const raw = args.trim();
@@ -351,6 +610,10 @@ export default function (pi: ExtensionAPI): void {
351
610
  description: "Show the effective pstack model map (resolved through defaults), the winning settings file, and loaded pstack skills.",
352
611
  handler: (_args, ctx) => pstackStatusHandler(pi, _args, ctx),
353
612
  });
613
+ pi.registerCommand("pstack-doctor", {
614
+ description: "Check the pstack install: settings validity, package agents, vendored playbooks, companion tools, and the upstream pin.",
615
+ handler: (_args, ctx) => pstackDoctorHandler(pi, _args, ctx),
616
+ });
354
617
  pi.registerCommand("bro", {
355
618
  description: "Restate the last assistant reply in plain human language, no jargon.",
356
619
  handler: (_args, ctx) => broHandler(pi, _args, ctx),
@@ -5,6 +5,9 @@ import { join } from "node:path";
5
5
  import { test } from "node:test";
6
6
  import {
7
7
  POTETO_DIRECTIVE,
8
+ applyBudget,
9
+ budgetLabel,
10
+ budgetOf,
8
11
  buildTier,
9
12
  lastModeEnabled,
10
13
  loadSettings,
@@ -12,16 +15,53 @@ import {
12
15
  parseModelId,
13
16
  resolvedMapping,
14
17
  saveSettings,
18
+ summaryText,
15
19
  type ModeEntry,
16
20
  } from "./settings.ts";
17
21
 
18
22
  test("parseArgs maps scope, tier, and role tokens", () => {
19
- assert.deepEqual(parseArgs("-l inherit"), { scope: "project", tier: "all-inherit", role: undefined });
20
- assert.deepEqual(parseArgs("scout"), { scope: undefined, tier: undefined, role: "scout" });
21
- assert.deepEqual(parseArgs(""), { scope: undefined, tier: undefined, role: undefined });
22
- assert.deepEqual(parseArgs("--local light"), { scope: "project", tier: "light", role: undefined });
23
- assert.deepEqual(parseArgs("custom poteto-agent"), { scope: undefined, tier: "custom", role: "poteto-agent" });
24
- assert.deepEqual(parseArgs("nonsense-token"), { scope: undefined, tier: undefined, role: undefined });
23
+ assert.deepEqual(parseArgs("-l inherit"), { scope: "project", tier: "all-inherit", role: undefined, budget: undefined });
24
+ assert.deepEqual(parseArgs("scout"), { scope: undefined, tier: undefined, role: "scout", budget: undefined });
25
+ assert.deepEqual(parseArgs(""), { scope: undefined, tier: undefined, role: undefined, budget: undefined });
26
+ assert.deepEqual(parseArgs("--local light"), { scope: "project", tier: "light", role: undefined, budget: undefined });
27
+ assert.deepEqual(parseArgs("custom poteto-agent"), { scope: undefined, tier: "custom", role: "poteto-agent", budget: undefined });
28
+ assert.deepEqual(parseArgs("nonsense-token"), { scope: undefined, tier: undefined, role: undefined, budget: undefined });
29
+ });
30
+
31
+ test("parseArgs maps budget tokens, alone and combined", () => {
32
+ assert.equal(parseArgs("small").budget, "small");
33
+ assert.equal(parseArgs("unlimited").budget, "unlimited");
34
+ assert.deepEqual(parseArgs("-l large"), { scope: "project", tier: undefined, role: undefined, budget: "large" });
35
+ assert.equal(parseArgs("medium scout").role, "scout");
36
+ assert.equal(parseArgs("medium scout").budget, "medium");
37
+ });
38
+
39
+ test("applyBudget sets and clears defaultThinking without mutating its input", () => {
40
+ const base = { defaultModel: "inherit", defaultThinking: "high" as const };
41
+ const raised = applyBudget(base, "large");
42
+ assert.equal(raised.defaultThinking, "xhigh");
43
+ assert.equal(base.defaultThinking, "high", "input map is untouched");
44
+ const cleared = applyBudget(base, "unlimited");
45
+ assert.equal(cleared.defaultThinking, undefined);
46
+ assert.ok("defaultThinking" in cleared === false, "unlimited removes the key instead of writing null");
47
+ const lowered = applyBudget({}, "small");
48
+ assert.equal(lowered.defaultThinking, "medium");
49
+ });
50
+
51
+ test("budgetOf reads the recorded budget back off the map", () => {
52
+ assert.equal(budgetOf({}), "unlimited");
53
+ assert.equal(budgetOf({ defaultThinking: "xhigh" }), "large");
54
+ assert.equal(budgetOf({ defaultThinking: "max" }), "large");
55
+ assert.equal(budgetOf({ defaultThinking: "high" }), "medium");
56
+ assert.equal(budgetOf({ defaultThinking: "medium" }), "small");
57
+ assert.equal(budgetOf({ defaultThinking: "low" }), "unlimited", "a hand-written level below the ladder reads as unlimited");
58
+ assert.equal(budgetLabel({ defaultThinking: "high" }), "medium (high reasoning)");
59
+ });
60
+
61
+ test("summaryText names the budget on the header line", () => {
62
+ const text = summaryText(applyBudget(buildTier("all-inherit"), "small"), "user", []);
63
+ assert.ok(text.includes("budget: small"), text);
64
+ assert.ok(text.startsWith("pstack models (user), budget: small"), text);
25
65
  });
26
66
 
27
67
  test("parseModelId strips labels to ids", () => {
@@ -37,25 +77,40 @@ test("buildTier is pure and idempotent", () => {
37
77
  assert.equal(JSON.stringify(buildTier("all-inherit")), JSON.stringify(all));
38
78
 
39
79
  const light = buildTier("light");
40
- assert.equal(light.defaultModel, "opencode-go/hy3");
41
80
  assert.equal(light.defaultThinking, "high");
42
- assert.equal(light.agentOverrides?.scout?.thinking, "low");
43
- assert.deepEqual(light.agentOverrides?.oracle?.fallbackModels, ["opencode-go/deepseek-v4-flash"]);
44
- assert.equal(light.agentOverrides?.["poteto-agent"]?.thinking, "high");
81
+ assert.deepEqual(light.agentOverrides, {
82
+ scout: { thinking: "low" },
83
+ oracle: { thinking: "high" },
84
+ "poteto-agent": { thinking: "high" },
85
+ "comment-sicko": { thinking: "high" },
86
+ });
87
+ assert.equal(JSON.stringify(buildTier("light")), JSON.stringify(light));
88
+ });
89
+
90
+ test("light tier writes no model slug other than inherit", () => {
91
+ const json = JSON.stringify(buildTier("light"));
92
+ assert.deepEqual(json.match(/[a-z0-9-]+\/[a-z0-9.-]+/gi), null, "no provider/model slug is pinned");
93
+ assert.ok(json.includes('"defaultModel":"inherit"'), "every role inherits the parent model");
45
94
  });
46
95
 
47
96
  test("resolvedMapping falls back default -> inherit and includes extras", () => {
48
97
  const light = buildTier("light");
49
98
  const mapped = resolvedMapping(light, ["default", "scout", "oracle", "poteto-agent"]);
50
- assert.deepEqual(mapped.map((row) => [row.role, row.model]), [
51
- ["default", "opencode-go/hy3"],
52
- ["scout", "inherit"],
53
- ["oracle", "inherit"],
54
- ["poteto-agent", "inherit"],
99
+ assert.deepEqual(mapped, [
100
+ { role: "default", model: "inherit", extra: undefined },
101
+ { role: "scout", model: "inherit", extra: "thinking:low" },
102
+ { role: "oracle", model: "inherit", extra: "thinking:high" },
103
+ { role: "poteto-agent", model: "inherit", extra: "thinking:high" },
55
104
  ]);
56
- assert.ok(mapped.find((row) => row.role === "oracle")?.extra?.includes("fallback:"));
57
105
  const empty = resolvedMapping({}, ["default", "worker"]);
58
106
  assert.deepEqual(empty.map((row) => row.model), ["inherit", "inherit"]);
107
+ const handWrittenFallback = resolvedMapping(
108
+ { agentOverrides: { oracle: { model: "inherit", fallbackModels: ["demo/one"] } } },
109
+ ["oracle"],
110
+ );
111
+ assert.deepEqual(handWrittenFallback, [
112
+ { role: "oracle", model: "inherit", extra: "fallback:demo/one" },
113
+ ]);
59
114
  });
60
115
 
61
116
  test("saveSettings merges into existing settings, never clobbers", () => {
@@ -36,6 +36,52 @@ export interface ModelMap {
36
36
  export type Tier = "all-inherit" | "light" | "custom";
37
37
  export type Scope = "user" | "project";
38
38
 
39
+ export type BudgetKey = "unlimited" | "large" | "medium" | "small";
40
+
41
+ export interface BudgetSpec {
42
+ thinking: ThinkingLevel | undefined;
43
+ blurb: string;
44
+ }
45
+
46
+ export const BUDGETS: Record<BudgetKey, BudgetSpec> = {
47
+ unlimited: { thinking: undefined, blurb: "keep each role's own setting" },
48
+ large: { thinking: "xhigh", blurb: "xhigh reasoning" },
49
+ medium: { thinking: "high", blurb: "high reasoning" },
50
+ small: { thinking: "medium", blurb: "medium reasoning" },
51
+ };
52
+
53
+ const BUDGET_KEYS = new Set<string>(Object.keys(BUDGETS));
54
+
55
+ export function applyBudget(map: ModelMap, budget: BudgetKey): ModelMap {
56
+ const next: ModelMap = { ...map };
57
+ const thinking = BUDGETS[budget].thinking;
58
+ if (thinking) {
59
+ next.defaultThinking = thinking;
60
+ } else {
61
+ delete next.defaultThinking;
62
+ }
63
+ return next;
64
+ }
65
+
66
+ export function budgetOf(map: ModelMap): BudgetKey {
67
+ switch (map.defaultThinking) {
68
+ case "xhigh":
69
+ case "max":
70
+ return "large";
71
+ case "high":
72
+ return "medium";
73
+ case "medium":
74
+ return "small";
75
+ default:
76
+ return "unlimited";
77
+ }
78
+ }
79
+
80
+ export function budgetLabel(map: ModelMap): string {
81
+ const key = budgetOf(map);
82
+ return `${key} (${BUDGETS[key].blurb})`;
83
+ }
84
+
39
85
  export const ROLE_KEYS: RoleKey[] = [
40
86
  "default",
41
87
  "scout",
@@ -74,13 +120,14 @@ export interface ParsedArgs {
74
120
  scope: Scope | undefined;
75
121
  tier?: Tier;
76
122
  role?: RoleKey;
123
+ budget?: BudgetKey;
77
124
  }
78
125
 
79
126
  const ROLE_SET = new Set<string>(ROLE_KEYS);
80
127
 
81
128
  export function parseArgs(raw: string): ParsedArgs {
82
129
  const tokens = raw.trim().split(/\s+/).filter(Boolean);
83
- const out: ParsedArgs = { scope: undefined, tier: undefined, role: undefined };
130
+ const out: ParsedArgs = { scope: undefined, tier: undefined, role: undefined, budget: undefined };
84
131
  for (const token of tokens) {
85
132
  if (token === "-l" || token === "--local" || token === "local") {
86
133
  out.scope = "project";
@@ -90,6 +137,8 @@ export function parseArgs(raw: string): ParsedArgs {
90
137
  out.tier = "light";
91
138
  } else if (token === "custom") {
92
139
  out.tier = "custom";
140
+ } else if (BUDGET_KEYS.has(token)) {
141
+ out.budget = token as BudgetKey;
93
142
  } else if (ROLE_SET.has(token)) {
94
143
  out.role = token as RoleKey;
95
144
  }
@@ -183,15 +232,13 @@ export function buildTier(tier: "all-inherit" | "light"): ModelMap {
183
232
  };
184
233
  }
185
234
  return {
186
- defaultModel: "opencode-go/hy3",
235
+ defaultModel: "inherit",
187
236
  defaultThinking: "high",
188
237
  agentOverrides: {
189
- scout: { model: "inherit", thinking: "low" },
190
- worker: { model: "inherit" },
191
- reviewer: { model: "inherit" },
192
- oracle: { model: "inherit", fallbackModels: ["opencode-go/deepseek-v4-flash"] },
193
- "poteto-agent": { model: "inherit", thinking: "high" },
194
- "comment-sicko": { model: "inherit", thinking: "high" },
238
+ scout: { thinking: "low" },
239
+ oracle: { thinking: "high" },
240
+ "poteto-agent": { thinking: "high" },
241
+ "comment-sicko": { thinking: "high" },
195
242
  },
196
243
  };
197
244
  }
@@ -226,7 +273,8 @@ export function summaryText(
226
273
  loadedPstackSkills: string[],
227
274
  ): string {
228
275
  const scopeName = scope === "project" ? "project" : scope === "user" ? "user" : "none saved";
229
- const lines = [`pstack models (${scopeName}):`];
276
+ const budget = budgetOf(map);
277
+ const lines = [`pstack models (${scopeName}), budget: ${budget} (${BUDGETS[budget].blurb}):`];
230
278
  for (const role of resolvedMapping(map, ROLE_KEYS)) {
231
279
  lines.push(
232
280
  ` ${role.role} -> ${role.model}${role.extra ? ` (${role.extra})` : ""}`,