@ikie-dev/cli 9.9.0 → 9.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikie-dev/cli",
3
- "version": "9.9.0",
3
+ "version": "9.9.1",
4
4
  "description": "ikie — a coding agent CLI powered by ikie AI",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -38,6 +38,7 @@ import { getActive, getActiveId, getContinuationPolicy } from "./state.js"
38
38
  import { createFermentTipProvider } from "./tips.js"
39
39
  import { registerFermentTodoSync } from "./todo-sync.js"
40
40
  import { applyFermentRuntimeToolProfile } from "./tool-scope.js"
41
+ import { registerEscalateTool } from "./tools/escalate.js"
41
42
  import { registerKnowledgeTools } from "./tools/knowledge.js"
42
43
  import { buildFreeformScopingFeedbackMessage, registerLifecycleTools } from "./tools/lifecycle.js"
43
44
  import { registerPhaseTools } from "./tools/phases.js"
@@ -333,5 +334,6 @@ export default function fermentExtension(pi: ExtensionAPI, runtime: FermentRunti
333
334
  registerPhaseTools(pi, runtime)
334
335
  registerStepTools(pi, runtime)
335
336
  registerKnowledgeTools(pi, runtime)
337
+ registerEscalateTool(pi, runtime)
336
338
  registerAgentSpawnGuard(pi, runtime)
337
339
  }
@@ -0,0 +1,70 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { findFirstPlannedPhase } from "../../../ferment/engine.js"
3
+ import { captureGitHead } from "../phase-evidence.js"
4
+ import type { FermentRuntime } from "../runtime.js"
5
+ import { createApplyAndPersist, toolOk } from "../tool-helpers.js"
6
+ import { applyFermentToolProfile, profileForFerment } from "../tool-scope.js"
7
+
8
+ export function registerEscalateTool(pi: ExtensionAPI, runtime: FermentRuntime): void {
9
+ pi.registerTool({
10
+ name: "escalate_tools",
11
+ label: "Escalate Tools",
12
+ description:
13
+ "Upgrade your tool access when you need write/execute tools (bash, edit, write, start_ferment_step, etc.) that aren't currently available. " +
14
+ "If a ferment exists and is in planning phase, this activates the first planned phase and unlocks the full implementation toolset. " +
15
+ "If no ferment is active, it tells you how to start one.",
16
+ parameters: {
17
+ type: "object",
18
+ properties: {},
19
+ additionalProperties: false,
20
+ },
21
+ async execute() {
22
+ const ferment = runtime.getActive()
23
+
24
+ if (!ferment) {
25
+ return toolOk(
26
+ "No active ferment found. Use `/ferment new <your task>` to start one, then call escalate_tools again after scoping to begin implementation.",
27
+ )
28
+ }
29
+
30
+ const currentProfile = profileForFerment(ferment)
31
+ if (currentProfile === "implementation") {
32
+ return toolOk(
33
+ `Already in implementation phase (ferment: ${ferment.id}). All tools should be available. If something is still missing, check your permissions config.`,
34
+ )
35
+ }
36
+
37
+ const planned = findFirstPlannedPhase(ferment)
38
+ if (!planned) {
39
+ return toolOk(
40
+ `Ferment "${ferment.id}" has no planned phases to activate. Use scope_ferment to define phases, then call escalate_tools again.`,
41
+ )
42
+ }
43
+
44
+ const storage = runtime.getStorage()
45
+ const f = storage.get(ferment.id)
46
+ if (!f) {
47
+ return toolOk(`Ferment "${ferment.id}" not found in storage. Try /ferment list to see available ferments.`)
48
+ }
49
+
50
+ const applyAndPersist = createApplyAndPersist(runtime)
51
+ const outcome = applyAndPersist(ferment.id, { type: "activate_phase", phaseId: planned.id })
52
+ if (!outcome.ok) {
53
+ return toolOk(`Could not activate phase: ${outcome.error.message}`)
54
+ }
55
+
56
+ try {
57
+ applyFermentToolProfile(pi, profileForFerment(outcome.ferment))
58
+ } catch (err) {
59
+ console.error("[ferment] escalate_tools: applyFermentToolProfile failed", err)
60
+ }
61
+
62
+ const headRef = captureGitHead()
63
+ if (headRef) runtime.setPhaseStartRef(ferment.id, planned.id, headRef)
64
+
65
+ return toolOk(
66
+ `Phase "${planned.name}" activated. Tool access upgraded to implementation — you now have bash, edit, write, start_ferment_step, and all other execution tools.`,
67
+ )
68
+ },
69
+ })
70
+ }
@@ -30,6 +30,9 @@ function makeAssistant(content: AssistantMessage["content"]): AssistantMessage {
30
30
  }
