@linimin/pi-letscook 0.1.51 → 0.1.52

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.
@@ -1,14 +1,20 @@
1
+ import { promises as fsp } from "node:fs";
1
2
  import * as path from "node:path";
2
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
4
  import { runCookEntry, type CompletionDriverDeps } from "./driver";
4
5
  import {
5
6
  buildCookTriggerAssistConfirmationLayout,
7
+ buildCookTriggerClarificationLayout,
8
+ buildCookTriggerRecoveryLayout,
9
+ maybeWriteCookTriggerClarificationSnapshot,
6
10
  maybeWriteCookTriggerConfirmationSnapshot,
11
+ maybeWriteCookTriggerRecoverySnapshot,
7
12
  maybeWriteCookTriggerRoutingSnapshot,
8
13
  } from "./prompt-surfaces";
9
14
  import {
10
15
  collectRecentDiscussionEntries,
11
16
  hasRecentDiscussionImplementationIntent,
17
+ hasStructuredContextProposalSignal,
12
18
  stripCodeBlocks,
13
19
  } from "./proposal";
14
20
  import {
@@ -18,9 +24,15 @@ import {
18
24
  import { asString, loadCompletionSnapshot } from "./state-store";
19
25
  import type {
20
26
  CompletionStateSnapshot,
27
+ CookNaturalLanguageHandoff,
28
+ CookTriggerAdoptedArtifact,
29
+ CookTriggerClarificationAction,
30
+ CookTriggerClarificationCapsule,
21
31
  CookTriggerClassification,
22
32
  CookTriggerConfirmationAction,
23
33
  CookTriggerDecision,
34
+ CookTriggerRecoveryAction,
35
+ CookTriggerWorkflowBias,
24
36
  NaturalLanguageCookTriggerMode,
25
37
  } from "./types";
26
38
 
@@ -41,13 +53,33 @@ type InputRoutingContext = {
41
53
  hasPendingMessages: () => boolean;
42
54
  };
43
55
 
56
+ type ContextProposal = Awaited<ReturnType<CompletionDriverDeps["deriveCookContextProposal"]>>;
57
+
58
+ type RecentSessionMessage = {
59
+ role: "user" | "assistant" | "custom";
60
+ text: string;
61
+ };
62
+
44
63
  const MAX_TRIGGER_CANDIDATE_LENGTH = 120;
45
64
  const MAX_TRIGGER_CANDIDATE_LINES = 3;
65
+ const ADOPTED_ARTIFACT_PREVIEW_LIMIT = 280;
66
+ const ROUTER_BYPASS_REPLAY_PREFIX = "__pi_completion_router_bypass__:";
67
+ const ROUTER_FAILURE_RETRY_LIMIT = 2;
46
68
  const CLEAR_TRIGGER_PATTERNS = [
47
- /^(?:go ahead|please go ahead|proceed|let'?s do it|let'?s start|start(?: implementing| implementation)?|begin(?: implementing| implementation)?|continue(?: with implementation| implementing)?|ship it|work on it|do it)\b/i,
48
- /^(?:開始(?:做|實作|实现|落地)|开始(?:做|实作|实现|落地)|那就做吧|照(?:剛剛|刚刚|這個|这个|上述|上面的)?(?:討論|讨论|方向).*(?:做|實作|实现|落地)|可以開始(?:做|實作|实现)?|可以开始(?:做|实作|实现)?|繼續(?:做|實作|实现|往下做)|继续(?:做|实作|实现|往下做))/u,
69
+ /^(?:go ahead|please go ahead|proceed|let'?s do it|let'?s start|start(?: implementing| implementation| the workflow| the next round)?|begin(?: implementing| implementation| the workflow| the next round)?|continue(?: with implementation| implementing| the workflow)?|resume(?: the workflow| where we left off)?|next step|work on it|do it|ship it|let'?s do this instead|switch to this)\b/i,
70
+ /^(?:開始(?:做|實作|实现|落地|下一輪)|开始(?:做|实作|实现|落地|下一轮)|那就做吧|照(?:剛剛|刚刚|這個|这个|上述|上面的)?(?:討論|讨论|方向).*(?:做|實作|实现|落地)|可以開始(?:做|實作|实现|下一輪)?|可以开始(?:做|实作|实现|下一轮)?|繼續(?:做|實作|实现|往下做)|继续(?:做|实作|实现|往下做)|接著(?:做|實作|实现)|接着(?:做|实作|实现)|下一步|那改做(?:這個|这个)|先做新的那個方向|先做新的那个方向|好,開始做這個|好,开始做这个)/u,
71
+ ];
72
+ const ADOPTED_PLAN_TRIGGER_PATTERNS = [
73
+ /^(?:use|follow|start from|begin from|work from|go with|implement from)\b.*\b(?:plan|proposal|spec|summary|notes|[\w./-]+\.md)\b/i,
74
+ /^(?:start|begin|implement|do)\b.*\b(?:from|using)\b.*\b(?:plan|proposal|spec|summary|[\w./-]+\.md)\b/i,
75
+ /^(?:照|依|按照|就照|跟著|跟着|用).*(?:剛剛|刚刚|最新|上面|上述|那份|這份|这份|這個|这个|方案|計劃|计划|提案|規格|规格|總結|总结|[\w./-]+\.md).*(?:做|開始|开始|實作|实现|落地)/u,
76
+ ];
77
+ const EXPLICIT_ARTIFACT_ADOPTION_PATTERNS = [
78
+ /(?:\b(?:use|follow|start from|begin from|work from|go with|implement from)\b.*\b(?:plan|proposal|spec|summary|notes|[\w./-]+\.md)\b)/i,
79
+ /(?:照|依|按照|就照|跟著|跟着|用).*(?:剛剛|刚刚|最新|上面|上述|那份|這份|这份|這個|这个|方案|計劃|计划|提案|規格|规格|總結|总结|[\w./-]+\.md)/u,
49
80
  ];
50
81
  const AMBIGUOUS_ACK_PATTERNS = [/^(?:ok|okay|sure|fine|yes|yeah|yep)$/i, /^(?:好|好的|可以|嗯|那就這樣|那就这样|就這樣|就这样|先這樣|先这样|收到)$/u];
82
+ const MARKDOWN_PATH_PATTERN = /(?:^|[\s("'`])((?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.md)(?=$|[\s)"'`.,;:])/g;
51
83
 
52
84
  function roleFromEnv(): string | undefined {
53
85
  return asString(process.env.PI_COMPLETION_ROLE);
@@ -58,11 +90,13 @@ function configuredTriggerMode(): NaturalLanguageCookTriggerMode {
58
90
  asString(process.env.PI_COMPLETION_TEST_TRIGGER_MODE)?.toLowerCase() ??
59
91
  asString(process.env.PI_COMPLETION_TRIGGER_MODE)?.toLowerCase() ??
60
92
  "assist";
61
- return raw === "off" || raw === "assist" || raw === "auto" ? raw : "assist";
93
+ return raw === "off" || raw === "assist" || raw === "router" || raw === "auto" ? raw : "assist";
62
94
  }
63
95
 
64
- function effectiveTriggerMode(mode: NaturalLanguageCookTriggerMode): "off" | "assist" {
65
- return mode === "off" ? "off" : "assist";
96
+ function effectiveTriggerMode(mode: NaturalLanguageCookTriggerMode): "off" | "assist" | "router" {
97
+ if (mode === "off") return "off";
98
+ if (mode === "assist") return "assist";
99
+ return "router";
66
100
  }
67
101
 
68
102
  function triggerRoutingSnapshotPath(): string | undefined {
@@ -73,12 +107,41 @@ function triggerConfirmationSnapshotPath(): string | undefined {
73
107
  return asString(process.env.PI_COMPLETION_TEST_TRIGGER_CONFIRMATION_PATH);
74
108
  }
75
109
 
110
+ function triggerClarificationSnapshotPath(): string | undefined {
111
+ return asString(process.env.PI_COMPLETION_TEST_TRIGGER_CLARIFICATION_PATH);
112
+ }
113
+
114
+ function triggerRecoverySnapshotPath(): string | undefined {
115
+ return asString(process.env.PI_COMPLETION_TEST_TRIGGER_RECOVERY_PATH);
116
+ }
117
+
76
118
  function triggerConfirmationOverride(): CookTriggerConfirmationAction | undefined {
77
119
  const raw = asString(process.env.PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION)?.toLowerCase();
78
120
  if (!raw) return undefined;
79
- if (raw === "start" || raw === "start_cook" || raw === "cook") return "start_cook";
80
- if (raw === "continue" || raw === "keep_chatting" || raw === "keep-chatting") return "keep_chatting";
81
- if (raw === "cancel") return "cancel";
121
+ if (raw === "start" || raw === "start_cook" || raw === "start_workflow" || raw === "cook") return "start_workflow";
122
+ if (raw === "send_as_normal_chat" || raw === "send-as-normal-chat" || raw === "normal_chat" || raw === "normal-chat") return "send_as_normal_chat";
123
+ if (raw === "cancel" || raw === "dismiss") return "cancel";
124
+ return undefined;
125
+ }
126
+
127
+ function triggerClarificationOverride(): CookTriggerClarificationAction | undefined {
128
+ const raw = asString(process.env.PI_COMPLETION_TEST_TRIGGER_CLARIFICATION_ACTION)?.toLowerCase();
129
+ if (!raw) return undefined;
130
+ if (raw === "startup" || raw === "route_startup") return "route_startup";
131
+ if (raw === "resume" || raw === "route_resume") return "route_resume";
132
+ if (raw === "refocus" || raw === "route_refocus") return "route_refocus";
133
+ if (raw === "next_round" || raw === "next-round" || raw === "route_next_round") return "route_next_round";
134
+ if (raw === "send_as_normal_chat" || raw === "send-as-normal-chat" || raw === "normal_chat" || raw === "normal-chat") return "send_as_normal_chat";
135
+ if (raw === "cancel" || raw === "dismiss") return "cancel";
136
+ return undefined;
137
+ }
138
+
139
+ function triggerRecoveryOverride(): CookTriggerRecoveryAction | undefined {
140
+ const raw = asString(process.env.PI_COMPLETION_TEST_TRIGGER_RECOVERY_ACTION)?.toLowerCase();
141
+ if (!raw) return undefined;
142
+ if (raw === "retry" || raw === "retry_routing" || raw === "retry-routing") return "retry_routing";
143
+ if (raw === "send_as_normal_chat" || raw === "send-as-normal-chat" || raw === "normal_chat" || raw === "normal-chat") return "send_as_normal_chat";
144
+ if (raw === "cancel" || raw === "dismiss") return "cancel";
82
145
  return undefined;
83
146
  }
84
147
 
@@ -86,6 +149,11 @@ function normalizeTriggerText(text: string): string {
86
149
  return text.replace(/\s+/g, " ").trim();
87
150
  }
88
151
 
152
+ function truncateInline(text: string, maxLength = ADOPTED_ARTIFACT_PREVIEW_LIMIT): string {
153
+ const normalized = text.replace(/\s+/g, " ").trim();
154
+ return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…` : normalized;
155
+ }
156
+
89
157
  function hasImages(event: InputRoutingEvent): boolean {
90
158
  return Array.isArray(event.images) && event.images.length > 0;
91
159
  }
@@ -94,6 +162,10 @@ function activeWorkflowContext(snapshot: CompletionStateSnapshot | undefined): b
94
162
  return Boolean(snapshot) && asString(snapshot?.state?.continuation_policy) !== "done";
95
163
  }
96
164
 
165
+ function isExplicitArtifactAdoption(text: string): boolean {
166
+ return EXPLICIT_ARTIFACT_ADOPTION_PATTERNS.some((pattern) => pattern.test(text));
167
+ }
168
+
97
169
  function looksLikeTriggerCandidate(text: string): boolean {
98
170
  const normalized = normalizeTriggerText(text);
99
171
  if (!normalized) return false;
@@ -101,7 +173,7 @@ function looksLikeTriggerCandidate(text: string): boolean {
101
173
  if (text.split(/\r?\n/).length > MAX_TRIGGER_CANDIDATE_LINES) return false;
102
174
  if (normalized.startsWith("/") || normalized.startsWith("!")) return false;
103
175
  if (AMBIGUOUS_ACK_PATTERNS.some((pattern) => pattern.test(normalized))) return false;
104
- return CLEAR_TRIGGER_PATTERNS.some((pattern) => pattern.test(normalized));
176
+ return CLEAR_TRIGGER_PATTERNS.some((pattern) => pattern.test(normalized)) || ADOPTED_PLAN_TRIGGER_PATTERNS.some((pattern) => pattern.test(normalized));
105
177
  }
106
178
 
107
179
  function hasRecentImplementationContext(entries: Array<{ text: string }>): boolean {
@@ -132,7 +204,8 @@ function writeRoutingDecision(event: InputRoutingEvent, decision: CookTriggerDec
132
204
  action: decision.action,
133
205
  reason: decision.reason,
134
206
  bypassReason: decision.bypassReason ?? null,
135
- classificationIntent: decision.classification?.intent ?? null,
207
+ classificationDecision: decision.classification?.decision ?? null,
208
+ workflowBias: decision.classification?.workflowBias ?? null,
136
209
  confidence: decision.classification?.confidence ?? null,
137
210
  classifierReason: decision.classification?.reason ?? null,
138
211
  focusHint: decision.classification?.focusHint ?? null,
@@ -156,6 +229,216 @@ function classifierFailureReason(result: CookTriggerClassifierResult): string {
156
229
  }
157
230
  }
158
231
 
232
+ function classifierFailureLabel(result: CookTriggerClassifierResult): string {
233
+ switch (result.status) {
234
+ case "timeout":
235
+ return "The router classifier timed out before it could decide whether /cook should take over.";
236
+ case "invalid_output":
237
+ return "The router classifier returned invalid JSON output, so the router refused to guess.";
238
+ case "error":
239
+ default:
240
+ return result.errorMessage?.trim() || "The router classifier failed before it could return a valid decision.";
241
+ }
242
+ }
243
+
244
+ function routerBypassReplayText(text: string): string {
245
+ return `${ROUTER_BYPASS_REPLAY_PREFIX}${text}`;
246
+ }
247
+
248
+ function consumeRouterBypassReplay(event: InputRoutingEvent): string | undefined {
249
+ if (event.source !== "extension") return undefined;
250
+ if (typeof event.text !== "string" || !event.text.startsWith(ROUTER_BYPASS_REPLAY_PREFIX)) return undefined;
251
+ return event.text.slice(ROUTER_BYPASS_REPLAY_PREFIX.length);
252
+ }
253
+
254
+ async function replayOriginalMessageToPrimaryAgent(
255
+ pi: ExtensionAPI,
256
+ event: InputRoutingEvent,
257
+ ): Promise<void> {
258
+ await pi.sendUserMessage(routerBypassReplayText(event.text));
259
+ }
260
+
261
+ function extractMessageText(content: unknown): string {
262
+ if (typeof content === "string") return content.trim();
263
+ if (!Array.isArray(content)) return "";
264
+ return content
265
+ .map((item) => {
266
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return "";
267
+ return item.type === "text" && typeof item.text === "string" ? item.text.trim() : "";
268
+ })
269
+ .filter((item) => item.length > 0)
270
+ .join("\n")
271
+ .trim();
272
+ }
273
+
274
+ function recentSessionMessages(ctx: InputRoutingContext, limit = 12): RecentSessionMessage[] {
275
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
276
+ const entries: RecentSessionMessage[] = [];
277
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
278
+ const entry = branch[index];
279
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
280
+ if (entry.type !== "message" || typeof entry.message !== "object" || entry.message === null || Array.isArray(entry.message)) continue;
281
+ const role = asString(entry.message.role);
282
+ if (role !== "user" && role !== "assistant" && role !== "custom") continue;
283
+ const text = extractMessageText(entry.message.content);
284
+ if (!text || /^\/(?:cook|complete)\b/i.test(text)) continue;
285
+ entries.push({ role, text });
286
+ if (entries.length >= limit) break;
287
+ }
288
+ return entries;
289
+ }
290
+
291
+ function extractMarkdownPath(text: string): string | undefined {
292
+ let match: RegExpExecArray | null;
293
+ while ((match = MARKDOWN_PATH_PATTERN.exec(text)) !== null) {
294
+ const candidate = match[1]?.trim();
295
+ if (candidate) return candidate;
296
+ }
297
+ return undefined;
298
+ }
299
+
300
+ async function readRepoMarkdownArtifact(root: string, candidatePath: string): Promise<CookTriggerAdoptedArtifact | undefined> {
301
+ const resolved = path.resolve(root, candidatePath);
302
+ const relative = path.relative(root, resolved);
303
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
304
+ try {
305
+ const raw = await fsp.readFile(resolved, "utf8");
306
+ return {
307
+ kind: "repo_markdown",
308
+ basis: "explicit_user_adoption",
309
+ title: candidatePath,
310
+ path: candidatePath,
311
+ preview: truncateInline(raw),
312
+ };
313
+ } catch {
314
+ return undefined;
315
+ }
316
+ }
317
+
318
+ function findRecentPlanArtifact(recentMessages: RecentSessionMessage[]): CookTriggerAdoptedArtifact | undefined {
319
+ for (const entry of recentMessages) {
320
+ if (entry.role !== "assistant" && entry.role !== "custom") continue;
321
+ if (!hasStructuredContextProposalSignal(entry.text, stripCodeBlocks) && !/(?:plan|proposal|spec|方案|計劃|计划|提案|規格|规格)/iu.test(entry.text)) {
322
+ continue;
323
+ }
324
+ return {
325
+ kind: "recent_plan",
326
+ basis: "explicit_user_adoption",
327
+ title: entry.role === "assistant" ? "latest discussed assistant plan" : "latest discussed plan",
328
+ preview: truncateInline(entry.text),
329
+ };
330
+ }
331
+ return undefined;
332
+ }
333
+
334
+ async function detectExplicitAdoptedArtifact(
335
+ eventText: string,
336
+ ctx: InputRoutingContext,
337
+ root: string,
338
+ recentMessages: RecentSessionMessage[],
339
+ ): Promise<CookTriggerAdoptedArtifact | undefined> {
340
+ if (!isExplicitArtifactAdoption(eventText)) return undefined;
341
+ const markdownPath = extractMarkdownPath(eventText);
342
+ if (markdownPath) {
343
+ return readRepoMarkdownArtifact(root, markdownPath);
344
+ }
345
+ return findRecentPlanArtifact(recentMessages);
346
+ }
347
+
348
+ function buildAdoptedArtifactHint(adoptedArtifact: CookTriggerAdoptedArtifact | undefined): string | undefined {
349
+ if (!adoptedArtifact) return undefined;
350
+ const lines = [`User explicitly adopted ${adoptedArtifact.kind === "repo_markdown" ? "repo markdown artifact" : "recent plan"}: ${adoptedArtifact.title}`];
351
+ if (adoptedArtifact.path) lines.push(`Artifact path: ${adoptedArtifact.path}`);
352
+ if (adoptedArtifact.preview) lines.push(`Artifact preview: ${adoptedArtifact.preview}`);
353
+ return lines.join("\n");
354
+ }
355
+
356
+ function buildClarificationWorkflowBiases(
357
+ snapshot: CompletionStateSnapshot | undefined,
358
+ proposal: ContextProposal,
359
+ ): CookTriggerWorkflowBias[] {
360
+ if (!snapshot) return ["startup"];
361
+ if (!activeWorkflowContext(snapshot)) return ["next_round"];
362
+ const currentMission = asString(snapshot.state?.mission_anchor) ?? asString(snapshot.plan?.mission_anchor) ?? asString(snapshot.active?.mission_anchor);
363
+ if (proposal?.mission && currentMission && proposal.mission.trim() === currentMission.trim()) {
364
+ return ["resume"];
365
+ }
366
+ if (proposal?.mission && currentMission && proposal.mission.trim() !== currentMission.trim()) {
367
+ return ["resume", "refocus"];
368
+ }
369
+ return ["resume"];
370
+ }
371
+
372
+ function clarificationBiasFromAction(action: CookTriggerClarificationAction): CookTriggerWorkflowBias | undefined {
373
+ switch (action) {
374
+ case "route_startup":
375
+ return "startup";
376
+ case "route_resume":
377
+ return "resume";
378
+ case "route_refocus":
379
+ return "refocus";
380
+ case "route_next_round":
381
+ return "next_round";
382
+ default:
383
+ return undefined;
384
+ }
385
+ }
386
+
387
+ function buildClarificationCapsule(
388
+ action: CookTriggerClarificationAction,
389
+ classification: CookTriggerClassification,
390
+ proposal: ContextProposal,
391
+ ): CookTriggerClarificationCapsule | undefined {
392
+ const selectedWorkflowBias = clarificationBiasFromAction(action);
393
+ if (!selectedWorkflowBias) return undefined;
394
+ return {
395
+ selectedWorkflowBias,
396
+ reason: classification.reason,
397
+ goal: proposal?.mission ?? classification.focusHint,
398
+ scope: proposal?.scope?.slice(0, 3),
399
+ nonGoal: proposal?.constraints?.slice(0, 2),
400
+ doneWhen: proposal?.acceptance?.slice(0, 2),
401
+ };
402
+ }
403
+
404
+ function routingExtrasForArtifact(adoptedArtifact: CookTriggerAdoptedArtifact | undefined): Record<string, unknown> {
405
+ return adoptedArtifact
406
+ ? {
407
+ adoptedArtifactKind: adoptedArtifact.kind,
408
+ adoptedArtifactBasis: adoptedArtifact.basis,
409
+ adoptedArtifactTitle: adoptedArtifact.title,
410
+ adoptedArtifactPath: adoptedArtifact.path ?? null,
411
+ adoptedArtifactPreview: adoptedArtifact.preview ?? null,
412
+ }
413
+ : {
414
+ adoptedArtifactKind: null,
415
+ adoptedArtifactBasis: null,
416
+ adoptedArtifactTitle: null,
417
+ adoptedArtifactPath: null,
418
+ adoptedArtifactPreview: null,
419
+ };
420
+ }
421
+
422
+ function routingExtrasForClarification(clarificationCapsule: CookNaturalLanguageHandoff["clarificationCapsule"] | undefined): Record<string, unknown> {
423
+ return clarificationCapsule
424
+ ? {
425
+ clarificationSelectedBias: clarificationCapsule.selectedWorkflowBias,
426
+ clarificationReason: clarificationCapsule.reason,
427
+ clarificationGoal: clarificationCapsule.goal ?? null,
428
+ clarificationScope: clarificationCapsule.scope ?? [],
429
+ clarificationNonGoal: clarificationCapsule.nonGoal ?? [],
430
+ clarificationDoneWhen: clarificationCapsule.doneWhen ?? [],
431
+ }
432
+ : {
433
+ clarificationSelectedBias: null,
434
+ clarificationReason: null,
435
+ clarificationGoal: null,
436
+ clarificationScope: [],
437
+ clarificationNonGoal: [],
438
+ clarificationDoneWhen: [],
439
+ };
440
+ }
441
+
159
442
  async function promptCookTriggerTakeover(
160
443
  ctx: InputRoutingContext,
161
444
  classification: CookTriggerClassification,
@@ -180,11 +463,64 @@ async function promptCookTriggerTakeover(
180
463
  return index >= 0 ? layout.actions[index].id : "cancel";
181
464
  }
182
465
 
183
- function guidanceForClassifierFailure(result: CookTriggerClassifierResult): string {
184
- if (result.status === "timeout") {
185
- return "Could not safely determine whether /cook should take over before implementation work started because the trigger classifier timed out. If you want the completion workflow boundary, run /cook explicitly.";
186
- }
187
- return "Could not safely determine whether /cook should take over before implementation work started. If you want the completion workflow boundary, run /cook explicitly.";
466
+ async function promptCookTriggerClarification(
467
+ ctx: InputRoutingContext,
468
+ snapshot: CompletionStateSnapshot | undefined,
469
+ proposal: ContextProposal,
470
+ adoptedArtifact: CookTriggerAdoptedArtifact | undefined,
471
+ deps: CompletionDriverDeps,
472
+ ): Promise<CookTriggerClarificationAction> {
473
+ const workflowBiases = buildClarificationWorkflowBiases(snapshot, proposal);
474
+ const override = triggerClarificationOverride();
475
+ const layout = buildCookTriggerClarificationLayout({
476
+ currentMission: asString(snapshot?.state?.mission_anchor) ?? asString(snapshot?.plan?.mission_anchor),
477
+ candidateMission: proposal?.mission,
478
+ workflowBiases,
479
+ mainChatRerunGuidance: deps.mainChatRerunGuidance,
480
+ adoptedArtifact,
481
+ });
482
+ maybeWriteCookTriggerClarificationSnapshot(layout, triggerClarificationSnapshotPath());
483
+ if (override) return override;
484
+ if (!ctx.hasUI || !ctx.ui) return "cancel";
485
+ const choices = layout.actions.map((action) => `${action.label}\n\n${action.description}`);
486
+ const titleParts = [layout.title, "", layout.intro];
487
+ if (layout.currentMissionHeading && layout.currentMissionBody) titleParts.push("", layout.currentMissionHeading, layout.currentMissionBody);
488
+ if (layout.candidateMissionHeading && layout.candidateMissionBody) titleParts.push("", layout.candidateMissionHeading, layout.candidateMissionBody);
489
+ if (layout.adoptedArtifactHeading && layout.adoptedArtifactBody) titleParts.push("", layout.adoptedArtifactHeading, layout.adoptedArtifactBody);
490
+ const choice = await ctx.ui.select(titleParts.join("\n"), choices);
491
+ if (!choice) return "cancel";
492
+ const index = choices.indexOf(choice);
493
+ return index >= 0 ? layout.actions[index].id : "cancel";
494
+ }
495
+
496
+ async function promptCookTriggerRecovery(
497
+ ctx: InputRoutingContext,
498
+ result: CookTriggerClassifierResult,
499
+ deps: CompletionDriverDeps,
500
+ ): Promise<CookTriggerRecoveryAction> {
501
+ const override = triggerRecoveryOverride();
502
+ const layout = buildCookTriggerRecoveryLayout({
503
+ failureLabel: classifierFailureLabel(result),
504
+ mainChatRerunGuidance: deps.mainChatRerunGuidance,
505
+ });
506
+ maybeWriteCookTriggerRecoverySnapshot(layout, triggerRecoverySnapshotPath());
507
+ if (override) return override;
508
+ if (!ctx.hasUI || !ctx.ui) return "cancel";
509
+ const choices = layout.actions.map((action) => `${action.label}\n\n${action.description}`);
510
+ const titleParts = [layout.title, "", layout.intro];
511
+ if (layout.failureHeading && layout.failureBody) titleParts.push("", layout.failureHeading, layout.failureBody);
512
+ const choice = await ctx.ui.select(titleParts.join("\n"), choices);
513
+ if (!choice) return "cancel";
514
+ const index = choices.indexOf(choice);
515
+ return index >= 0 ? layout.actions[index].id : "cancel";
516
+ }
517
+
518
+ function buildHandoffHintText(
519
+ classification: CookTriggerClassification,
520
+ clarificationCapsule: CookNaturalLanguageHandoff["clarificationCapsule"] | undefined,
521
+ adoptedArtifact: CookTriggerAdoptedArtifact | undefined,
522
+ ): string | undefined {
523
+ return clarificationCapsule?.goal ?? classification.focusHint ?? adoptedArtifact?.title;
188
524
  }
189
525
 
190
526
  export async function handleCookNaturalLanguageTrigger(
@@ -192,7 +528,12 @@ export async function handleCookNaturalLanguageTrigger(
192
528
  event: InputRoutingEvent,
193
529
  ctx: InputRoutingContext,
194
530
  deps: CompletionDriverDeps,
195
- ): Promise<{ action: "continue" | "handled" }> {
531
+ ): Promise<{ action: "continue" | "handled" } | { action: "transform"; text: string; images?: unknown[] }> {
532
+ const replayText = consumeRouterBypassReplay(event);
533
+ if (replayText !== undefined) {
534
+ return { action: "transform", text: replayText, images: event.images };
535
+ }
536
+
196
537
  const configuredMode = configuredTriggerMode();
197
538
  const mode = effectiveTriggerMode(configuredMode);
198
539
  if (mode === "off") {
@@ -246,78 +587,194 @@ export async function handleCookNaturalLanguageTrigger(
246
587
  }
247
588
 
248
589
  const snapshot = await loadCompletionSnapshot(ctx.cwd);
590
+ const root = snapshot?.files.root ?? ctx.cwd;
591
+ const projectName = path.basename(root);
249
592
  const recentEntries = collectRecentDiscussionEntries(ctx, {
250
593
  asString,
251
594
  isRecord: (value) => typeof value === "object" && value !== null && !Array.isArray(value),
252
595
  }, 6);
253
- if (!activeWorkflowContext(snapshot) && !hasRecentImplementationContext(recentEntries)) {
596
+ const recentMessages = recentSessionMessages(ctx, 12);
597
+ const adoptedArtifact = await detectExplicitAdoptedArtifact(event.text, ctx, root, recentMessages);
598
+ const routerMode = mode === "router";
599
+ if (!routerMode && !activeWorkflowContext(snapshot) && !hasRecentImplementationContext(recentEntries) && !adoptedArtifact) {
254
600
  writeRoutingDecision(event, {
255
601
  mode: configuredMode,
256
602
  action: "continue",
257
603
  reason: "no_workflow_or_recent_implementation_context",
258
604
  bypassReason: "no_workflow_or_recent_implementation_context",
259
- });
605
+ }, routingExtrasForArtifact(adoptedArtifact));
260
606
  return { action: "continue" };
261
607
  }
262
- if (!looksLikeTriggerCandidate(event.text)) {
608
+ if (!routerMode && !looksLikeTriggerCandidate(event.text)) {
263
609
  writeRoutingDecision(event, {
264
610
  mode: configuredMode,
265
611
  action: "continue",
266
612
  reason: "not_candidate",
267
613
  bypassReason: "not_candidate",
268
- });
614
+ }, routingExtrasForArtifact(adoptedArtifact));
269
615
  return { action: "continue" };
270
616
  }
271
617
 
272
- const classifier = await classifyCookTriggerIntentWithAgent({
273
- ctx,
274
- projectName: path.basename(snapshot?.files.root ?? ctx.cwd),
275
- inputText: normalizeTriggerText(event.text),
276
- recentEntries,
277
- workflowContextLines: buildTriggerWorkflowContextLines(snapshot),
278
- });
279
- if (classifier.status !== "classified" || !classifier.classification) {
280
- deps.emitCommandText(ctx, guidanceForClassifierFailure(classifier), "info");
618
+ let classifier: CookTriggerClassifierResult | undefined;
619
+ for (let attempt = 0; attempt < ROUTER_FAILURE_RETRY_LIMIT; attempt += 1) {
620
+ classifier = await classifyCookTriggerIntentWithAgent({
621
+ ctx,
622
+ projectName,
623
+ inputText: normalizeTriggerText(event.text),
624
+ recentEntries,
625
+ workflowContextLines: buildTriggerWorkflowContextLines(snapshot),
626
+ });
627
+ if (classifier.status === "classified" && classifier.classification) break;
628
+ const recovery = await promptCookTriggerRecovery(ctx, classifier, deps);
629
+ if (recovery === "retry_routing" && attempt + 1 < ROUTER_FAILURE_RETRY_LIMIT) {
630
+ deps.emitCommandText(ctx, "Retrying workflow-aware router once before deciding whether /cook should take over.", "info");
631
+ continue;
632
+ }
633
+ if (recovery === "send_as_normal_chat") {
634
+ await replayOriginalMessageToPrimaryAgent(pi, event);
635
+ deps.emitCommandText(ctx, "Replayed the original message once to the main chat path and bypassed router interception for that replay.", "info");
636
+ writeRoutingDecision(event, {
637
+ mode: configuredMode,
638
+ action: "handled",
639
+ reason: `${classifierFailureReason(classifier)}_send_as_normal_chat`,
640
+ }, {
641
+ ...routingExtrasForArtifact(adoptedArtifact),
642
+ recoveryAction: recovery,
643
+ errorMessage: classifier.errorMessage ?? null,
644
+ rawOutput: classifier.rawOutput ?? null,
645
+ replayedToPrimaryAgent: true,
646
+ replayBypassMarkerApplied: true,
647
+ });
648
+ return { action: "handled" };
649
+ }
650
+ deps.emitCommandText(
651
+ ctx,
652
+ "Cancelled router recovery without replaying the original message. If you want the completion workflow boundary, rerun /cook explicitly.",
653
+ "info",
654
+ );
281
655
  writeRoutingDecision(event, {
282
656
  mode: configuredMode,
283
657
  action: "handled",
284
- reason: classifierFailureReason(classifier),
658
+ reason: `${classifierFailureReason(classifier)}_cancelled`,
285
659
  }, {
660
+ ...routingExtrasForArtifact(adoptedArtifact),
661
+ recoveryAction: recovery,
286
662
  errorMessage: classifier.errorMessage ?? null,
287
663
  rawOutput: classifier.rawOutput ?? null,
664
+ replayedToPrimaryAgent: false,
665
+ replayBypassMarkerApplied: false,
666
+ });
667
+ return { action: "handled" };
668
+ }
669
+
670
+ if (!classifier || classifier.status !== "classified" || !classifier.classification) {
671
+ deps.emitCommandText(ctx, "Router recovery stopped without replaying the original message. If you still want the workflow boundary, rerun /cook explicitly.", "info");
672
+ writeRoutingDecision(event, {
673
+ mode: configuredMode,
674
+ action: "handled",
675
+ reason: classifier ? `${classifierFailureReason(classifier)}_retry_exhausted` : "classifier_error_retry_exhausted",
676
+ }, {
677
+ ...routingExtrasForArtifact(adoptedArtifact),
678
+ recoveryAction: "retry_routing",
679
+ errorMessage: classifier?.errorMessage ?? null,
680
+ rawOutput: classifier?.rawOutput ?? null,
681
+ replayedToPrimaryAgent: false,
682
+ replayBypassMarkerApplied: false,
288
683
  });
289
684
  return { action: "handled" };
290
685
  }
291
686
 
292
687
  const classification = classifier.classification;
293
- if (classification.intent === "normal_prompt") {
688
+ if (classification.decision === "normal_prompt") {
294
689
  writeRoutingDecision(event, {
295
690
  mode: configuredMode,
296
691
  action: "continue",
297
692
  reason: "classifier_normal_prompt",
298
693
  classification,
299
- });
694
+ }, routingExtrasForArtifact(adoptedArtifact));
300
695
  return { action: "continue" };
301
696
  }
302
- if (classification.intent === "unclear") {
697
+
698
+ const proposalHint = buildAdoptedArtifactHint(adoptedArtifact);
699
+ if (classification.decision === "unclear") {
700
+ const proposal = await deps.deriveCookContextProposal(ctx, projectName, proposalHint);
701
+ const clarification = await promptCookTriggerClarification(ctx, snapshot, proposal, adoptedArtifact, deps);
702
+ if (clarification === "send_as_normal_chat") {
703
+ await replayOriginalMessageToPrimaryAgent(pi, event);
704
+ deps.emitCommandText(ctx, "Replayed the original message once to the main chat path and bypassed router interception for that clarification replay.", "info");
705
+ writeRoutingDecision(event, {
706
+ mode: configuredMode,
707
+ action: "handled",
708
+ reason: "user_sent_as_normal_chat_after_clarification",
709
+ classification,
710
+ }, {
711
+ ...routingExtrasForArtifact(adoptedArtifact),
712
+ clarificationAction: clarification,
713
+ replayedToPrimaryAgent: true,
714
+ replayBypassMarkerApplied: true,
715
+ });
716
+ return { action: "handled" };
717
+ }
718
+ if (clarification === "cancel") {
719
+ deps.emitCommandText(
720
+ ctx,
721
+ "Cancelled commandless workflow clarification. If you want the workflow boundary, rerun /cook explicitly.",
722
+ "info",
723
+ );
724
+ writeRoutingDecision(event, {
725
+ mode: configuredMode,
726
+ action: "handled",
727
+ reason: triggerClarificationOverride() ? "user_cancelled_clarification" : ctx.hasUI ? "user_cancelled_clarification" : "clarification_unavailable",
728
+ classification,
729
+ }, {
730
+ ...routingExtrasForArtifact(adoptedArtifact),
731
+ clarificationAction: clarification,
732
+ replayedToPrimaryAgent: false,
733
+ replayBypassMarkerApplied: false,
734
+ });
735
+ return { action: "handled" };
736
+ }
737
+ const clarificationCapsule = buildClarificationCapsule(clarification, classification, proposal);
738
+ const selectedBias = clarificationBiasFromAction(clarification) ?? classification.workflowBias;
739
+ deps.emitCommandText(ctx, "Routing clarified natural-language handoff into /cook.", "info");
303
740
  writeRoutingDecision(event, {
304
741
  mode: configuredMode,
305
- action: "continue",
306
- reason: "classifier_unclear",
742
+ action: "routed_to_cook",
743
+ reason: "clarification_resolved",
307
744
  classification,
745
+ }, {
746
+ ...routingExtrasForArtifact(adoptedArtifact),
747
+ ...routingExtrasForClarification(clarificationCapsule),
748
+ clarificationAction: clarification,
308
749
  });
309
- return { action: "continue" };
750
+ await runCookEntry(pi, ctx, deps, {
751
+ origin: "natural-language-trigger",
752
+ hintText: buildHandoffHintText(classification, clarificationCapsule, adoptedArtifact),
753
+ originalInput: event.text,
754
+ triggerText: event.text,
755
+ preferredRoutingBias: selectedBias,
756
+ clarificationCapsule,
757
+ adoptedArtifact,
758
+ });
759
+ return { action: "handled" };
310
760
  }
311
761
 
312
762
  const confirmation = await promptCookTriggerTakeover(ctx, classification, deps);
313
- if (confirmation === "keep_chatting") {
763
+ if (confirmation === "send_as_normal_chat") {
764
+ await replayOriginalMessageToPrimaryAgent(pi, event);
765
+ deps.emitCommandText(ctx, "Replayed the original message once to the main chat path and bypassed router interception for that workflow-offer replay.", "info");
314
766
  writeRoutingDecision(event, {
315
767
  mode: configuredMode,
316
- action: "continue",
317
- reason: "user_declined_takeover",
768
+ action: "handled",
769
+ reason: "user_sent_as_normal_chat",
318
770
  classification,
771
+ }, {
772
+ ...routingExtrasForArtifact(adoptedArtifact),
773
+ confirmationAction: confirmation,
774
+ replayedToPrimaryAgent: true,
775
+ replayBypassMarkerApplied: true,
319
776
  });
320
- return { action: "continue" };
777
+ return { action: "handled" };
321
778
  }
322
779
  if (confirmation === "cancel") {
323
780
  deps.emitCommandText(
@@ -330,6 +787,11 @@ export async function handleCookNaturalLanguageTrigger(
330
787
  action: "handled",
331
788
  reason: ctx.hasUI ? "user_cancelled_takeover" : "assist_confirmation_unavailable",
332
789
  classification,
790
+ }, {
791
+ ...routingExtrasForArtifact(adoptedArtifact),
792
+ confirmationAction: confirmation,
793
+ replayedToPrimaryAgent: false,
794
+ replayBypassMarkerApplied: false,
333
795
  });
334
796
  return { action: "handled" };
335
797
  }
@@ -340,11 +802,17 @@ export async function handleCookNaturalLanguageTrigger(
340
802
  action: "routed_to_cook",
341
803
  reason: "accepted_takeover",
342
804
  classification,
805
+ }, {
806
+ ...routingExtrasForArtifact(adoptedArtifact),
807
+ confirmationAction: confirmation,
343
808
  });
344
809
  await runCookEntry(pi, ctx, deps, {
345
810
  origin: "natural-language-trigger",
346
- hintText: classification.focusHint,
811
+ hintText: buildHandoffHintText(classification, undefined, adoptedArtifact),
347
812
  originalInput: event.text,
813
+ triggerText: event.text,
814
+ preferredRoutingBias: classification.workflowBias,
815
+ adoptedArtifact,
348
816
  });
349
817
  return { action: "handled" };
350
818
  }