@slope-dev/slope 1.53.0 → 1.55.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 (145) hide show
  1. package/dist/cli/commands/agent.d.ts +35 -0
  2. package/dist/cli/commands/agent.d.ts.map +1 -0
  3. package/dist/cli/commands/agent.js +345 -0
  4. package/dist/cli/commands/agent.js.map +1 -0
  5. package/dist/cli/commands/briefing.d.ts.map +1 -1
  6. package/dist/cli/commands/briefing.js +5 -3
  7. package/dist/cli/commands/briefing.js.map +1 -1
  8. package/dist/cli/commands/commit-ready.d.ts +29 -0
  9. package/dist/cli/commands/commit-ready.d.ts.map +1 -0
  10. package/dist/cli/commands/commit-ready.js +263 -0
  11. package/dist/cli/commands/commit-ready.js.map +1 -0
  12. package/dist/cli/commands/doctor.d.ts +9 -0
  13. package/dist/cli/commands/doctor.d.ts.map +1 -1
  14. package/dist/cli/commands/doctor.js +163 -3
  15. package/dist/cli/commands/doctor.js.map +1 -1
  16. package/dist/cli/commands/gate.d.ts +2 -0
  17. package/dist/cli/commands/gate.d.ts.map +1 -0
  18. package/dist/cli/commands/gate.js +175 -0
  19. package/dist/cli/commands/gate.js.map +1 -0
  20. package/dist/cli/commands/guard.d.ts.map +1 -1
  21. package/dist/cli/commands/guard.js +4 -0
  22. package/dist/cli/commands/guard.js.map +1 -1
  23. package/dist/cli/commands/hook.js +6 -1
  24. package/dist/cli/commands/hook.js.map +1 -1
  25. package/dist/cli/commands/init.d.ts.map +1 -1
  26. package/dist/cli/commands/init.js +23 -6
  27. package/dist/cli/commands/init.js.map +1 -1
  28. package/dist/cli/commands/pr.d.ts +49 -0
  29. package/dist/cli/commands/pr.d.ts.map +1 -0
  30. package/dist/cli/commands/pr.js +214 -0
  31. package/dist/cli/commands/pr.js.map +1 -0
  32. package/dist/cli/commands/retro.d.ts +18 -0
  33. package/dist/cli/commands/retro.d.ts.map +1 -0
  34. package/dist/cli/commands/retro.js +251 -0
  35. package/dist/cli/commands/retro.js.map +1 -0
  36. package/dist/cli/commands/review-state.d.ts +1 -2
  37. package/dist/cli/commands/review-state.d.ts.map +1 -1
  38. package/dist/cli/commands/review-state.js +71 -25
  39. package/dist/cli/commands/review-state.js.map +1 -1
  40. package/dist/cli/commands/roadmap.d.ts.map +1 -1
  41. package/dist/cli/commands/roadmap.js +31 -4
  42. package/dist/cli/commands/roadmap.js.map +1 -1
  43. package/dist/cli/commands/sprint-plan.d.ts +21 -0
  44. package/dist/cli/commands/sprint-plan.d.ts.map +1 -0
  45. package/dist/cli/commands/sprint-plan.js +226 -0
  46. package/dist/cli/commands/sprint-plan.js.map +1 -0
  47. package/dist/cli/commands/sprint.d.ts.map +1 -1
  48. package/dist/cli/commands/sprint.js +137 -0
  49. package/dist/cli/commands/sprint.js.map +1 -1
  50. package/dist/cli/commands/status.d.ts.map +1 -1
  51. package/dist/cli/commands/status.js +34 -1
  52. package/dist/cli/commands/status.js.map +1 -1
  53. package/dist/cli/commands/ticket.d.ts +9 -0
  54. package/dist/cli/commands/ticket.d.ts.map +1 -0
  55. package/dist/cli/commands/ticket.js +168 -0
  56. package/dist/cli/commands/ticket.js.map +1 -0
  57. package/dist/cli/commands/version.d.ts.map +1 -1
  58. package/dist/cli/commands/version.js +33 -4
  59. package/dist/cli/commands/version.js.map +1 -1
  60. package/dist/cli/commands/vision.d.ts.map +1 -1
  61. package/dist/cli/commands/vision.js +14 -1
  62. package/dist/cli/commands/vision.js.map +1 -1
  63. package/dist/cli/guards/branch-before-commit.d.ts.map +1 -1
  64. package/dist/cli/guards/branch-before-commit.js +2 -1
  65. package/dist/cli/guards/branch-before-commit.js.map +1 -1
  66. package/dist/cli/guards/next-action.d.ts.map +1 -1
  67. package/dist/cli/guards/next-action.js +3 -2
  68. package/dist/cli/guards/next-action.js.map +1 -1
  69. package/dist/cli/guards/post-hole-enforcement.d.ts +18 -0
  70. package/dist/cli/guards/post-hole-enforcement.d.ts.map +1 -0
  71. package/dist/cli/guards/post-hole-enforcement.js +100 -0
  72. package/dist/cli/guards/post-hole-enforcement.js.map +1 -0
  73. package/dist/cli/guards/pr-review.d.ts +16 -1
  74. package/dist/cli/guards/pr-review.d.ts.map +1 -1
  75. package/dist/cli/guards/pr-review.js +87 -5
  76. package/dist/cli/guards/pr-review.js.map +1 -1
  77. package/dist/cli/guards/roadmap-edit-shipped.d.ts +12 -0
  78. package/dist/cli/guards/roadmap-edit-shipped.d.ts.map +1 -0
  79. package/dist/cli/guards/roadmap-edit-shipped.js +155 -0
  80. package/dist/cli/guards/roadmap-edit-shipped.js.map +1 -0
  81. package/dist/cli/index.js +47 -2
  82. package/dist/cli/index.js.map +1 -1
  83. package/dist/cli/registry.d.ts +1 -1
  84. package/dist/cli/registry.d.ts.map +1 -1
  85. package/dist/cli/registry.js +67 -1
  86. package/dist/cli/registry.js.map +1 -1
  87. package/dist/core/analyzers/git.d.ts +14 -0
  88. package/dist/core/analyzers/git.d.ts.map +1 -1
  89. package/dist/core/analyzers/git.js +47 -0
  90. package/dist/core/analyzers/git.js.map +1 -1
  91. package/dist/core/formatter.d.ts.map +1 -1
  92. package/dist/core/formatter.js +51 -2
  93. package/dist/core/formatter.js.map +1 -1
  94. package/dist/core/guard.d.ts +1 -1
  95. package/dist/core/guard.d.ts.map +1 -1
  96. package/dist/core/guard.js +16 -0
  97. package/dist/core/guard.js.map +1 -1
  98. package/dist/core/index.d.ts +3 -2
  99. package/dist/core/index.d.ts.map +1 -1
  100. package/dist/core/index.js +3 -2
  101. package/dist/core/index.js.map +1 -1
  102. package/dist/core/json-memory-backend.d.ts +23 -0
  103. package/dist/core/json-memory-backend.d.ts.map +1 -0
  104. package/dist/core/json-memory-backend.js +98 -0
  105. package/dist/core/json-memory-backend.js.map +1 -0
  106. package/dist/core/memory-backend.d.ts +30 -0
  107. package/dist/core/memory-backend.d.ts.map +1 -0
  108. package/dist/core/memory-backend.js +14 -0
  109. package/dist/core/memory-backend.js.map +1 -0
  110. package/dist/core/memory-types.d.ts +25 -0
  111. package/dist/core/memory-types.d.ts.map +1 -0
  112. package/dist/core/memory-types.js +4 -0
  113. package/dist/core/memory-types.js.map +1 -0
  114. package/dist/core/memory-validation.d.ts +4 -0
  115. package/dist/core/memory-validation.d.ts.map +1 -0
  116. package/dist/core/memory-validation.js +35 -0
  117. package/dist/core/memory-validation.js.map +1 -0
  118. package/dist/core/memory.d.ts +21 -37
  119. package/dist/core/memory.d.ts.map +1 -1
  120. package/dist/core/memory.js +97 -147
  121. package/dist/core/memory.js.map +1 -1
  122. package/dist/core/pi-settings.d.ts.map +1 -1
  123. package/dist/core/pi-settings.js +8 -0
  124. package/dist/core/pi-settings.js.map +1 -1
  125. package/dist/core/roadmap.d.ts +16 -2
  126. package/dist/core/roadmap.d.ts.map +1 -1
  127. package/dist/core/roadmap.js +81 -24
  128. package/dist/core/roadmap.js.map +1 -1
  129. package/dist/core/vision.d.ts +10 -0
  130. package/dist/core/vision.d.ts.map +1 -1
  131. package/dist/core/vision.js +68 -1
  132. package/dist/core/vision.js.map +1 -1
  133. package/dist/store/index.d.ts.map +1 -1
  134. package/dist/store/index.js +18 -0
  135. package/dist/store/index.js.map +1 -1
  136. package/dist/store/sqlite-memory-backend.d.ts +43 -0
  137. package/dist/store/sqlite-memory-backend.d.ts.map +1 -0
  138. package/dist/store/sqlite-memory-backend.js +181 -0
  139. package/dist/store/sqlite-memory-backend.js.map +1 -0
  140. package/dist/store-pg/index.d.ts.map +1 -1
  141. package/dist/store-pg/index.js +23 -0
  142. package/dist/store-pg/index.js.map +1 -1
  143. package/package.json +1 -1
  144. package/packages/pi-extension/dist/index.d.ts +19 -0
  145. package/packages/pi-extension/dist/index.js +339 -15
