@linimin/pi-letscook 0.1.51 → 0.1.53

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