@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 +1 -1
- package/src/extensions/ferment/index.ts +2 -0
- package/src/extensions/ferment/tools/escalate.ts +70 -0
- package/src/extensions/orchestration/continuation-nudge.test.ts +9 -0
- package/src/extensions/orchestration/continuation-nudge.ts +8 -0
- package/src/extensions/orchestration/model-roles.test.ts +6 -1
- package/src/extensions/orchestration/model-roles.ts +6 -0
- package/src/extensions/prompt-construction/prompt-enrichment.test.ts +8 -3
- package/src/shared/planning/tool-catalog.test.ts +15 -8
- package/src/shared/planning/tool-catalog.ts +4 -3
package/package.json
CHANGED
|
@@ -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("
|
|
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:
|
|
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
|
|
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: [
|
|
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(
|
|
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", "
|
|
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(
|
|
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"] },
|