@@ -22,6 +22,63 @@ function slopeCmd(args, cwd) {
22
22
  function hasSlopeProject(cwd) {
23
23
  return existsSync(join(cwd, '.slope', 'config.json'));
24
24
  }
25
+ // ── Vague-Prompt Detector ────────────────────────────
26
+ // Matches vague action verbs at the start of a prompt. Shared by isVaguePrompt() and getPlannerSignals().
27
+ const VAGUE_VERB_RE = /^(optimize|improve|make\s+\S+\s+better|make\s+better|fix|refactor|clean\s+up|tidy|architect)\b/i;
28
+ export function isVaguePrompt(prompt) {
29
+ const trimmed = prompt.trim();
30
+ return (trimmed.length < 200 &&
31
+ VAGUE_VERB_RE.test(trimmed) &&
32
+ !HAS_PATH_RE.test(trimmed) &&
33
+ !HAS_SYMBOL_RE.test(trimmed));
34
+ }
35
+ /**
36
+ * Module-level ref populated by registerModelRouter so the vague-prompt
37
+ * detector (registered first) can call doSwitch without accessing the closure.
38
+ * Set synchronously during extension init, before any events fire.
39
+ */
40
+ let _routerRef = null;
41
+ // ── Plan-Gate Helpers ────────────────────────────────
42
+ /** Returns the active sprint phase, or null if no sprint state exists. */
43
+ function readSprintPhase(cwd) {
44
+ const sprintStatePath = join(cwd, '.slope', 'sprint-state.json');
45
+ if (!existsSync(sprintStatePath))
46
+ return null;
47
+ try {
48
+ const state = JSON.parse(readFileSync(sprintStatePath, 'utf8'));
49
+ return state.phase ?? null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function inSprintPhase(cwd) {
56
+ const phase = readSprintPhase(cwd);
57
+ return phase === 'planning' || phase === 'implementing' || phase === 'scoring';
58
+ }
59
+ function hasRecentPlan(ctx) {
60
+ const entries = ctx.sessionManager.getEntries();
61
+ // Scan last 5 assistant messages for a structured plan.
62
+ const assistantMessages = entries
63
+ .filter(e => e.role === 'assistant' && typeof e.content === 'string')
64
+ .slice(-5);
65
+ const PLAN_HEADING = /^##+\s*(plan|approach|steps?)\b/im;
66
+ const NUMBERED_LIST = /^\s*1\.\s.+\n\s*2\.\s.+\n\s*3\.\s/m;
67
+ const PLAN_MARKER = /\[plan\]|<plan>/i;
68
+ return assistantMessages.some(m => PLAN_HEADING.test(m.content ?? '') ||
69
+ NUMBERED_LIST.test(m.content ?? '') ||
70
+ PLAN_MARKER.test(m.content ?? ''));
71
+ }
72
+ // Read-only bash commands that are always exempt from the plan-gate.
73
+ const BASH_EXEMPT_PREFIXES = [
74
+ 'git status', 'git log', 'git diff', 'git show', 'git branch',
75
+ 'ls', 'pwd', 'cat ', 'grep', 'find ', 'which ', 'echo ',
76
+ 'head ', 'tail ', 'wc ', 'stat ', 'file ', 'type ',
77
+ ];
78
+ function isBashExempt(command) {
79
+ const trimmed = command.trim();
80
+ return BASH_EXEMPT_PREFIXES.some(prefix => trimmed.startsWith(prefix));
81
+ }
25
82
  // ── Onboarding State ────────────────────────────────
26
83
  const ONBOARDING_STATE_FILE = '.slope/.pi-onboarding.json';
27
84
  function loadOnboardingState(cwd) {
@@ -42,22 +99,12 @@ function saveOnboardingState(cwd, state) {
42
99
  }
43
100
  /** Determine the SLOPE project state for this workspace */
44
101
  function getProjectState(cwd) {
45
- const sprintStatePath = join(cwd, '.slope', 'sprint-state.json');
46
- if (!existsSync(sprintStatePath)) {
47
- return 'fresh';
48
- }
49
- try {
50
- const state = JSON.parse(readFileSync(sprintStatePath, 'utf8'));
51
- if (state.phase === 'planning' ||
52
- state.phase === 'implementing' ||
53
- state.phase === 'scoring') {
54
- return 'active';
55
- }
56
- return 'complete';
57
- }
58
- catch {
102
+ const phase = readSprintPhase(cwd);
103
+ if (phase === null)
59
104
  return 'fresh';
60
- }
105
+ if (phase === 'planning' || phase === 'implementing' || phase === 'scoring')
106
+ return 'active';
107
+ return 'complete';
61
108
  }
62
109
  // ── Extension Entry Point ───────────────────────────
63
110
  export default function slopeExtension(pi, _cwdOverride) {
@@ -381,6 +428,53 @@ export default function slopeExtension(pi, _cwdOverride) {
381
428
  }
382
429
  });
383
430
  } // end guards event handlers
431
+ if (isSkillEnabled(settings, 'plan-gate')) {
432
+ pi.on('tool_call', async (event, ctx) => {
433
+ const { toolName, input } = event;
434
+ const inp = input;
435
+ // Only gate destructive tools.
436
+ if (toolName !== 'write' && toolName !== 'edit' && toolName !== 'bash')
437
+ return;
438
+ // Bash: exempt read-only commands.
439
+ if (toolName === 'bash' && typeof inp.command === 'string' && isBashExempt(inp.command))
440
+ return;
441
+ // Not a gate situation when a sprint is active.
442
+ if (inSprintPhase(ctx.cwd))
443
+ return;
444
+ // Not a gate situation when the assistant recently produced a plan.
445
+ if (hasRecentPlan(ctx))
446
+ return;
447
+ const reason = 'plan-gate: Run /sprint start or write a plan before this action.';
448
+ // Interactive: ask the user; non-interactive: hard block.
449
+ const ui = ctx.ui;
450
+ if (typeof ui?.confirm === 'function') {
451
+ const ok = await ui.confirm('No plan detected', 'Run /sprint start or write a plan first. Proceed anyway?');
452
+ if (!ok)
453
+ return { block: true, reason };
454
+ }
455
+ else {
456
+ return { block: true, reason };
457
+ }
458
+ });
459
+ // Vague-prompt detector — registered BEFORE model-router's before_agent_start so
460
+ // the forced local-planner tier sticks when the router handler fires next.
461
+ pi.on('before_agent_start', async (event, ctx) => {
462
+ if (!isVaguePrompt(event.prompt ?? ''))
463
+ return;
464
+ // Inject planning preamble into the chained system prompt.
465
+ const preamble = '\n\nBefore any tool calls, produce a written plan addressing what to change, ' +
466
+ 'where, and the order of work. Format: a numbered list of steps under a ' +
467
+ '"## Plan" heading. Do not call write/edit/bash before the plan is written.';
468
+ // Force-route to local-planner when the model-router skill is active.
469
+ if (_routerRef) {
470
+ _routerRef.state.turnsSinceSwitch = 3; // satisfy hysteresis so doSwitch fires
471
+ await doSwitch('local-planner', 'vague-prompt-detector', ctx, _routerRef.pi, _routerRef.state);
472
+ }
473
+ return {
474
+ systemPrompt: event.systemPrompt + preamble,
475
+ };
476
+ });
477
+ } // end plan-gate event handlers
384
478
  if (isSkillEnabled(settings, 'planning')) {
385
479
  // Guard: post-push sprint nudge
386
480
  pi.on('tool_result', async (event, ctx) => {
@@ -449,6 +543,10 @@ export default function slopeExtension(pi, _cwdOverride) {
449
543
  } // end memory slash command
450
544
  // Settings command
451
545
  registerSettingsCommand(pi, settings, cwd);
546
+ // ── Model Router ────────────────────────────────
547
+ if (isSkillEnabled(settings, 'model-router')) {
548
+ registerModelRouter(pi, cwd);
549
+ }
452
550
  // ── Session Start: Inject Briefing on First Turn ──
453
551
  let briefingInjected = false;
454
552
  pi.on('session_start', async (_event, ctx) => {
@@ -552,6 +650,232 @@ export default function slopeExtension(pi, _cwdOverride) {
552
650
  } // end briefing event handlers
553
651
  }
554
652
  // ── Settings Command ──────────────────────────────
653
+ // ── Model Router ──────────────────────────────────────────
654
+ const COMPLEX_KEYWORDS = [
655
+ 'architect', 'design', 'refactor', 'debug', 'investigate',
656
+ 'why does', 'how should', 'what\'s the best', 'review this',
657
+ 'performance', 'security', 'migration', 'breaking change',
658
+ 'multi-file', 'cross-cutting', 'redesign', 'strategy',
659
+ 'explain why', 'trade-off', 'compare approaches',
660
+ ];
661
+ const SIMPLE_KEYWORDS = [
662
+ 'fix typo', 'rename', 'add import', 'run test', 'format',
663
+ 'lint', 'commit', 'push', 'status', 'list files', 'show',
664
+ 'create file', 'delete file', 'move file',
665
+ ];
666
+ // Standalone ambiguous noun tokens. 'it' is intentionally excluded — too common.
667
+ const AMBIGUOUS_NOUN_TOKENS = [
668
+ 'the code', 'this', 'everything', 'all of it', 'the whole thing', 'the codebase',
669
+ ];
670
+ const HAS_PATH_RE = /\.(tsx?|jsx?|py|rs|go|md|json|ya?ml|sh|sql|css|html|toml)\b/i;
671
+ // Function-call shape `foo()` OR PascalCase identifier `MyClass`.
672
+ const HAS_SYMBOL_RE = /\b[a-z][A-Za-z0-9_]*\(\)|\b[A-Z][a-z][A-Za-z0-9]+\b/;
673
+ function hasCodeIdentifier(prompt) {
674
+ return HAS_PATH_RE.test(prompt) || HAS_SYMBOL_RE.test(prompt);
675
+ }
676
+ function getPlannerSignals(prompt) {
677
+ const trimmed = prompt.trim();
678
+ const lower = trimmed.toLowerCase();
679
+ const signals = [];
680
+ if (VAGUE_VERB_RE.test(trimmed))
681
+ signals.push('vague-verb');
682
+ for (const t of AMBIGUOUS_NOUN_TOKENS) {
683
+ if (lower.includes(t)) {
684
+ signals.push('ambiguous-noun');
685
+ break;
686
+ }
687
+ }
688
+ if (trimmed.length < 200 && !hasCodeIdentifier(trimmed))
689
+ signals.push('short-no-code');
690
+ return signals;
691
+ }
692
+ export function scoreComplexity(prompt) {
693
+ const trimmed = prompt.trim();
694
+ const lower = trimmed.toLowerCase();
695
+ // 0–5 score (cloud-escalation signal). Same weights as the prior implementation.
696
+ let score = 0;
697
+ let topComplexKw = null;
698
+ for (const kw of COMPLEX_KEYWORDS) {
699
+ if (lower.includes(kw)) {
700
+ score += 2;
701
+ if (!topComplexKw)
702
+ topComplexKw = kw;
703
+ }
704
+ }
705
+ for (const kw of SIMPLE_KEYWORDS) {
706
+ if (lower.includes(kw))
707
+ score -= 1;
708
+ }
709
+ if (trimmed.length > 500)
710
+ score += 1;
711
+ if (trimmed.length > 1000)
712
+ score += 1;
713
+ if (trimmed.length < 50)
714
+ score -= 1;
715
+ const questions = (trimmed.match(/\?/g) ?? []).length;
716
+ if (questions >= 2)
717
+ score += 1;
718
+ const fileRefs = (trimmed.match(/\.[a-z]{1,4}\b/g) ?? []).length;
719
+ if (fileRefs >= 3)
720
+ score += 1;
721
+ score = Math.max(0, Math.min(5, score));
722
+ // Tier selection. Order matters: cloud > planner > coder > general.
723
+ if (score >= 3) {
724
+ return { tier: 'cloud', score, signal: topComplexKw ?? 'high-complexity' };
725
+ }
726
+ const planner = getPlannerSignals(trimmed);
727
+ if (planner.length >= 2) {
728
+ return { tier: 'local-planner', score, signal: planner.join('+') };
729
+ }
730
+ if (hasCodeIdentifier(trimmed)) {
731
+ return { tier: 'local-coder', score, signal: 'code-identifier' };
732
+ }
733
+ // Coder-flavoured keywords still hint at coder when no identifier is present.
734
+ const CODER_HINT_KWS = ['fix typo', 'rename', 'add import', 'run test', 'format', 'lint',
735
+ 'commit', 'push', 'create file', 'delete file', 'move file', 'debug', 'refactor'];
736
+ for (const kw of CODER_HINT_KWS) {
737
+ if (lower.includes(kw))
738
+ return { tier: 'local-coder', score, signal: kw };
739
+ }
740
+ return { tier: 'local-general', score, signal: 'general-reasoning' };
741
+ }
742
+ const TIER_ICONS = {
743
+ 'local-coder': '\u{1F6E0}',
744
+ 'local-general': '\u{1F9E0}',
745
+ 'local-planner': '\u{1F4CB}',
746
+ 'cloud': '☁',
747
+ };
748
+ /** Backwards-compat: persisted RouterState may carry the old 'local' tier name. */
749
+ function normalizeTier(tier) {
750
+ if (tier === 'local')
751
+ return 'local-coder';
752
+ if (tier === 'local-coder' || tier === 'local-general' || tier === 'local-planner' || tier === 'cloud') {
753
+ return tier;
754
+ }
755
+ return 'local-coder';
756
+ }
757
+ function registerModelRouter(pi, _cwd) {
758
+ const state = {
759
+ currentTier: 'local-coder',
760
+ turnsSinceSwitch: 0,
761
+ lastSwitchReason: 'startup',
762
+ };
763
+ // Expose state + pi so the vague-prompt detector (registered earlier) can call doSwitch.
764
+ _routerRef = { state, pi };
765
+ // Restore state from session
766
+ pi.on('session_start', async (_event, ctx) => {
767
+ for (const entry of ctx.sessionManager.getEntries()) {
768
+ if (entry.type === 'custom' && entry.customType === 'slope-model-router') {
769
+ const persisted = entry.data;
770
+ state.currentTier = normalizeTier(persisted.currentTier ?? 'local-coder');
771
+ state.turnsSinceSwitch = persisted.turnsSinceSwitch ?? 0;
772
+ state.lastSwitchReason = persisted.lastSwitchReason ?? 'restored';
773
+ state.plannerPreambleStaged = persisted.plannerPreambleStaged;
774
+ }
775
+ }
776
+ ctx.ui.setStatus('model-router', `${TIER_ICONS[state.currentTier]} ${state.currentTier}`);
777
+ });
778
+ // Analyze complexity before each agent turn
779
+ pi.on('before_agent_start', async (event, ctx) => {
780
+ const prompt = (event.prompt ?? '').toLowerCase();
781
+ // Explicit user commands \u2014 match longest variants first.
782
+ if (/(use|switch|\/route)\s+local-planner\b/.test(prompt)) {
783
+ await doSwitch('local-planner', 'user request', ctx, pi, state);
784
+ return;
785
+ }
786
+ if (/(use|switch|\/route)\s+local-general\b/.test(prompt)) {
787
+ await doSwitch('local-general', 'user request', ctx, pi, state);
788
+ return;
789
+ }
790
+ if (/(use|switch|\/route)\s+local-coder\b/.test(prompt)) {
791
+ await doSwitch('local-coder', 'user request', ctx, pi, state);
792
+ return;
793
+ }
794
+ const FORCE_LOCAL = ['use local', '/route local', 'switch local'];
795
+ const FORCE_CLOUD = ['use cloud', '/route cloud', 'switch cloud', 'use sonnet', 'use opus'];
796
+ if (FORCE_LOCAL.some(k => prompt.includes(k))) {
797
+ await doSwitch('local-coder', 'user request (legacy "local")', ctx, pi, state);
798
+ return;
799
+ }
800
+ if (FORCE_CLOUD.some(k => prompt.includes(k))) {
801
+ await doSwitch('cloud', 'user request', ctx, pi, state);
802
+ return;
803
+ }
804
+ const result = scoreComplexity(event.prompt ?? '');
805
+ // Hysteresis: don't switch too frequently (min 3 turns between switches).
806
+ if (state.turnsSinceSwitch < 3) {
807
+ state.turnsSinceSwitch++;
808
+ return;
809
+ }
810
+ if (result.tier !== state.currentTier) {
811
+ await doSwitch(result.tier, `auto: ${result.signal} (score ${result.score})`, ctx, pi, state);
812
+ }
813
+ else {
814
+ state.turnsSinceSwitch++;
815
+ }
816
+ });
817
+ // Track turns
818
+ pi.on('turn_end', async () => {
819
+ state.turnsSinceSwitch++;
820
+ });
821
+ // Manual command
822
+ pi.registerCommand('route', {
823
+ description: 'Switch model routing: /route local-coder | local-general | local-planner | cloud | status',
824
+ handler: async (args, ctx) => {
825
+ const arg = (args ?? '').trim().toLowerCase();
826
+ if (arg === 'status' || arg === '') {
827
+ ctx.ui.notify(`Model router: ${state.currentTier} (${state.turnsSinceSwitch} turns since switch, reason: ${state.lastSwitchReason})`, 'info');
828
+ return;
829
+ }
830
+ // Back-compat: bare 'local' = local-coder.
831
+ if (arg === 'local') {
832
+ await doSwitch('local-coder', 'manual /route (legacy "local")', ctx, pi, state);
833
+ return;
834
+ }
835
+ if (arg === 'local-coder' || arg === 'local-general' || arg === 'local-planner' || arg === 'cloud') {
836
+ await doSwitch(arg, 'manual /route command', ctx, pi, state);
837
+ return;
838
+ }
839
+ ctx.ui.notify('Usage: /route local-coder | local-general | local-planner | cloud | status', 'info');
840
+ },
841
+ });
842
+ }
843
+ export async function doSwitch(tier, reason, ctx, pi, state) {
844
+ const registry = ctx.modelRegistry;
845
+ let model;
846
+ if (tier === 'cloud') {
847
+ model = registry.find('openrouter', 'anthropic/claude-sonnet-4-5')
848
+ ?? registry.find('openrouter', 'google/gemini-2.5-pro-preview')
849
+ ?? registry.find('anthropic', 'claude-sonnet-4-20250514');
850
+ }
851
+ else if (tier === 'local-coder') {
852
+ model = registry.find('mlx-local', 'Qwen3-Coder-Next')
853
+ ?? registry.find('mlx-local', 'Qwen3-Coder')
854
+ ?? registry.find('ollama', 'qwen3-coder:30b');
855
+ }
856
+ else {
857
+ // local-general and local-planner share the dense reasoning model.
858
+ model = registry.find('mlx-local', 'Qwen3.6-27B')
859
+ ?? registry.find('mlx-local', 'Qwen3-27B')
860
+ ?? registry.find('ollama', 'qwen3:27b');
861
+ }
862
+ if (!model) {
863
+ ctx.ui.notify(`No ${tier} model available — check models.json`, 'warning');
864
+ return;
865
+ }
866
+ const success = await pi.setModel(model);
867
+ if (success) {
868
+ state.currentTier = tier;
869
+ state.turnsSinceSwitch = 0;
870
+ state.lastSwitchReason = reason;
871
+ state.plannerPreambleStaged = tier === 'local-planner';
872
+ ctx.ui.setStatus('model-router', `${TIER_ICONS[tier]} ${tier}`);
873
+ ctx.ui.notify(`Model router: switched to ${tier} (${model.name ?? model.id}) — ${reason}`, 'info');
874
+ // Persist state
875
+ pi.appendEntry('slope-model-router', state);
876
+ }
877
+ }
878
+ // ── Settings Command ──────────────────────────────────
555
879
  function registerSettingsCommand(pi, settings, cwd) {
556
880
  pi.registerCommand('slope-settings', {
557
881
  description: 'Show and manage SLOPE Pi feature settings',