31
31
 
32
32
  const textOnlyMessage = makeAssistant([{ type: "text", text: "I will delegate this to Nemotron." }])
33
+ const greetingMessage = makeAssistant([
34
+ { type: "text", text: "Hi there! I'm ready to help. What would you like to work on?" },
35
+ ])
33
36
 
34
37
  const toolCallMessage = makeAssistant([
35
38
  {
@@ -193,6 +196,12 @@ describe("ContinuationNudge.evaluateTurn", () => {
193
196
  simulateSessionWithPriorToolCall(guard)
194
197
  expect(guard.evaluateTurn(textOnlyMessage)).toBe(true)
195
198
  })
199
+
200
+ it("does not nudge normal conversational replies after prior tool use", () => {
201
+ const guard = new ContinuationNudge()
202
+ simulateSessionWithPriorToolCall(guard)
203
+ expect(guard.evaluateTurn(greetingMessage)).toBe(false)
204
+ })
196
205
  })
197
206
 
198
207
  describe("ContinuationNudge session-level tool tracking", () => {
@@ -44,6 +44,13 @@ export const SECOND_NUDGE_TEXT =
44
44
  export const EMPTY_TURN_NUDGE_TEXT =
45
45
  "If you have finished, please summarize the result for the user. Otherwise, continue with the next tool call."
46
46
 
47
+ const TOOL_INTENT_RE =
48
+ /\b(?:i(?:'|’)ll|i\s+will|let\s+me|i(?:'|’)m\s+going\s+to|i\s+am\s+going\s+to|going\s+to)\s+(?:delegate|hand\s+off|ask\s+(?:an?\s+)?(?:agent|subagent)|use|call|run|execute|inspect|read|search|grep|find|edit|write|modify|update|create|open|check|test|verify|review|analy[sz]e|investigate)\b|\b(?:delegating|handing\s+off|calling|running|executing|reading|searching|editing|writing|checking|testing|verifying|reviewing|investigating)\b|\b(?:use|call)\s+(?:the\s+)?(?:agent|task|bash|read|grep|glob|webfetch|apply_patch|tool)\b/i
49
+
50
+ function hasPendingToolIntent(message: AssistantMessage): boolean {
51
+ return message.content.some((c) => c.type === "text" && TOOL_INTENT_RE.test(c.text))
52
+ }
53
+
47
54
  /** Post-turn state machine for the "text-only drift" nudge.
48
55
  *
49
56
  * Fires at most twice per user-input cycle, and only when no tool has been
@@ -140,6 +147,7 @@ export class ContinuationNudge {
140
147
  const hasToolCalls = message.content.some((c) => c.type === "toolCall")
141
148
  const hasText = message.content.some((c) => c.type === "text" && c.text.trim().length > 0)
142
149
  if (hasToolCalls || !hasText) return false
150
+ if (!hasPendingToolIntent(message)) return false
143
151
  this.nudgeCountThisCycle++
144
152
  this.nudgeResponsePending = true
145
153
  return true
@@ -346,8 +346,13 @@ describe("validateModelRoles", () => {
346
346
  expect(result.unavailable).toHaveLength(0)
347
347
  })
348
348
 
349
- it("handles empty available set", () => {
349
+ it("does not warn for unavailable defaults unless requested", () => {
350
350
  const result = validateModelRoles(DEFAULT_MODEL_ROLES, new Set())
351
+ expect(result.unavailable).toHaveLength(0)
352
+ })
353
+
354
+ it("can include unavailable defaults for diagnostics", () => {
355
+ const result = validateModelRoles(DEFAULT_MODEL_ROLES, new Set(), { warnForDefaults: true })
351
356
  expect(result.unavailable.length).toBeGreaterThanOrEqual(5)
352
357
  const flaggedRoles = new Set(result.unavailable.map((u) => u.role))
353
358
  expect(flaggedRoles).toEqual(
@@ -237,15 +237,21 @@ export interface ModelRoleValidationResult {
237
237
  unavailable: { role: keyof ModelRoles; configuredModel: string }[]
238
238
  }
239
239
 
240
+ function roleValuesEqual(a: RoleModelAssignment, b: RoleModelAssignment): boolean {
241
+ return JSON.stringify(normalizeRoleModels(a)) === JSON.stringify(normalizeRoleModels(b))
242
+ }
243
+
240
244
  /**
241
245
  * Validate that each role's model(s) exist in the set of available model IDs.
242
246
  */
243
247
  export function validateModelRoles(
244
248
  roles: ModelRoles,
245
249
  availableModelIds: ReadonlySet<string>,
250
+ options: { warnForDefaults?: boolean } = {},
246
251
  ): ModelRoleValidationResult {
247
252
  const unavailable: ModelRoleValidationResult["unavailable"] = []
248
253
  for (const key of ROLE_KEYS) {
254
+ if (!options.warnForDefaults && roleValuesEqual(roles[key], DEFAULT_MODEL_ROLES[key])) continue
249
255
  const refs = normalizeRoleModels(roles[key])
250
256
  for (const ref of refs) {
251
257
  const id = modelIdFromRef(ref)
@@ -12,6 +12,7 @@ import * as startupContext from "../../startup-context.js"
12
12
  import * as agentWorkerContext from "../agent-worker-context.js"
13
13
  import { CLAUDE_CODE_SKILLS_RESOURCE_ID } from "../claude-code-skills/definition.js"
14
14
  import type { OrchestratorMessages } from "../orchestration/continuation-nudge.js"
15
+ import * as modelRoles from "../orchestration/model-roles.js"
15
16
  import promptEnrichmentExtension, {
16
17
  stripEmptyToolCalls,
17
18
  _resetDeprecatedNotificationTracking,
@@ -687,9 +688,13 @@ describe("model role startup warnings", () => {
687
688
  expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("[model-roles] Warning:"))
688
689
  })
689
690
 
690
- it("keeps unavailable role warnings when Ikie auth is already configured", () => {
691
+ it("keeps unavailable role warnings for explicit user role overrides when Ikie auth is already configured", () => {
691
692
  vi.spyOn(config, "loadConfig").mockReturnValue({ apiKey: "test-key" } as ReturnType<typeof config.loadConfig>)
692
693
  vi.spyOn(startupContext, "getAvailableModels").mockReturnValue([modelMetadata("different-model")])
694
+ vi.spyOn(modelRoles, "getModelRoles").mockReturnValue({
695
+ ...modelRoles.DEFAULT_MODEL_ROLES,
696
+ builder: "anthropic/claude-sonnet-4-5",
697
+ })
693
698
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
694
699
  const pi = {
695
700
  registerFlag: () => {},
@@ -702,7 +707,7 @@ describe("model role startup warnings", () => {
702
707
 
703
708
  promptEnrichmentExtension([])(pi)
704
709
 
705
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("[model-roles] Warning: orchestrator"))
710
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("[model-roles] Warning: builder"))
706
711
  })
707
712
  })
708
713
 
@@ -1095,7 +1100,7 @@ describe("continuation nudge turn_end handler", () => {
1095
1100
  // The handler should allow a second nudge since the model did not
1096
1101
  // intentionally stop.
1097
1102
  await fire("turn_end", {
1098
- message: makeAssistantWithStop([{ type: "text", text: "I was going to say..." }], "length"),
1103
+ message: makeAssistantWithStop([{ type: "text", text: "I was going to call the Agent tool." }], "length"),
1099
1104
  })
1100
1105
  expect(sendMessageCalls.length).toBe(2)
1101
1106
  })
@@ -14,10 +14,20 @@ const namesOf = (entries: ToolEntry[]): string[] => entries.map((t) => t.name)
14
14
  const TODO_TOOL_NAMES = ["create_todos", "update_todos", "add_todo", "mark_todo", "clear_todos"]
15
15
 
16
16
  const TOOL_NAMES = {
17
- sharedCore: ["read", "grep", "find", "ls", "web_fetch", "web_search", "mcp", ...TODO_TOOL_NAMES],
17
+ sharedCore: [
18
+ "read",
19
+ "grep",
20
+ "find",
21
+ "ls",
22
+ "web_fetch",
23
+ "web_search",
24
+ "mcp",
25
+ ...TODO_TOOL_NAMES,
26
+ "set_phase",
27
+ "escalate_tools",
28
+ ],
18
29
  adhocOnly: ["questionnaire"],
19
30
  fermentPlanningTools: [
20
- "set_phase",
21
31
  "propose_ferment_scoping",
22
32
  "scope_ferment",
23
33
  "update_ferment_scope_field",
@@ -44,7 +54,7 @@ const TOOL_NAMES = {
44
54
 
45
55
  describe("SHARED_CORE_TOOLS", () => {
46
56
  it("contains the 6 read-only discovery tools, the mcp gateway, plus 5 todo lifecycle tools", () => {
47
- expect(SHARED_CORE_TOOLS).toHaveLength(12)
57
+ expect(SHARED_CORE_TOOLS).toHaveLength(14)
48
58
  for (const name of TOOL_NAMES.sharedCore) {
49
59
  expect(SHARED_CORE_TOOLS).toContainEqual(expect.objectContaining({ name }))
50
60
  }
@@ -60,7 +70,7 @@ describe("ADHOC_MODE_TOOLS", () => {
60
70
  })
61
71
 
62
72
  it("does not contain ferment-mode tools", () => {
63
- const fermentTools = ["ask_user", "confirm_ferment_completion_criteria", "set_phase", "activate_ferment_phase"]
73
+ const fermentTools = ["ask_user", "confirm_ferment_completion_criteria", "activate_ferment_phase"]
64
74
  for (const name of fermentTools) {
65
75
  expect(namesOf(ADHOC_MODE_TOOLS)).not.toContain(name)
66
76
  }
@@ -71,7 +81,6 @@ describe("FERMENT_MODE_TOOLS", () => {
71
81
  const allNames = namesOf(FERMENT_MODE_TOOLS)
72
82
 
73
83
  const expectedPlanningTools = [
74
- "set_phase",
75
84
  "propose_ferment_scoping",
76
85
  "scope_ferment",
77
86
  "update_ferment_scope_field",
@@ -116,7 +125,6 @@ describe("FERMENT_MODE_TOOLS", () => {
116
125
  }
117
126
 
118
127
  // planning-only
119
- expect(byName("set_phase").phases).toEqual(["planning"])
120
128
  expect(byName("propose_ferment_scoping").phases).toEqual(["planning"])
121
129
  expect(byName("confirm_ferment_completion_criteria").phases).toEqual(["planning"])
122
130
  expect(byName("activate_ferment_phase").phases).toEqual(["planning"])
@@ -156,7 +164,7 @@ describe("getToolsForProfile", () => {
156
164
  describe("idle", () => {
157
165
  it("returns only shared core tools", () => {
158
166
  const result = getToolsForProfile("idle")
159
- expect(result).toHaveLength(12)
167
+ expect(result).toHaveLength(14)
160
168
  expect(namesOf(result)).toEqual(TOOL_NAMES.sharedCore)
161
169
  })
162
170
 
@@ -205,7 +213,6 @@ describe("getToolsForProfile", () => {
205
213
  const fermentOnly = [
206
214
  "ask_user",
207
215
  "confirm_ferment_completion_criteria",
208
- "set_phase",
209
216
  "propose_ferment_scoping",
210
217
  "scope_ferment",
211
218
  "activate_ferment_phase",
@@ -112,6 +112,10 @@ export const SHARED_CORE_TOOLS: ToolEntry[] = [
112
112
  { name: "add_todo", modes: ["shared"] },
113
113
  { name: "mark_todo", modes: ["shared"] },
114
114
  { name: "clear_todos", modes: ["shared"] },
115
+ // Phase tracker — always available so the agent can declare intent
116
+ { name: "set_phase", modes: ["shared"] },
117
+ // Self-escalation — agent calls this when it needs write tools
118
+ { name: "escalate_tools", modes: ["shared"] },
115
119
  ]
116
120
 
117
121
  /** Tools gated behind `--plan` (adhoc planning mode). */
@@ -134,9 +138,6 @@ export const ADHOC_MODE_TOOLS: ToolEntry[] = [
134
138
  export const FERMENT_MODE_TOOLS: ToolEntry[] = [
135
139
  // -- planning phase (before any phase is activated) --
136
140
 
137
- // Phase tracker injected by the ferment planner supplement
138
- { name: "set_phase", modes: ["ferment"], phases: ["planning"] },
139
-
140
141
  // Draft scoping surface
141
142
  { name: "propose_ferment_scoping", modes: ["ferment"], phases: ["planning"] },
142
143
  { name: "scope_ferment", modes: ["ferment"], phases: ["planning"] },