@leing2021/super-pi 0.30.6 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -98,6 +98,8 @@ Configure in `.pi/settings.json`:
98
98
 
99
99
  Model and thinking level switch automatically — no manual `/model` needed.
100
100
 
101
+ Routing triggers on explicit `/skill:` commands **and** when the agent reads a stage's `SKILL.md` on its own. If no strategy is configured, routing is a no-op — the session model is never touched. Project-level settings take precedence; missing keys fall back to global `~/.pi/agent/settings.json`.
102
+
101
103
  ## Design Philosophy & Acknowledgements
102
104
 
103
105
  **80% planning and review, 20% execution.**
@@ -1,7 +1,4 @@
1
- import { readFile } from "node:fs/promises"
2
- import path from "node:path"
3
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
4
- import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"
5
2
  import { Type } from "typebox"
6
3
  import { createArtifactHelperTool, type ArtifactType } from "./tools/artifact-helper"
7
4
  import { createAskUserQuestionTool, CUSTOM_SENTINEL } from "./tools/ask-user-question"
@@ -19,14 +16,7 @@ import { createContextHandoffTool } from "./tools/context-handoff"
19
16
  import { filterBashOutput } from "./tools/bash-output-filter"
20
17
  import { filterReadOutput } from "./tools/read-output-filter"
21
18
  import { COMPACTION_FOCUS_INSTRUCTIONS } from "./tools/compaction-optimizer"
22
-
23
- const PIPELINE_STAGE_KEYS = new Set([
24
- "01-brainstorm",
25
- "02-plan",
26
- "03-work",
27
- "04-review",
28
- "05-learn",
29
- ])
19
+ import { applyStageStrategies, parseStageSkillName, parseStageSkillPath } from "./utils/stage-routing"
30
20
 
31
21
  /**
32
22
  * Module-level promise chain that serializes interactive `ask_user_question`
@@ -92,91 +82,6 @@ function buildAskUserQuestionUi(ctx: any): import("./tools/ask-user-question").A
92
82
  return { input, select }
93
83
  }
94
84
 
95
- interface StrategySettings {
96
- modelStrategy?: Record<string, string>
97
- thinkingStrategy?: Record<string, string>
98
- }
99
-
100
- /**
101
- * Read settings from two locations (config dir honors pi's `CONFIG_DIR_NAME`,
102
- * which defaults to `.pi` but is user-configurable since pi 0.79.7):
103
- * 1. Project-level: {cwd}/{CONFIG_DIR_NAME}/settings.json (highest priority)
104
- * 2. Global-level: ~/{CONFIG_DIR_NAME}/agent/settings.json (fallback)
105
- *
106
- * Project-level takes precedence; global-level is used as fallback.
107
- */
108
- async function readSettings(cwd: string): Promise<StrategySettings | null> {
109
- const agentHome = process.env.HOME || "~"
110
- // Try project-level first
111
- const projectPath = path.join(cwd, CONFIG_DIR_NAME, "settings.json")
112
- try {
113
- const content = await readFile(projectPath, "utf8")
114
- const projectSettings = JSON.parse(content) as StrategySettings
115
- // If project has modelStrategy or thinkingStrategy, use it
116
- if (projectSettings.modelStrategy || projectSettings.thinkingStrategy) {
117
- return projectSettings
118
- }
119
- } catch {
120
- // Project settings not found, continue to global
121
- }
122
-
123
- // Fallback to global-level
124
- const globalPath = path.join(agentHome, CONFIG_DIR_NAME, "agent", "settings.json")
125
- try {
126
- const content = await readFile(globalPath, "utf8")
127
- return JSON.parse(content) as StrategySettings
128
- } catch {
129
- // Global settings not found either
130
- }
131
-
132
- // Try ~/{CONFIG_DIR_NAME}/settings.json as another fallback
133
- const altGlobalPath = path.join(agentHome, CONFIG_DIR_NAME, "settings.json")
134
- try {
135
- const content = await readFile(altGlobalPath, "utf8")
136
- return JSON.parse(content) as StrategySettings
137
- } catch {
138
- return null
139
- }
140
- }
141
-
142
- function parseStageSkillName(text: string): string | null {
143
- const trimmed = text.trim()
144
- const match = trimmed.match(/^\/skill:([^\s]+)/)
145
- if (!match) {
146
- return null
147
- }
148
-
149
- const skillName = match[1]
150
- return PIPELINE_STAGE_KEYS.has(skillName) ? skillName : null
151
- }
152
-
153
- function parseModelRef(
154
- modelRef: string,
155
- currentProvider?: string,
156
- ): { provider: string, id: string } | null {
157
- const trimmed = modelRef.trim()
158
- if (!trimmed) {
159
- return null
160
- }
161
-
162
- const slashIndex = trimmed.indexOf("/")
163
- if (slashIndex > 0 && slashIndex < trimmed.length - 1) {
164
- return {
165
- provider: trimmed.slice(0, slashIndex),
166
- id: trimmed.slice(slashIndex + 1),
167
- }
168
- }
169
-
170
- if (!currentProvider) {
171
- return null
172
- }
173
-
174
- return {
175
- provider: currentProvider,
176
- id: trimmed,
177
- }
178
- }
179
-
180
85
  const artifactHelperParams = Type.Object({
181
86
  repoRoot: Type.String({ description: "Repository root where workflow artifacts should be created" }),
182
87
  artifactType: Type.Union([
@@ -360,78 +265,31 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
360
265
  return { action: "continue" as const }
361
266
  }
362
267
 
363
- const settings = await readSettings(ctx.cwd)
364
- const modelStrategy = settings?.modelStrategy
365
- const thinkingStrategy = settings?.thinkingStrategy
366
- // Notification guard: only notify in interactive (TUI) or RPC modes.
367
- // For interactive input capability checks (askUserQuestion), use ctx.hasUI directly.
368
- const shouldNotify = ctx.mode === "tui" || ctx.mode === "rpc"
369
-
370
- // Model switching
371
- if (modelStrategy) {
372
- const targetModelRef = modelStrategy[stageKey] ?? modelStrategy.default
373
- if (targetModelRef) {
374
- const parsed = parseModelRef(targetModelRef, ctx.model?.provider)
375
- if (parsed) {
376
- // Skip if already using the same model
377
- if (ctx.model?.provider !== parsed.provider || ctx.model?.id !== parsed.id) {
378
- const model = ctx.modelRegistry.find(parsed.provider, parsed.id)
379
- if (model) {
380
- const switched = await pi.setModel(model)
381
- if (switched) {
382
- if (shouldNotify) {
383
- ctx.ui.notify(`Switched model for ${stageKey}: ${model.provider}/${model.id}`, "info")
384
- }
385
- } else {
386
- if (shouldNotify) {
387
- ctx.ui.notify(`No API key for ${stageKey}: ${model.provider}/${model.id}`, "warning")
388
- }
389
- }
390
- } else if (shouldNotify) {
391
- ctx.ui.notify(`Model not found for ${stageKey}: ${targetModelRef}`, "warning")
392
- }
393
- }
394
- } else if (shouldNotify) {
395
- ctx.ui.notify(`Invalid modelStrategy for ${stageKey}: ${targetModelRef}`, "warning")
396
- }
397
- }
398
- }
399
-
400
- // Thinking level switching
401
- if (thinkingStrategy) {
402
- const targetThinking = thinkingStrategy[stageKey] ?? thinkingStrategy.default
403
- if (targetThinking) {
404
- const levelMap: Record<string, ReturnType<ExtensionAPI["getThinkingLevel"]>> = {
405
- off: "off",
406
- minimal: "minimal",
407
- low: "low",
408
- medium: "medium",
409
- high: "high",
410
- xhigh: "xhigh",
411
- max: "max",
412
- "0": "low",
413
- "1": "medium",
414
- "2": "high",
415
- }
416
- const rawThinking = targetThinking.toLowerCase()
417
- const knownLevel = levelMap[rawThinking]
418
- const normalized = knownLevel ?? "medium"
419
- if (!knownLevel && shouldNotify) {
420
- ctx.ui.notify(`Unknown thinking level for ${stageKey}: ${targetThinking}, falling back to medium`, "warning")
421
- }
422
- const currentLevel = pi.getThinkingLevel()
423
- if (currentLevel !== normalized) {
424
- pi.setThinkingLevel(normalized)
425
- if (shouldNotify) {
426
- ctx.ui.notify(`Switched thinking level for ${stageKey}: ${normalized}`, "info")
427
- }
428
- }
429
- }
430
- }
268
+ await applyStageStrategies(pi, ctx, stageKey)
431
269
 
432
270
  return { action: "continue" as const }
433
271
  })
434
272
 
273
+ // Model-initiated skill invocation: when the agent reads a pipeline
274
+ // stage's SKILL.md (instead of the user typing /skill:), route the
275
+ // stage's model/thinking strategies the same way. Reads of other paths
276
+ // are untouched.
277
+ pi.on("tool_call", async (event, ctx) => {
278
+ if (event.toolName !== "read") {
279
+ return undefined
280
+ }
281
+ const filePath = (event.input as { path?: unknown } | undefined)?.path
282
+ if (typeof filePath !== "string") {
283
+ return undefined
284
+ }
285
+ const stageKey = parseStageSkillPath(filePath)
286
+ if (!stageKey) {
287
+ return undefined
288
+ }
289
+ await applyStageStrategies(pi, ctx, stageKey)
290
+ return undefined
291
+ })
292
+
435
293
  const artifactHelper = createArtifactHelperTool()
436
294
  const askUserQuestion = createAskUserQuestionTool()
437
295
  const workflowState = createWorkflowStateTool()
@@ -0,0 +1,230 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
4
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"
5
+
6
+ export const PIPELINE_STAGE_KEYS = new Set([
7
+ "01-brainstorm",
8
+ "02-plan",
9
+ "03-work",
10
+ "04-review",
11
+ "05-learn",
12
+ "06-next",
13
+ "07-worktree",
14
+ ])
15
+
16
+ interface StrategySettings {
17
+ modelStrategy?: Record<string, string>
18
+ thinkingStrategy?: Record<string, string>
19
+ }
20
+
21
+ /**
22
+ * Structural subset of the extension context used by stage routing.
23
+ * Keeps the routing helpers testable without full ExtensionContext mocks.
24
+ */
25
+ interface StageRoutingContext {
26
+ cwd: string
27
+ mode?: string
28
+ hasUI?: boolean
29
+ model?: { provider?: string, id?: string }
30
+ modelRegistry?: { find(provider: string, id: string): unknown }
31
+ ui?: { notify(message: string, level?: string): void }
32
+ }
33
+
34
+ /**
35
+ * Model/thinking strategies only matter where the user can see the switch
36
+ * notification; silent hosts would swallow failures.
37
+ */
38
+ function shouldNotifyRouting(ctx: StageRoutingContext): boolean {
39
+ return ctx.mode === "tui" || ctx.mode === "rpc"
40
+ }
41
+
42
+ async function applyModelStrategy(
43
+ pi: ExtensionAPI,
44
+ ctx: StageRoutingContext,
45
+ stageKey: string,
46
+ strategy: Record<string, string> | undefined,
47
+ ): Promise<void> {
48
+ if (!strategy) {
49
+ return
50
+ }
51
+ const notify = (message: string, level: "info" | "warning") => {
52
+ if (shouldNotifyRouting(ctx)) {
53
+ ctx.ui?.notify(message, level)
54
+ }
55
+ }
56
+
57
+ const targetModelRef = strategy[stageKey] ?? strategy.default
58
+ if (!targetModelRef) {
59
+ return
60
+ }
61
+ const parsed = parseModelRef(targetModelRef, ctx.model?.provider)
62
+ if (!parsed) {
63
+ notify(`Invalid modelStrategy for ${stageKey}: ${targetModelRef}`, "warning")
64
+ return
65
+ }
66
+ // Skip if already using the same model (idempotent re-reads).
67
+ if (ctx.model?.provider === parsed.provider && ctx.model?.id === parsed.id) {
68
+ return
69
+ }
70
+ const model = ctx.modelRegistry?.find(parsed.provider, parsed.id)
71
+ if (!model) {
72
+ notify(`Model not found for ${stageKey}: ${targetModelRef}`, "warning")
73
+ return
74
+ }
75
+ const switched = await pi.setModel(model as Parameters<ExtensionAPI["setModel"]>[0])
76
+ if (switched) {
77
+ notify(`Switched model for ${stageKey}: ${parsed.provider}/${parsed.id}`, "info")
78
+ } else {
79
+ notify(`No API key for ${stageKey}: ${parsed.provider}/${parsed.id}`, "warning")
80
+ }
81
+ }
82
+
83
+ function applyThinkingStrategy(
84
+ pi: ExtensionAPI,
85
+ ctx: StageRoutingContext,
86
+ stageKey: string,
87
+ strategy: Record<string, string> | undefined,
88
+ ): void {
89
+ if (!strategy) {
90
+ return
91
+ }
92
+ const targetThinking = strategy[stageKey] ?? strategy.default
93
+ if (!targetThinking) {
94
+ return
95
+ }
96
+ const levelMap: Record<string, ReturnType<ExtensionAPI["getThinkingLevel"]>> = {
97
+ off: "off",
98
+ minimal: "minimal",
99
+ low: "low",
100
+ medium: "medium",
101
+ high: "high",
102
+ xhigh: "xhigh",
103
+ max: "max",
104
+ "0": "low",
105
+ "1": "medium",
106
+ "2": "high",
107
+ }
108
+ const rawThinking = targetThinking.toLowerCase()
109
+ const knownLevel = levelMap[rawThinking]
110
+ const normalized = knownLevel ?? "medium"
111
+ if (!knownLevel && shouldNotifyRouting(ctx)) {
112
+ ctx.ui?.notify(`Unknown thinking level for ${stageKey}: ${targetThinking}, falling back to medium`, "warning")
113
+ }
114
+ if (pi.getThinkingLevel() !== normalized) {
115
+ pi.setThinkingLevel(normalized)
116
+ if (shouldNotifyRouting(ctx)) {
117
+ ctx.ui?.notify(`Switched thinking level for ${stageKey}: ${normalized}`, "info")
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Apply model + thinking strategies for a pipeline stage. Shared by the
124
+ * `input` hook (explicit `/skill:` commands) and the `tool_call` hook
125
+ * (model-initiated SKILL.md reads).
126
+ */
127
+ export async function applyStageStrategies(
128
+ pi: ExtensionAPI,
129
+ ctx: StageRoutingContext,
130
+ stageKey: string,
131
+ ): Promise<void> {
132
+ const settings = await readSettings(ctx.cwd)
133
+ await applyModelStrategy(pi, ctx, stageKey, settings?.modelStrategy)
134
+ applyThinkingStrategy(pi, ctx, stageKey, settings?.thinkingStrategy)
135
+ }
136
+
137
+ /**
138
+ * Read settings from two locations (config dir honors pi's `CONFIG_DIR_NAME`,
139
+ * which defaults to `.pi` but is user-configurable since pi 0.79.7):
140
+ * 1. Project-level: {cwd}/{CONFIG_DIR_NAME}/settings.json (highest priority)
141
+ * 2. Global-level: ~/{CONFIG_DIR_NAME}/agent/settings.json (fallback)
142
+ *
143
+ * Project-level takes precedence; global-level is used as fallback.
144
+ */
145
+ async function readSettings(cwd: string): Promise<StrategySettings | null> {
146
+ const agentHome = process.env.HOME || "~"
147
+ // Try project-level first
148
+ const projectPath = path.join(cwd, CONFIG_DIR_NAME, "settings.json")
149
+ try {
150
+ const content = await readFile(projectPath, "utf8")
151
+ const projectSettings = JSON.parse(content) as StrategySettings
152
+ // If project has modelStrategy or thinkingStrategy, use it
153
+ if (projectSettings.modelStrategy || projectSettings.thinkingStrategy) {
154
+ return projectSettings
155
+ }
156
+ } catch {
157
+ // Project settings not found, continue to global
158
+ }
159
+
160
+ // Fallback to global-level
161
+ const globalPath = path.join(agentHome, CONFIG_DIR_NAME, "agent", "settings.json")
162
+ try {
163
+ const content = await readFile(globalPath, "utf8")
164
+ return JSON.parse(content) as StrategySettings
165
+ } catch {
166
+ // Global settings not found either
167
+ }
168
+
169
+ // Try ~/{CONFIG_DIR_NAME}/settings.json as another fallback
170
+ const altGlobalPath = path.join(agentHome, CONFIG_DIR_NAME, "settings.json")
171
+ try {
172
+ const content = await readFile(altGlobalPath, "utf8")
173
+ return JSON.parse(content) as StrategySettings
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Detect a pipeline stage SKILL.md path, e.g.
181
+ * `.../skills/03-work/SKILL.md`. Returns the stage key or null.
182
+ */
183
+ export function parseStageSkillPath(filePath: string): string | null {
184
+ // Accept absolute paths, `./`-prefixed, and bare relative paths like
185
+ // `skills/03-work/SKILL.md` (the read tool allows relative paths).
186
+ const match = filePath.match(/(?:^|[\\/])skills[\\/]([^\\/]+)[\\/]SKILL\.md$/)
187
+ if (!match) {
188
+ return null
189
+ }
190
+ const stageKey = match[1]
191
+ return PIPELINE_STAGE_KEYS.has(stageKey) ? stageKey : null
192
+ }
193
+
194
+ export function parseStageSkillName(text: string): string | null {
195
+ const trimmed = text.trim()
196
+ const match = trimmed.match(/^\/skill:([^\s]+)/)
197
+ if (!match) {
198
+ return null
199
+ }
200
+
201
+ const skillName = match[1]
202
+ return PIPELINE_STAGE_KEYS.has(skillName) ? skillName : null
203
+ }
204
+
205
+ function parseModelRef(
206
+ modelRef: string,
207
+ currentProvider?: string,
208
+ ): { provider: string, id: string } | null {
209
+ const trimmed = modelRef.trim()
210
+ if (!trimmed) {
211
+ return null
212
+ }
213
+
214
+ const slashIndex = trimmed.indexOf("/")
215
+ if (slashIndex > 0 && slashIndex < trimmed.length - 1) {
216
+ return {
217
+ provider: trimmed.slice(0, slashIndex),
218
+ id: trimmed.slice(slashIndex + 1),
219
+ }
220
+ }
221
+
222
+ if (!currentProvider) {
223
+ return null
224
+ }
225
+
226
+ return {
227
+ provider: currentProvider,
228
+ id: trimmed,
229
+ }
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leing2021/super-pi",
3
- "version": "0.30.6",
3
+ "version": "0.31.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Pi-native Compound Engineering package for iterative development workflows",
@@ -4,8 +4,12 @@ Use these rules in all Phase 1 skills: `01-brainstorm` → `02-plan` → `03-wor
4
4
 
5
5
  ## Start of skill: model routing
6
6
 
7
- Model routing is handled automatically by the ce-core extension's `input` hook.
8
- When a user types `/skill:01-brainstorm` through `/skill:05-learn`, the extension:
7
+ Model routing is handled automatically by the ce-core extension, with two triggers:
8
+
9
+ 1. **Explicit command**: the user types `/skill:01-brainstorm` through `/skill:07-worktree` (the `input` hook).
10
+ 2. **Model-initiated skill load**: the agent reads a stage's `SKILL.md` (e.g. `.../skills/03-work/SKILL.md`) without an explicit command (the `tool_call` hook).
11
+
12
+ On either trigger the extension:
9
13
 
10
14
  1. Reads `.pi/settings.json` from the project root.
11
15
  2. Parses `modelStrategy[stageKey]` or falls back to `modelStrategy.default`.