@linimin/pi-letscook 0.1.46 → 0.1.48

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/CHANGELOG.md CHANGED
@@ -2,10 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.48
6
+
7
+ ### Fixed
8
+
9
+ - stopped injecting active completion workflow routing into ordinary non-`/cook` main-chat turns after a repo had been activated earlier in the same Pi process, so stale canonical stop-wave state can no longer pull unrelated user requests into `completion-regrounder` unless a fresh `/cook` driver prompt is actually in flight
10
+
11
+ ## 0.1.47
12
+
5
13
  ### Changed
6
14
 
7
15
  - removed inline `/cook <text>` argument support so bare `/cook` is now the only supported workflow entrypoint
8
16
  - made runtime, deterministic regressions, README guidance, and packaged release parity fail closed when command arguments are passed instead of discussion driving proposal derivation
17
+ - made bare `/cook` weight the latest clear implementation intent ahead of older background discussion, preserve alternate recent missions for chooser-driven disambiguation, and summarize each candidate directly in the active-workflow chooser instead of forcing a single guessed replacement path
18
+ - made bare `/cook` suppress reopening already completed or already verified work by comparing recent discussion against canonical mission, active-slice, and verification-evidence context before startup/refocus proposal confirmation
9
19
 
10
20
  ## 0.1.44
11
21
 
package/README.md CHANGED
@@ -55,9 +55,9 @@ Bare `/cook` is the only supported workflow entrypoint.
55
55
 
56
56
  | Repo state | `/cook` behavior |
57
57
  |---|---|
58
- | No workflow yet | Summarizes recent main-chat discussion into a startup proposal, then asks for approval with **Start** or **Cancel**. If the discussion is weak, ambiguous, assistant-produced, or only a plan/spec/design-doc/proposal artifact instead of concrete repo changes, `/cook` fails closed without writing `.agent/**` state and tells you to clarify the mission in the main chat before rerunning bare `/cook`. |
59
- | Active workflow exists | Reads the current mission plus recent non-command main-chat discussion. Matching or unclear discussion resumes from canonical `.agent/**` state. Clear replacement discussion about different concrete repo changes opens a chooser first, then only rewrites canonical state after the follow-on **Start** confirmation. Assistant/summary artifacts or plan/spec/design-doc/proposal-only context do not refocus the workflow. `/cook <text>` is rejected without running proposal routing or rewriting workflow state. |
60
- | Previous workflow is `done` | Starts the next round from recent main-chat discussion, then asks for approval with **Start** or **Cancel**. Weak, ambiguous, assistant-produced, or planning-artifact-only discussion fails closed without rewriting canonical state and tells you to clarify the mission in the main chat before rerunning bare `/cook`. `/cook <text>` is rejected before any next-round proposal is derived. |
58
+ | No workflow yet | Summarizes recent main-chat discussion into a startup proposal, weighting the latest clear implementation intent ahead of older background discussion, then asks for approval with **Start** or **Cancel**. If the discussion is weak, ambiguous, assistant-produced, or only a plan/spec/design-doc/proposal artifact instead of concrete repo changes, `/cook` fails closed without writing `.agent/**` state and tells you to clarify the mission in the main chat before rerunning bare `/cook`. |
59
+ | Active workflow exists | Reads the current mission plus recent non-command main-chat discussion. Matching or unclear discussion resumes from canonical `.agent/**` state. Clear replacement discussion about different concrete repo changes opens a chooser first, then only rewrites canonical state after the follow-on **Start** confirmation. If recent discussion implies more than one plausible replacement mission, `/cook` keeps the current workflow parked behind a multi-candidate chooser instead of silently resuming or guessing. Assistant/summary artifacts or plan/spec/design-doc/proposal-only context do not refocus the workflow. `/cook <text>` is rejected without running proposal routing or rewriting workflow state. |
60
+ | Previous workflow is `done` | Starts the next round from recent main-chat discussion, then asks for approval with **Start** or **Cancel**. Weak, ambiguous, assistant-produced, or planning-artifact-only discussion fails closed without rewriting canonical state and tells you to clarify the mission in the main chat before rerunning bare `/cook`. Recent discussion that only restates already-completed or already-verified work also fails closed instead of reopening the finished mission. `/cook <text>` is rejected before any next-round proposal is derived. |
61
61
 
62
62
  ## Approval-only confirmation and fail-closed behavior
63
63
 
@@ -69,15 +69,16 @@ All startup, next-round, and replacement proposals are **approval-only**:
69
69
 
70
70
  When `/cook` cannot derive a clear startup, next-round, or replacement proposal for concrete repo changes from recent main-chat discussion, it fails closed instead of guessing. That means no canonical `.agent/**` state is created or rewritten until the discussion is clarified in the main chat and you rerun `/cook`. Tracked docs-only work such as README/CHANGELOG updates is still execution-ready, but assistant-produced summaries and plan/spec/design-doc/proposal-only artifacts are not enough to start or refocus a workflow on their own. `/cook <text>` also fails closed immediately and tells you to move that text into the main chat before rerunning bare `/cook`.
71
71
 
72
- When an active workflow already exists and recent discussion clearly suggests a different workflow, `/cook` shows a separate chooser first:
72
+ When an active workflow already exists and recent discussion suggests a different workflow, `/cook` shows a separate chooser first. The chooser can stay conservative or list multiple candidate replacements when the latest discussion contains more than one plausible implementation goal:
73
73
 
74
74
  - **Continue current workflow**
75
75
  - **Start new workflow from recent discussion**
76
+ - **Start alternate workflow from recent discussion** (when a second plausible mission exists)
76
77
  - **Cancel**
77
78
 
78
- Only the follow-on startup/replacement proposal uses the approval-only Start/Cancel gate, and canonical `.agent/**` state changes happen only after **Start** is accepted.
79
+ Chooser options summarize each candidate mission with its latest scope/constraint/acceptance highlights before the follow-on approval-only Start/Cancel gate. Canonical `.agent/**` state changes still happen only after **Start** is accepted.
79
80
 
80
- When you accept startup or refocus from that flow, `/cook` persists the chosen `task_type` and `evaluation_profile` across `.agent/profile.json`, `.agent/state.json`, `.agent/plan.json`, and `.agent/active-slice.json`, and records the accepted critique outcome in canonical continuation state before the re-ground round begins.
81
+ When you accept startup or refocus from that flow, `/cook` persists the chosen `task_type` and `evaluation_profile` across `.agent/profile.json`, `.agent/state.json`, `.agent/plan.json`, and `.agent/active-slice.json`, and records the accepted critique outcome plus any alternate-mission / suppression notes in canonical continuation state before the re-ground round begins.
81
82
 
82
83
  ## Observability
83
84
 
@@ -20,9 +20,12 @@ type ContextProposalAnalysis = {
20
20
  critique: string[];
21
21
  risks: string[];
22
22
  possibleNoise: string[];
23
+ alternateMissions: string[];
24
+ suppressedCompletedTopics: string[];
25
+ suppressedNegatedTopics: string[];
23
26
  };
24
27
 
25
- type ContextProposal = {
28
+ type ContextProposalAlternate = {
26
29
  mission: string;
27
30
  scope: string[];
28
31
  constraints: string[];
@@ -33,6 +36,10 @@ type ContextProposal = {
33
36
  source: "session" | "analyst";
34
37
  };
35
38
 
39
+ type ContextProposal = ContextProposalAlternate & {
40
+ alternateProposals: ContextProposalAlternate[];
41
+ };
42
+
36
43
  type ContextProposalDecision = {
37
44
  missionAnchor: string;
38
45
  goalText: string;
@@ -41,7 +48,7 @@ type ContextProposalDecision = {
41
48
 
42
49
  type ExistingWorkflowDecision =
43
50
  | { action: "continue"; currentMissionAnchor: string }
44
- | { action: "refocus"; currentMissionAnchor: string; missionAnchor: string };
51
+ | { action: "refocus"; currentMissionAnchor: string; missionAnchor: string; proposal: ContextProposal };
45
52
 
46
53
  type ActiveWorkflowProposalAssessment = {
47
54
  action: "continue" | "refocus" | "unclear";
@@ -55,6 +62,7 @@ type ExistingWorkflowChooserOptions = {
55
62
  comparison?: "strict" | "semantic";
56
63
  proposedMissionLabel?: string;
57
64
  refocusChoiceLabel?: string;
65
+ alternateChoiceLabel?: string;
58
66
  };
59
67
 
60
68
  type DriverContext = {
@@ -115,11 +123,13 @@ export type CompletionDriverDeps = {
115
123
  missionAnchorsLikelyEquivalent: (left: string, right: string) => boolean;
116
124
  missionAnchorsStrictlyEquivalent: (left: string, right: string) => boolean;
117
125
  shouldTreatBareActiveWorkflowProposalAsClearRefocus: (proposal: ContextProposal) => boolean;
126
+ activateCompletionRoutingForRoot: (root: string | undefined) => void;
118
127
  maybeWriteTestSnapshot: (targetPath: string | undefined, content: string) => void;
119
128
  completionTestDriverPromptPath: () => string | undefined;
120
129
  completionTestAutoContinuePromptPath: () => string | undefined;
121
130
  completionTestExistingWorkflowChooserSnapshotPath: () => string | undefined;
122
131
  completionTestWorkflowActionOverride: () => "continue" | "refocus" | "cancel" | undefined;
132
+ completionTestWorkflowMissionOverride: () => string | undefined;
123
133
  shouldSkipDriverKickoffForTests: () => boolean;
124
134
  shouldTestAutoContinueOnSessionStart: () => boolean;
125
135
  };
@@ -241,6 +251,14 @@ function rememberParkedDriverContinuation(rootKey: string, fingerprint: string):
241
251
  tracker.inFlight = false;
242
252
  }
243
253
 
254
+ function summarizeProposalForChoice(proposal: ContextProposalAlternate): string {
255
+ const parts: string[] = [`Mission\n${proposal.mission}`];
256
+ if (proposal.scope.length > 0) parts.push(`Scope\n- ${proposal.scope.slice(0, 2).join("\n- ")}`);
257
+ if (proposal.constraints.length > 0) parts.push(`Constraints\n- ${proposal.constraints.slice(0, 1).join("\n- ")}`);
258
+ if (proposal.acceptance.length > 0) parts.push(`Acceptance\n- ${proposal.acceptance.slice(0, 1).join("\n- ")}`);
259
+ return parts.join("\n\n");
260
+ }
261
+
244
262
  async function queueCompletionDriverPrompt(
245
263
  pi: ExtensionAPI,
246
264
  ctx: { cwd: string; hasUI: boolean; ui: any },
@@ -379,14 +397,17 @@ async function confirmExistingWorkflowProposal(
379
397
  ): Promise<ExistingWorkflowDecision | undefined> {
380
398
  const currentMission = currentMissionAnchor(snapshot);
381
399
  const comparison = options.comparison ?? "semantic";
382
- const missionsMatch =
400
+ const candidateProposals = [proposal, ...(proposal.alternateProposals ?? [])].filter((candidate, index, list) =>
401
+ list.findIndex((other) => deps.missionAnchorsStrictlyEquivalent(other.mission, candidate.mission)) === index,
402
+ );
403
+ const missionMatches = (candidate: ContextProposalAlternate): boolean =>
383
404
  comparison === "strict"
384
- ? deps.missionAnchorsStrictlyEquivalent(currentMission, proposal.mission)
385
- : deps.missionAnchorsLikelyEquivalent(currentMission, proposal.mission);
386
- if (missionsMatch) {
405
+ ? deps.missionAnchorsStrictlyEquivalent(currentMission, candidate.mission)
406
+ : deps.missionAnchorsLikelyEquivalent(currentMission, candidate.mission);
407
+ if (candidateProposals.some((candidate) => missionMatches(candidate))) {
387
408
  return { action: "continue", currentMissionAnchor: currentMission };
388
409
  }
389
- const title = [
410
+ const titleLines = [
390
411
  "Existing completion workflow found",
391
412
  "",
392
413
  options.intro ?? "A workflow is already in progress. Choose how /cook should proceed:",
@@ -394,24 +415,42 @@ async function confirmExistingWorkflowProposal(
394
415
  "Current mission",
395
416
  currentMission,
396
417
  "",
397
- options.proposedMissionLabel ?? "New proposed mission",
418
+ options.proposedMissionLabel ?? "Primary proposed mission",
398
419
  proposal.mission,
399
- ].join("\n");
420
+ ];
421
+ if (candidateProposals.length > 1) {
422
+ titleLines.push("", "Alternate recent missions", ...candidateProposals.slice(1).map((candidate) => candidate.mission));
423
+ }
424
+ const title = titleLines.join("\n");
400
425
  const continueChoice = "Continue current workflow\n\nKeep the current mission and treat the new goal as extra direction only.";
401
- const refocusChoice =
402
- options.refocusChoiceLabel ??
403
- "Abandon current workflow and start this new one\n\nReview the proposed replacement in a final Start/Cancel confirmation before /cook rewrites canonical workflow state.";
426
+ const buildRefocusChoice = (candidate: ContextProposalAlternate, variant: "primary" | "alternate") =>
427
+ variant === "primary"
428
+ ? `${options.refocusChoiceLabel ?? "Start new workflow from recent discussion\n\nReview the proposed replacement in a final Start/Cancel confirmation before /cook rewrites canonical workflow state."}\n\n${summarizeProposalForChoice(candidate)}`
429
+ : `${options.alternateChoiceLabel ?? "Start alternate workflow from recent discussion\n\nReview this alternate replacement in a final Start/Cancel confirmation before /cook rewrites canonical workflow state."}\n\n${summarizeProposalForChoice(candidate)}`;
430
+ const refocusChoices = candidateProposals.map((candidate, index) => buildRefocusChoice(candidate, index === 0 ? "primary" : "alternate"));
404
431
  const cancelChoice = `Cancel\n\nKeep the current workflow unchanged. ${deps.mainChatRerunGuidance}`;
405
432
  deps.maybeWriteTestSnapshot(
406
433
  deps.completionTestExistingWorkflowChooserSnapshotPath(),
407
- `${JSON.stringify({ title, choices: [continueChoice, refocusChoice, cancelChoice] }, null, 2)}\n`,
434
+ `${JSON.stringify({ title, candidateMissions: candidateProposals.map((candidate) => candidate.mission), choices: [continueChoice, ...refocusChoices, cancelChoice] }, null, 2)}\n`,
408
435
  );
436
+ const missionOverride = deps.completionTestWorkflowMissionOverride();
437
+ if (missionOverride) {
438
+ const matched = candidateProposals.find((candidate) => deps.missionAnchorsStrictlyEquivalent(candidate.mission, missionOverride));
439
+ if (matched) {
440
+ return {
441
+ action: "refocus",
442
+ currentMissionAnchor: currentMission,
443
+ missionAnchor: matched.mission,
444
+ proposal: { ...matched, alternateProposals: [] },
445
+ };
446
+ }
447
+ }
409
448
  const actionOverride = deps.completionTestWorkflowActionOverride();
410
449
  if (actionOverride === "continue") {
411
450
  return { action: "continue", currentMissionAnchor: currentMission };
412
451
  }
413
452
  if (actionOverride === "refocus") {
414
- return { action: "refocus", currentMissionAnchor: currentMission, missionAnchor: proposal.mission };
453
+ return { action: "refocus", currentMissionAnchor: currentMission, missionAnchor: proposal.mission, proposal };
415
454
  }
416
455
  if (actionOverride === "cancel") return undefined;
417
456
  if (!deps.getCtxHasUI(ctx)) {
@@ -421,10 +460,20 @@ async function confirmExistingWorkflowProposal(
421
460
  if (!ui) {
422
461
  return { action: "continue", currentMissionAnchor: currentMission };
423
462
  }
424
- const choice = await ui.select(title, [continueChoice, refocusChoice, cancelChoice]);
463
+ const choice = await ui.select(title, [continueChoice, ...refocusChoices, cancelChoice]);
425
464
  if (!choice || choice === cancelChoice) return undefined;
426
- if (choice === refocusChoice) {
427
- return { action: "refocus", currentMissionAnchor: currentMission, missionAnchor: proposal.mission };
465
+ if (choice === continueChoice) {
466
+ return { action: "continue", currentMissionAnchor: currentMission };
467
+ }
468
+ const matchedIndex = refocusChoices.indexOf(choice);
469
+ if (matchedIndex >= 0) {
470
+ const selected = candidateProposals[matchedIndex];
471
+ return {
472
+ action: "refocus",
473
+ currentMissionAnchor: currentMission,
474
+ missionAnchor: selected.mission,
475
+ proposal: matchedIndex === 0 ? proposal : { ...selected, alternateProposals: [] },
476
+ };
428
477
  }
429
478
  return { action: "continue", currentMissionAnchor: currentMission };
430
479
  }
@@ -534,6 +583,7 @@ export function registerCookCommand(pi: ExtensionAPI, deps: CompletionDriverDeps
534
583
  deps.emitCommandText(ctx, "Failed to load completion workflow state", "error");
535
584
  return;
536
585
  }
586
+ deps.activateCompletionRoutingForRoot(snapshot.files.root);
537
587
  if (!goal) {
538
588
  if (workflowDone) {
539
589
  const projectName = path.basename(snapshot.files.root);
@@ -557,15 +607,19 @@ export function registerCookCommand(pi: ExtensionAPI, deps: CompletionDriverDeps
557
607
  deps.emitCommandText(ctx, `Started a new completion workflow round from recent discussion: ${decision.missionAnchor}`, "info");
558
608
  } else {
559
609
  const assessment = await assessActiveWorkflowProposalRouting(ctx, snapshot, deps);
560
- if (assessment.action !== "refocus" || !assessment.proposal) {
610
+ if (!assessment.proposal || assessment.action === "continue") {
561
611
  await resumeActiveWorkflowFromCanonicalState(pi, ctx, snapshot, deps);
562
612
  return;
563
613
  }
564
614
  const decision = await confirmExistingWorkflowProposal(ctx, snapshot, assessment.proposal, deps, {
565
- intro: "Recent non-command discussion suggests a different workflow. Choose how /cook should proceed:",
615
+ intro:
616
+ assessment.action === "refocus"
617
+ ? "Recent non-command discussion suggests a different workflow. Choose how /cook should proceed:"
618
+ : "Recent discussion may point to a different implementation goal. Review the current mission and the latest inferred mission before deciding how /cook should proceed:",
566
619
  proposedMissionLabel: "Proposed mission from recent discussion",
567
620
  refocusChoiceLabel:
568
621
  "Start new workflow from recent discussion\n\nReview the proposed replacement in a final Start/Cancel confirmation before /cook rewrites canonical workflow state.",
622
+ comparison: assessment.action === "refocus" ? "semantic" : "strict",
569
623
  });
570
624
  if (!decision) {
571
625
  deps.emitCommandText(ctx, buildCookCancellationMessage("Cancelled existing workflow confirmation", deps), "info");
@@ -575,8 +629,12 @@ export function registerCookCommand(pi: ExtensionAPI, deps: CompletionDriverDeps
575
629
  await resumeActiveWorkflowFromCanonicalState(pi, ctx, snapshot, deps);
576
630
  return;
577
631
  }
578
- const proposalDecision = await deps.confirmContextProposal(ctx, assessment.proposal, {
579
- title: "Start the replacement workflow from recent discussion?",
632
+ const selectedProposal = decision.proposal;
633
+ const proposalDecision = await deps.confirmContextProposal(ctx, selectedProposal, {
634
+ title:
635
+ assessment.action === "refocus"
636
+ ? "Start the replacement workflow from recent discussion?"
637
+ : "Start the latest inferred workflow from recent discussion?",
580
638
  });
581
639
  if (!proposalDecision) {
582
640
  deps.emitCommandText(ctx, buildCookCancellationMessage("Cancelled replacement workflow proposal", deps), "info");
@@ -145,6 +145,10 @@ function completionTestWorkflowActionOverride(): "continue" | "refocus" | "cance
145
145
  return raw === "continue" || raw === "refocus" || raw === "cancel" ? raw : undefined;
146
146
  }
147
147
 
148
+ function completionTestWorkflowMissionOverride(): string | undefined {
149
+ return asString(process.env.PI_COMPLETION_EXISTING_WORKFLOW_MISSION);
150
+ }
151
+
148
152
  function shouldSkipDriverKickoffForTests(): boolean {
149
153
  return process.env.PI_COMPLETION_SKIP_DRIVER_KICKOFF === "1";
150
154
  }
@@ -238,8 +242,19 @@ function hasCompletionRoutingActivation(snapshot: CompletionStateSnapshot | unde
238
242
  return activatedCompletionRoutingRoots.has(path.resolve(snapshot.files.root));
239
243
  }
240
244
 
241
- function shouldInjectCompletionWorkflowContext(snapshot: CompletionStateSnapshot | undefined): boolean {
242
- return hasCompletionRoutingActivation(snapshot);
245
+ function latestUserOrCustomTurnText(ctx: { sessionManager?: any }): string | undefined {
246
+ return collectRecentDiscussionEntries(ctx as { sessionManager: any }, { isRecord, asString, isStaleContextError }, 1)[0]?.text;
247
+ }
248
+
249
+ function isCompletionDriverPromptTurn(ctx: { sessionManager?: any }): boolean {
250
+ const latest = latestUserOrCustomTurnText(ctx);
251
+ if (!latest) return false;
252
+ if (!/^\/skill:completion-protocol\b/.test(latest)) return false;
253
+ return /(?:Start or continue the completion workflow for this repo\.|Resume the completion workflow from canonical state\.)/.test(latest);
254
+ }
255
+
256
+ function shouldInjectCompletionWorkflowContext(snapshot: CompletionStateSnapshot | undefined, ctx: { sessionManager?: any }): boolean {
257
+ return hasCompletionRoutingActivation(snapshot) && isCompletionDriverPromptTurn(ctx);
243
258
  }
244
259
 
245
260
  function buildDoneWorkflowBoundaryReminder(snapshot: CompletionStateSnapshot): string {
@@ -271,6 +286,9 @@ function maybeWriteActiveWorkflowRoutingSnapshot(assessment: ActiveWorkflowPropo
271
286
  proposedMissionAnchor: assessment.proposal?.mission ?? null,
272
287
  proposalSource: assessment.proposal?.source ?? null,
273
288
  possibleNoise: assessment.proposal?.analysis.possibleNoise ?? [],
289
+ alternateMissions: assessment.proposal?.analysis.alternateMissions ?? [],
290
+ suppressedCompletedTopics: assessment.proposal?.analysis.suppressedCompletedTopics ?? [],
291
+ suppressedNegatedTopics: assessment.proposal?.analysis.suppressedNegatedTopics ?? [],
274
292
  scope: assessment.proposal?.scope ?? [],
275
293
  constraints: assessment.proposal?.constraints ?? [],
276
294
  acceptance: assessment.proposal?.acceptance ?? [],
@@ -361,14 +379,41 @@ async function deriveCookContextProposal(
361
379
  projectName: string,
362
380
  ): Promise<ContextProposal | undefined> {
363
381
  const recentEntries = collectRecentDiscussionEntries(ctx, { isRecord, asString, isStaleContextError });
382
+ const snapshot = await loadCompletionSnapshot(getCtxCwd(ctx));
383
+ const workflowContextLines = snapshot
384
+ ? [
385
+ `current mission anchor: ${asString(snapshot.state?.mission_anchor) ?? asString(snapshot.plan?.mission_anchor) ?? asString(snapshot.active?.mission_anchor) ?? "(none)"}`,
386
+ `continuation policy: ${asString(snapshot.state?.continuation_policy) ?? "(none)"}`,
387
+ `latest completed slice: ${asString(snapshot.state?.latest_completed_slice) ?? "(none)"}`,
388
+ `latest verified slice: ${asString(snapshot.state?.latest_verified_slice) ?? "(none)"}`,
389
+ `active slice goal: ${asString(snapshot.active?.goal) ?? "(none)"}`,
390
+ `active slice why_now: ${asString(snapshot.active?.why_now) ?? "(none)"}`,
391
+ `verification goal: ${asString(snapshot.verificationEvidence?.goal) ?? "(none)"}`,
392
+ `verification summary: ${asString(snapshot.verificationEvidence?.summary) ?? "(none)"}`,
393
+ ]
394
+ : [];
364
395
  return await deriveCookContextProposalFromRecentDiscussion(projectName, recentEntries, {
365
396
  asString,
366
397
  asStringArray,
398
+ workflowContext: snapshot
399
+ ? {
400
+ currentMissionAnchor:
401
+ asString(snapshot.state?.mission_anchor) ?? asString(snapshot.plan?.mission_anchor) ?? asString(snapshot.active?.mission_anchor),
402
+ latestCompletedSlice: asString(snapshot.state?.latest_completed_slice),
403
+ latestVerifiedSlice: asString(snapshot.state?.latest_verified_slice),
404
+ activeSliceGoal: asString(snapshot.active?.goal),
405
+ activeSliceWhyNow: asString(snapshot.active?.why_now),
406
+ verificationGoal: asString(snapshot.verificationEvidence?.goal),
407
+ verificationSummary: asString(snapshot.verificationEvidence?.summary),
408
+ continuationPolicy: asString(snapshot.state?.continuation_policy),
409
+ }
410
+ : undefined,
367
411
  analyzeContextProposal: async (entries) =>
368
412
  await analyzeContextProposalWithAgent({
369
413
  ctx,
370
414
  projectName,
371
415
  recentEntries: entries,
416
+ workflowContextLines,
372
417
  liveRoleActivityByRoot,
373
418
  completionStatusKey: COMPLETION_STATUS_KEY,
374
419
  safeUiCall,
@@ -881,6 +926,7 @@ export default function completionExtension(pi: ExtensionAPI) {
881
926
  completionTestDriverPromptPath,
882
927
  completionTestExistingWorkflowChooserSnapshotPath,
883
928
  completionTestWorkflowActionOverride,
929
+ completionTestWorkflowMissionOverride,
884
930
  confirmContextProposal,
885
931
  deriveCookContextProposal,
886
932
  emitCommandText,
@@ -890,6 +936,7 @@ export default function completionExtension(pi: ExtensionAPI) {
890
936
  getCtxUi,
891
937
  hasRunningCompletionRole,
892
938
  maybeWriteActiveWorkflowRoutingSnapshot,
939
+ activateCompletionRoutingForRoot,
893
940
  maybeWriteTestSnapshot,
894
941
  missionAnchorsLikelyEquivalent,
895
942
  missionAnchorsStrictlyEquivalent,
@@ -902,7 +949,10 @@ export default function completionExtension(pi: ExtensionAPI) {
902
949
  pi.on("session_start", async (_event, ctx) => {
903
950
  await refreshCompletionStatus({ ctx, ...statusSurfaceArgs });
904
951
  if (shouldTestAutoContinueOnSessionStart()) {
905
- await autoContinueWorkflowIfNeeded(pi, ctx, driverDeps);
952
+ const snapshot = await loadCompletionSnapshot(getCtxCwd(ctx));
953
+ if (hasCompletionRoutingActivation(snapshot) && isCompletionDriverPromptTurn(ctx)) {
954
+ await autoContinueWorkflowIfNeeded(pi, ctx, driverDeps);
955
+ }
906
956
  }
907
957
  });
908
958
 
@@ -916,17 +966,20 @@ export default function completionExtension(pi: ExtensionAPI) {
916
966
  await fsp.rm(snapshot.files.compactionMarkerPath, { force: true });
917
967
  }
918
968
  await refreshCompletionStatus({ ctx, ...statusSurfaceArgs });
919
- await autoContinueWorkflowIfNeeded(pi, ctx, driverDeps);
969
+ if (hasCompletionRoutingActivation(snapshot) && isCompletionDriverPromptTurn(ctx)) {
970
+ await autoContinueWorkflowIfNeeded(pi, ctx, driverDeps);
971
+ }
920
972
  });
921
973
 
922
974
  pi.on("before_agent_start", async (_event, ctx) => {
923
975
  const loaded = await loadCompletionDataForReminder(getCtxCwd(ctx));
924
- if (loaded) {
976
+ const driverPromptTurn = isCompletionDriverPromptTurn(ctx);
977
+ if (loaded && driverPromptTurn) {
925
978
  const rootKey = completionRootKey(loaded.snapshot, getCtxCwd(ctx));
926
979
  const fingerprint = completionContinuationFingerprint(loaded.snapshot);
927
980
  if (fingerprint) markQueuedDriverPromptInFlight(rootKey, fingerprint);
928
981
  }
929
- if (!loaded || !shouldInjectCompletionWorkflowContext(loaded.snapshot)) return;
982
+ if (!loaded || !shouldInjectCompletionWorkflowContext(loaded.snapshot, ctx)) return;
930
983
  const additions = isWorkflowDone(loaded.snapshot)
931
984
  ? [buildDoneWorkflowBoundaryReminder(loaded.snapshot)]
932
985
  : [composeSystemReminder(loaded.snapshot, loaded.sliceHistory, loaded.stopHistory)];
@@ -63,8 +63,23 @@ export function buildContextProposalCritiqueText(analysis: ContextProposalAnalys
63
63
  lines.push("Possible noise");
64
64
  for (const item of analysis.possibleNoise) lines.push(`- ${item}`);
65
65
  }
66
+ if (analysis.alternateMissions.length > 0) {
67
+ if (lines.length > 0) lines.push("");
68
+ lines.push("Alternate recent missions");
69
+ for (const item of analysis.alternateMissions) lines.push(`- ${item}`);
70
+ }
71
+ if (analysis.suppressedCompletedTopics.length > 0) {
72
+ if (lines.length > 0) lines.push("");
73
+ lines.push("Suppressed completed topics");
74
+ for (const item of analysis.suppressedCompletedTopics) lines.push(`- ${item}`);
75
+ }
76
+ if (analysis.suppressedNegatedTopics.length > 0) {
77
+ if (lines.length > 0) lines.push("");
78
+ lines.push("Suppressed negated topics");
79
+ for (const item of analysis.suppressedNegatedTopics) lines.push(`- ${item}`);
80
+ }
66
81
  if (lines.length === 0) {
67
- return "No critique, risk, or possible-noise notes were derived for this startup proposal.";
82
+ return "No critique, risk, noise, alternate-mission, or suppression notes were derived for this startup proposal.";
68
83
  }
69
84
  return lines.join("\n");
70
85
  }
@@ -101,6 +116,9 @@ export function buildContextProposalContinuationReason(
101
116
  analysis.critique.length > 0 ? `accepted critique=${deps.truncateInline(analysis.critique.join(" | "), 160)}` : "accepted critique=none",
102
117
  summarizeContextProposalAnalysisItems("risks", analysis.risks, deps.truncateInline),
103
118
  summarizeContextProposalAnalysisItems("possible_noise", analysis.possibleNoise, deps.truncateInline),
119
+ summarizeContextProposalAnalysisItems("alternate_missions", analysis.alternateMissions, deps.truncateInline),
120
+ summarizeContextProposalAnalysisItems("suppressed_completed", analysis.suppressedCompletedTopics, deps.truncateInline),
121
+ summarizeContextProposalAnalysisItems("suppressed_negated", analysis.suppressedNegatedTopics, deps.truncateInline),
104
122
  ].filter((part): part is string => Boolean(part));
105
123
  return `${prefix} ${deps.truncateInline(goalText, 220)} | startup routing: task_type=${analysis.taskType ?? deps.defaultTaskType}; evaluation_profile=${analysis.evaluationProfile ?? deps.defaultEvaluationProfile}; critique outcome=${critiqueParts.join("; ")}`;
106
124
  }
@@ -177,8 +195,14 @@ export function buildContextProposalConfirmationSelectItems(layout: ContextPropo
177
195
  }));
178
196
  }
179
197
 
180
- export function buildContextProposalAnalystPrompt(projectName: string, discussion: string): string {
181
- const lines = [`Project: ${projectName}`, "Infer the current mission from the discussion."];
198
+ export function buildContextProposalAnalystPrompt(projectName: string, discussion: string, contextLines: string[] = []): string {
199
+ const lines = [
200
+ `Project: ${projectName}`,
201
+ "Infer the current implementation mission from the discussion.",
202
+ "Prefer the latest clear user implementation intent over older background context.",
203
+ "Treat stale, completed, or explicitly negated topics as context to ignore unless the latest discussion clearly reopens them.",
204
+ ];
205
+ if (contextLines.length > 0) lines.push("", "Canonical workflow context:", ...contextLines);
182
206
  lines.push("", "Recent discussion:", discussion || "(none)");
183
207
  return lines.join("\n");
184
208
  }
@@ -14,9 +14,12 @@ export type ContextProposalAnalysis = {
14
14
  critique: string[];
15
15
  risks: string[];
16
16
  possibleNoise: string[];
17
+ alternateMissions: string[];
18
+ suppressedCompletedTopics: string[];
19
+ suppressedNegatedTopics: string[];
17
20
  };
18
21
 
19
- export type ContextProposal = {
22
+ export type ContextProposalAlternate = {
20
23
  mission: string;
21
24
  scope: string[];
22
25
  constraints: string[];
@@ -27,6 +30,10 @@ export type ContextProposal = {
27
30
  source: "session" | "analyst";
28
31
  };
29
32
 
33
+ export type ContextProposal = ContextProposalAlternate & {
34
+ alternateProposals: ContextProposalAlternate[];
35
+ };
36
+
30
37
  export type ContextProposalSection = "mission" | "scope" | "constraints" | "acceptance" | "critique" | "risks";
31
38
 
32
39
  export type RecentDiscussionEntry = {
@@ -67,6 +74,17 @@ export type ContextProposalConfirmOptions = {
67
74
  nonInteractiveBehavior?: "accept" | "cancel";
68
75
  };
69
76
 
77
+ export type ContextProposalWorkflowContext = {
78
+ currentMissionAnchor?: string;
79
+ latestCompletedSlice?: string;
80
+ latestVerifiedSlice?: string;
81
+ activeSliceGoal?: string;
82
+ activeSliceWhyNow?: string;
83
+ verificationGoal?: string;
84
+ verificationSummary?: string;
85
+ continuationPolicy?: string;
86
+ };
87
+
70
88
  type ProposalCommonDeps = {
71
89
  asString: (value: unknown) => string | undefined;
72
90
  asStringArray: (value: unknown) => string[];
@@ -324,6 +342,41 @@ export function serializeRecentDiscussionEntries(entries: RecentDiscussionEntry[
324
342
  .join("\n\n");
325
343
  }
326
344
 
345
+ const RECENT_DISCUSSION_IMPLEMENTATION_INTENT_REGEX =
346
+ /(?:\b(?:fix|update|add|remove|restore|refactor|ship|support|wire|route|rewrite|replace|preserve|filter|separate|refresh|reroute|suppress|align|convert|reconcile|repair|correct|implement|build|land|block|allow|keep|edit|document|write)\b|(?:修正|修復|修复|更新|新增|移除|恢復|恢复|重構|重构|調整|调整|過濾|过滤|分離|分离|刷新|替換|替换|抑制|對齊|对齐|實作|实现|落地|修補|修补|阻止|允許|允许|轉換|转换|保留|保持))/iu;
347
+
348
+ function hasRecentDiscussionImplementationIntent(text: string, stripCodeBlocksFn: (text: string) => string): boolean {
349
+ const cleaned = stripCodeBlocksFn(text).replace(/\r/g, " ").trim();
350
+ if (!cleaned) return false;
351
+ return hasStructuredContextProposalSignal(cleaned, stripCodeBlocksFn) || RECENT_DISCUSSION_IMPLEMENTATION_INTENT_REGEX.test(cleaned);
352
+ }
353
+
354
+ function recentDiscussionWindows(
355
+ recentEntries: RecentDiscussionEntry[],
356
+ stripCodeBlocksFn: (text: string) => string,
357
+ ): RecentDiscussionEntry[][] {
358
+ if (recentEntries.length === 0) return [];
359
+ const windows: RecentDiscussionEntry[][] = [];
360
+ const seen = new Set<string>();
361
+ const pushWindow = (entries: RecentDiscussionEntry[]) => {
362
+ if (entries.length === 0) return;
363
+ const key = entries.map((entry) => `${entry.role}:${entry.text}`).join("\n---\n");
364
+ if (seen.has(key)) return;
365
+ seen.add(key);
366
+ windows.push(entries);
367
+ };
368
+ const latestEntry = recentEntries[0];
369
+ if (hasRecentDiscussionImplementationIntent(latestEntry.text, stripCodeBlocksFn)) {
370
+ pushWindow([latestEntry]);
371
+ }
372
+ const recentIntentWindow = recentEntries.filter((entry, index) => index < 3);
373
+ if (recentIntentWindow.some((entry) => hasRecentDiscussionImplementationIntent(entry.text, stripCodeBlocksFn))) {
374
+ pushWindow(recentIntentWindow);
375
+ }
376
+ pushWindow(recentEntries);
377
+ return windows;
378
+ }
379
+
327
380
  export function extractJsonObjectFromText(text: string): string | undefined {
328
381
  const trimmed = text.trim();
329
382
  if (!trimmed) return undefined;
@@ -455,12 +508,26 @@ export function buildContextProposalAnalysis(args: {
455
508
  critique?: string[];
456
509
  risks?: string[];
457
510
  possibleNoise?: string[];
511
+ alternateMissions?: string[];
512
+ suppressedCompletedTopics?: string[];
513
+ suppressedNegatedTopics?: string[];
458
514
  hintTexts?: string[];
459
515
  }, deps: Pick<ProposalCommonDeps, "asString">): ContextProposalAnalysis {
460
516
  const critique = uniqueProposalItems(args.critique ?? []);
461
517
  const risks = uniqueProposalItems(args.risks ?? []);
462
518
  const possibleNoise = uniqueProposalItems(args.possibleNoise ?? []);
463
- const hintTexts = [...(args.hintTexts ?? []), ...critique, ...risks, ...possibleNoise];
519
+ const alternateMissions = uniqueProposalItems(args.alternateMissions ?? []);
520
+ const suppressedCompletedTopics = uniqueProposalItems(args.suppressedCompletedTopics ?? []);
521
+ const suppressedNegatedTopics = uniqueProposalItems(args.suppressedNegatedTopics ?? []);
522
+ const hintTexts = [
523
+ ...(args.hintTexts ?? []),
524
+ ...critique,
525
+ ...risks,
526
+ ...possibleNoise,
527
+ ...alternateMissions,
528
+ ...suppressedCompletedTopics,
529
+ ...suppressedNegatedTopics,
530
+ ];
464
531
  const taskType = normalizeContextProposalTaskTypeHint(args.taskType, deps.asString) ?? inferContextProposalTaskType(hintTexts);
465
532
  const evaluationProfile =
466
533
  normalizeContextProposalEvaluationProfileHint(args.evaluationProfile, deps.asString) ??
@@ -471,6 +538,9 @@ export function buildContextProposalAnalysis(args: {
471
538
  critique,
472
539
  risks,
473
540
  possibleNoise,
541
+ alternateMissions,
542
+ suppressedCompletedTopics,
543
+ suppressedNegatedTopics,
474
544
  };
475
545
  }
476
546
 
@@ -481,18 +551,33 @@ function mergeContextProposalAnalysis(
481
551
  const critique = uniqueProposalItems(sources.flatMap((source) => source?.critique ?? []));
482
552
  const risks = uniqueProposalItems(sources.flatMap((source) => source?.risks ?? []));
483
553
  const possibleNoise = uniqueProposalItems(sources.flatMap((source) => source?.possibleNoise ?? []));
554
+ const alternateMissions = uniqueProposalItems(sources.flatMap((source) => source?.alternateMissions ?? []));
555
+ const suppressedCompletedTopics = uniqueProposalItems(sources.flatMap((source) => source?.suppressedCompletedTopics ?? []));
556
+ const suppressedNegatedTopics = uniqueProposalItems(sources.flatMap((source) => source?.suppressedNegatedTopics ?? []));
557
+ const mergedHints = [
558
+ ...hintTexts,
559
+ ...critique,
560
+ ...risks,
561
+ ...possibleNoise,
562
+ ...alternateMissions,
563
+ ...suppressedCompletedTopics,
564
+ ...suppressedNegatedTopics,
565
+ ];
484
566
  const taskType =
485
567
  sources.map((source) => source?.taskType).find((value): value is string => Boolean(value)) ??
486
- inferContextProposalTaskType([...hintTexts, ...critique, ...risks, ...possibleNoise]);
568
+ inferContextProposalTaskType(mergedHints);
487
569
  const evaluationProfile =
488
570
  sources.map((source) => source?.evaluationProfile).find((value): value is string => Boolean(value)) ??
489
- inferContextProposalEvaluationProfile([...hintTexts, ...critique, ...risks, ...possibleNoise], taskType);
571
+ inferContextProposalEvaluationProfile(mergedHints, taskType);
490
572
  return {
491
573
  taskType,
492
574
  evaluationProfile,
493
575
  critique,
494
576
  risks,
495
577
  possibleNoise,
578
+ alternateMissions,
579
+ suppressedCompletedTopics,
580
+ suppressedNegatedTopics,
496
581
  };
497
582
  }
498
583
 
@@ -507,6 +592,9 @@ export function finalizeContextProposalAnalysis(
507
592
  critique: merged.critique,
508
593
  risks: merged.risks,
509
594
  possibleNoise: merged.possibleNoise,
595
+ alternateMissions: merged.alternateMissions,
596
+ suppressedCompletedTopics: merged.suppressedCompletedTopics,
597
+ suppressedNegatedTopics: merged.suppressedNegatedTopics,
510
598
  };
511
599
  }
512
600
 
@@ -610,7 +698,109 @@ function finalizeContextProposal(proposal: ContextProposal, projectName: string,
610
698
  };
611
699
  }
612
700
 
701
+ function proposalLikelyReopensCompletedWork(proposal: ContextProposal): boolean {
702
+ const corpus = [proposal.mission, proposal.basisPreview, ...proposal.scope, ...proposal.constraints, ...proposal.acceptance]
703
+ .map((text) => normalizeProposalLine(text).toLowerCase())
704
+ .filter(Boolean)
705
+ .join("\n");
706
+ return /(again|reopen|follow[- ]?up|next round|another round|rerun|revisit|再次|重新|下一輪|下一轮|延續|延续|回歸|回归)/iu.test(corpus);
707
+ }
708
+
709
+ function missionTextOverlapsTopic(mission: string, topic: string): boolean {
710
+ if (!mission || !topic) return false;
711
+ const missionTokens = missionAnchorSemanticTokens(mission);
712
+ const topicTokens = missionAnchorSemanticTokens(topic);
713
+ if (missionTokens.length === 0 || topicTokens.length === 0) return false;
714
+ const topicSet = new Set(topicTokens);
715
+ const overlap = missionTokens.filter((token) => topicSet.has(token));
716
+ return overlap.length >= Math.min(2, Math.min(missionTokens.length, topicTokens.length));
717
+ }
718
+
719
+ function proposalOverlapsTopic(proposal: ContextProposal, topic: string): boolean {
720
+ if (!topic.trim()) return false;
721
+ if (missionTextOverlapsTopic(proposal.mission, topic)) return true;
722
+ const bodyTexts = [proposal.basisPreview, ...proposal.scope, ...proposal.constraints, ...proposal.acceptance].filter(Boolean);
723
+ return bodyTexts.some((text) => missionTextOverlapsTopic(text, topic) || missionTextOverlapsTopic(topic, text));
724
+ }
725
+
726
+ function extractSuppressedNegatedTopics(proposal: ContextProposal): string[] {
727
+ return uniqueProposalItems(
728
+ proposal.constraints.filter((item) => looksLikeConstraint(item) && CONTEXT_PROPOSAL_IMPLEMENTATION_SOURCE_REGEX.test(normalizeProposalLine(item))),
729
+ );
730
+ }
731
+
732
+ function applyWorkflowContextToProposal(
733
+ proposal: ContextProposal | undefined,
734
+ context: ContextProposalWorkflowContext | undefined,
735
+ deps: ProposalCommonDeps,
736
+ ): ContextProposal | undefined {
737
+ if (!proposal) return proposal;
738
+ const possibleNoise = [...proposal.analysis.possibleNoise];
739
+ const alternateMissions = [...proposal.analysis.alternateMissions];
740
+ const suppressedCompletedTopics = [...proposal.analysis.suppressedCompletedTopics];
741
+ const suppressedNegatedTopics = [...proposal.analysis.suppressedNegatedTopics, ...extractSuppressedNegatedTopics(proposal)];
742
+ if (!context) {
743
+ return {
744
+ ...proposal,
745
+ analysis: finalizeContextProposalAnalysis(
746
+ {
747
+ ...proposal.analysis,
748
+ possibleNoise,
749
+ alternateMissions,
750
+ suppressedCompletedTopics,
751
+ suppressedNegatedTopics,
752
+ },
753
+ [proposal.goalText, proposal.mission],
754
+ ),
755
+ };
756
+ }
757
+ const completedTopics = [
758
+ context.latestCompletedSlice?.trim(),
759
+ context.latestVerifiedSlice?.trim(),
760
+ context.verificationGoal?.trim(),
761
+ context.verificationSummary?.trim(),
762
+ ].filter((value): value is string => Boolean(value));
763
+ for (const topic of completedTopics) {
764
+ if (proposalOverlapsTopic(proposal, topic) && !proposalLikelyReopensCompletedWork(proposal)) {
765
+ suppressedCompletedTopics.push(topic);
766
+ possibleNoise.push(`already completed: ${topic}`);
767
+ return undefined;
768
+ }
769
+ }
770
+ const activeTopics = [context.activeSliceGoal?.trim(), context.activeSliceWhyNow?.trim()].filter((value): value is string => Boolean(value));
771
+ for (const topic of activeTopics) {
772
+ if (proposalOverlapsTopic(proposal, topic) && proposal.analysis.alternateMissions.length === 0) {
773
+ possibleNoise.push(`overlaps canonical active slice: ${topic}`);
774
+ }
775
+ }
776
+ const currentMissionAnchor = context.currentMissionAnchor?.trim();
777
+ if (
778
+ context.continuationPolicy === "done" &&
779
+ currentMissionAnchor &&
780
+ deps.missionAnchorsStrictlyEquivalent(proposal.mission, currentMissionAnchor) &&
781
+ !proposalLikelyReopensCompletedWork(proposal)
782
+ ) {
783
+ suppressedCompletedTopics.push(currentMissionAnchor);
784
+ possibleNoise.push(`historical completed mission: ${currentMissionAnchor}`);
785
+ return undefined;
786
+ }
787
+ return {
788
+ ...proposal,
789
+ analysis: finalizeContextProposalAnalysis(
790
+ {
791
+ ...proposal.analysis,
792
+ possibleNoise,
793
+ alternateMissions,
794
+ suppressedCompletedTopics,
795
+ suppressedNegatedTopics,
796
+ },
797
+ [proposal.goalText, proposal.mission, ...completedTopics, ...activeTopics, currentMissionAnchor ?? ""],
798
+ ),
799
+ };
800
+ }
801
+
613
802
  export function shouldTreatBareActiveWorkflowProposalAsClearRefocus(proposal: ContextProposal): boolean {
803
+ if (proposal.analysis.alternateMissions.length > 0) return false;
614
804
  if (proposal.source === "session") {
615
805
  return proposal.scope.length > 0 && proposal.constraints.length > 0 && proposal.acceptance.length > 0;
616
806
  }
@@ -654,6 +844,7 @@ export function parseContextProposalAnalystOutput(
654
844
  const scope = uniqueProposalItems(deps.asStringArray(parsed.scope));
655
845
  const constraints = uniqueProposalItems(deps.asStringArray(parsed.constraints));
656
846
  const acceptance = uniqueProposalItems(deps.asStringArray(parsed.acceptance));
847
+ const alternateMissions = deps.asStringArray(parsed.alternate_missions ?? parsed.alternateMissions);
657
848
  const analysis = buildContextProposalAnalysis(
658
849
  {
659
850
  taskType: parsed.task_type ?? parsed.taskType,
@@ -661,6 +852,9 @@ export function parseContextProposalAnalystOutput(
661
852
  critique: deps.asStringArray(parsed.critique),
662
853
  risks: deps.asStringArray(parsed.risks ?? parsed.risk),
663
854
  possibleNoise: deps.asStringArray(parsed.possible_noise ?? parsed.possibleNoise),
855
+ alternateMissions,
856
+ suppressedCompletedTopics: deps.asStringArray(parsed.completed_topics ?? parsed.completedTopics),
857
+ suppressedNegatedTopics: deps.asStringArray(parsed.negated_topics ?? parsed.negatedTopics),
664
858
  hintTexts: [raw, mission, ...scope, ...constraints, ...acceptance],
665
859
  },
666
860
  deps,
@@ -676,6 +870,16 @@ export function parseContextProposalAnalystOutput(
676
870
  goalText,
677
871
  basisPreview: raw.replace(/\s+/g, " ").trim(),
678
872
  source: "analyst",
873
+ alternateProposals: alternateMissions.map((alternateMission) => ({
874
+ mission: alternateMission,
875
+ scope: [],
876
+ constraints: [],
877
+ acceptance: [],
878
+ analysis: finalizeContextProposalAnalysis(undefined, [alternateMission]),
879
+ goalText: buildContextProposalGoalText({ mission: alternateMission, scope: [], constraints: [], acceptance: [] }),
880
+ basisPreview: raw.replace(/\s+/g, " ").trim(),
881
+ source: "analyst",
882
+ })),
679
883
  },
680
884
  projectName,
681
885
  deps,
@@ -685,9 +889,10 @@ export function parseContextProposalAnalystOutput(
685
889
  export function buildContextProposalAnalystPromptFromEntries(
686
890
  projectName: string,
687
891
  recentEntries: RecentDiscussionEntry[],
892
+ contextLines: string[] = [],
688
893
  serializeEntries: (entries: RecentDiscussionEntry[]) => string = serializeRecentDiscussionEntries,
689
894
  ): string {
690
- return buildContextProposalAnalystPrompt(projectName, serializeEntries(recentEntries));
895
+ return buildContextProposalAnalystPrompt(projectName, serializeEntries(recentEntries), contextLines);
691
896
  }
692
897
 
693
898
  export function parseContextProposal(text: string, projectName: string, deps: ProposalParseDeps): ContextProposal | undefined {
@@ -836,6 +1041,7 @@ export function parseContextProposal(text: string, projectName: string, deps: Pr
836
1041
  goalText,
837
1042
  basisPreview,
838
1043
  source: "session",
1044
+ alternateProposals: [],
839
1045
  },
840
1046
  projectName,
841
1047
  deps,
@@ -850,7 +1056,26 @@ export function hasStructuredContextProposalSignal(text: string, stripCodeBlocks
850
1056
  );
851
1057
  }
852
1058
 
853
- export function parseStrictStructuredSessionProposal(text: string, projectName: string, deps: ProposalParseDeps): ContextProposal | undefined {
1059
+ function splitStructuredProposalBlocks(text: string): string[] {
1060
+ const lines = text.split("\n");
1061
+ const blocks: string[] = [];
1062
+ let startIndex = 0;
1063
+ for (let index = 0; index < lines.length; index += 1) {
1064
+ const rawLine = lines[index].trim();
1065
+ const inlineSection = matchInlineProposalSection(rawLine);
1066
+ const headerSection = inlineSection?.section ?? detectProposalSection(rawLine);
1067
+ if (index > 0 && headerSection === "mission") {
1068
+ const block = lines.slice(startIndex, index).join("\n").trim();
1069
+ if (block) blocks.push(block);
1070
+ startIndex = index;
1071
+ }
1072
+ }
1073
+ const tail = lines.slice(startIndex).join("\n").trim();
1074
+ if (tail) blocks.push(tail);
1075
+ return blocks;
1076
+ }
1077
+
1078
+ function parseStrictSingleStructuredSessionProposal(text: string, projectName: string, deps: ProposalParseDeps): ContextProposal | undefined {
854
1079
  const cleaned = deps.stripCodeBlocks(text).replace(/\r/g, "").trim();
855
1080
  if (!cleaned) return undefined;
856
1081
  const lines = cleaned
@@ -902,7 +1127,45 @@ export function parseStrictStructuredSessionProposal(text: string, projectName:
902
1127
  return undefined;
903
1128
  }
904
1129
  if (proposal.scope.length === 0 || proposal.constraints.length === 0 || proposal.acceptance.length === 0) return undefined;
905
- return { ...proposal, source: "session" };
1130
+ return { ...proposal, source: "session", alternateProposals: proposal.alternateProposals ?? [] };
1131
+ }
1132
+
1133
+ export function parseStrictStructuredSessionProposal(text: string, projectName: string, deps: ProposalParseDeps): ContextProposal | undefined {
1134
+ const cleaned = deps.stripCodeBlocks(text).replace(/\r/g, "").trim();
1135
+ if (!cleaned) return undefined;
1136
+ const blocks = splitStructuredProposalBlocks(cleaned);
1137
+ const proposals = blocks
1138
+ .map((block) => parseStrictSingleStructuredSessionProposal(block, projectName, deps))
1139
+ .filter((proposal): proposal is ContextProposal => Boolean(proposal));
1140
+ if (proposals.length === 0) return undefined;
1141
+ const primary = proposals[proposals.length - 1];
1142
+ const alternateProposals = proposals
1143
+ .slice(0, -1)
1144
+ .filter((proposal) => !deps.missionAnchorsStrictlyEquivalent(proposal.mission, primary.mission))
1145
+ .map((proposal) => ({
1146
+ mission: proposal.mission,
1147
+ scope: proposal.scope,
1148
+ constraints: proposal.constraints,
1149
+ acceptance: proposal.acceptance,
1150
+ analysis: proposal.analysis,
1151
+ goalText: proposal.goalText,
1152
+ basisPreview: proposal.basisPreview,
1153
+ source: proposal.source,
1154
+ }));
1155
+ const alternateMissions = uniqueProposalItems(alternateProposals.map((proposal) => proposal.mission));
1156
+ if (alternateMissions.length === 0) return { ...primary, alternateProposals: [] };
1157
+ return {
1158
+ ...primary,
1159
+ alternateProposals,
1160
+ analysis: finalizeContextProposalAnalysis(
1161
+ {
1162
+ ...primary.analysis,
1163
+ alternateMissions,
1164
+ possibleNoise: [...primary.analysis.possibleNoise, ...alternateMissions.map((mission) => `alternate recent mission: ${mission}`)],
1165
+ },
1166
+ [primary.goalText, primary.mission, ...alternateMissions],
1167
+ ),
1168
+ };
906
1169
  }
907
1170
 
908
1171
  export function extractContextProposalFromStructuredSession(
@@ -924,11 +1187,21 @@ export async function deriveCookContextProposalFromRecentDiscussion(
924
1187
  recentEntries: RecentDiscussionEntry[],
925
1188
  deps: ProposalParseDeps & {
926
1189
  analyzeContextProposal?: (recentEntries: RecentDiscussionEntry[]) => Promise<ContextProposal | undefined>;
1190
+ workflowContext?: ContextProposalWorkflowContext;
927
1191
  },
928
1192
  ): Promise<ContextProposal | undefined> {
929
1193
  if (recentEntries.length === 0) return undefined;
930
- return (await deps.analyzeContextProposal?.(recentEntries)) ??
931
- extractContextProposalFromStructuredSession(recentEntries, projectName, deps);
1194
+ for (const candidateEntries of recentDiscussionWindows(recentEntries, deps.stripCodeBlocks)) {
1195
+ const analyzed = applyWorkflowContextToProposal(await deps.analyzeContextProposal?.(candidateEntries), deps.workflowContext, deps);
1196
+ if (analyzed) return analyzed;
1197
+ const structured = applyWorkflowContextToProposal(
1198
+ extractContextProposalFromStructuredSession(candidateEntries, projectName, deps),
1199
+ deps.workflowContext,
1200
+ deps,
1201
+ );
1202
+ if (structured) return structured;
1203
+ }
1204
+ return undefined;
932
1205
  }
933
1206
 
934
1207
  export function resolveContextProposalConfirmationAction(
@@ -58,6 +58,7 @@ export type AnalyzeContextProposalWithAgentParams = {
58
58
  ctx: { cwd: string; hasUI: boolean; ui: any; model?: any };
59
59
  projectName: string;
60
60
  recentEntries: RecentDiscussionEntry[];
61
+ workflowContextLines?: string[];
61
62
  liveRoleActivityByRoot: Map<string, LiveRoleActivity>;
62
63
  completionStatusKey: string;
63
64
  safeUiCall: (action: () => void) => void;
@@ -75,14 +76,18 @@ const CONTEXT_PROPOSAL_ANALYST_SYSTEM_PROMPT = [
75
76
  "You analyze recent /cook startup discussion and return a strict JSON object.",
76
77
  "Do not emit markdown, code fences, or commentary.",
77
78
  "Return exactly one JSON object with keys: mission, scope, constraints, acceptance, critique, risks, task_type, evaluation_profile, confidence, possible_noise.",
79
+ "You may additionally include optional keys alternate_missions, completed_topics, and negated_topics when they are clearly supported by the discussion and canonical workflow context.",
78
80
  "mission must be a concise implementation mission anchor sentence.",
81
+ "Prefer the latest clear user implementation intent over older background context when they differ.",
82
+ "Do not reopen work that the canonical workflow context says is done, completed, historical, or already covered unless the latest discussion clearly asks to revisit it.",
83
+ "Treat stale, weakly related, or explicitly negated topics as noise instead of mission scope.",
79
84
  "scope must contain only work items that directly support the mission.",
80
85
  "constraints must contain guardrails or non-goals explicitly stated or strongly implied by the discussion.",
81
86
  "acceptance must contain verifiable outcomes explicitly stated or strongly implied by the discussion.",
82
87
  "critique must contain operator-facing cautions, concerns, or reminders that should be shown separately from mission and scope later.",
83
88
  "risks must contain concrete failure modes or regressions that the later workflow should keep in view.",
84
89
  "task_type and evaluation_profile should be candidate routing hints only; reuse the existing completion vocabulary when it clearly fits instead of inventing new schema names.",
85
- "possible_noise should list discussion points that look stale, weakly related, or unsafe to promote into scope.",
90
+ "possible_noise should list discussion points that look stale, weakly related, unsafe to promote into scope, or already completed elsewhere.",
86
91
  "When discussion is insufficient, prefer empty arrays and a low confidence value over invention.",
87
92
  ].join(" ");
88
93
  const STARTUP_ANALYST_ROLE = "cook-proposal-analyst";
@@ -167,7 +172,7 @@ async function runContextProposalAnalystSubprocess(params: AnalyzeContextProposa
167
172
  const cwd = params.getCtxCwd(ctx);
168
173
  const runCwd = findCompletionRoot(cwd) ?? findRepoRoot(cwd) ?? cwd;
169
174
  const rootKey = completionRootKey(undefined, cwd);
170
- const prompt = buildContextProposalAnalystPromptFromEntries(projectName, recentEntries);
175
+ const prompt = buildContextProposalAnalystPromptFromEntries(projectName, recentEntries, params.workflowContextLines);
171
176
  const systemPromptTemp = await writeTempFile(runCwd, "pi-cook-proposal-analyst-", CONTEXT_PROPOSAL_ANALYST_SYSTEM_PROMPT);
172
177
  const args: string[] = ["--mode", "json", "-p", "--no-session", "--append-system-prompt", systemPromptTemp.filePath, "--model", modelArg, prompt];
173
178
  const invocation = getPiInvocation(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linimin/pi-letscook",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "Pi package for long-running completion workflows with canonical .agent state, role-based subagents, continuity, and verification helpers.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -158,6 +158,85 @@ PY
158
158
 
159
159
  rm -rf .agent
160
160
 
161
+ # No workflow yet: when multiple structured discussions exist, bare /cook should prioritize the latest
162
+ # concrete implementation mission instead of failing closed on older structured context.
163
+ SESSION_ZERO_LATEST_WINDOW="$TMPDIR/session-zero-latest-window.jsonl"
164
+ DISCUSSION_ZERO_LATEST_OLDER=$'Mission: Remove the completion status line while keeping the completion widget.\nScope:\n- Keep the non-running completion widget.\nConstraints:\n- Do not reintroduce any other completion status surface.\nAcceptance:\n- Keep observability regression coverage truthful.'
165
+ DISCUSSION_ZERO_LATEST_NEWER=$'Mission: Fix login redirect callback behavior.\nScope:\n- Update the callback redirect decision logic.\nConstraints:\n- Do not refactor the broader auth flow.\nAcceptance:\n- Add a regression test for returning to the requested page.'
166
+ DISCUSSION_SNAPSHOT_ZERO_LATEST_WINDOW="$TMPDIR/context-proposal-latest-window.json"
167
+ python3 - "$SESSION_ZERO_LATEST_WINDOW" "$ROOT" "$DISCUSSION_ZERO_LATEST_OLDER" "$DISCUSSION_ZERO_LATEST_NEWER" <<'PY'
168
+ import json
169
+ import sys
170
+ from pathlib import Path
171
+
172
+ session_path = Path(sys.argv[1])
173
+ cwd = sys.argv[2]
174
+ older = sys.argv[3]
175
+ newer = sys.argv[4]
176
+ session_path.parent.mkdir(parents=True, exist_ok=True)
177
+ entries = [
178
+ {
179
+ "type": "session",
180
+ "version": 3,
181
+ "id": "11111111-1111-4111-8111-111111111111",
182
+ "timestamp": "2026-01-01T00:00:00.000Z",
183
+ "cwd": cwd,
184
+ },
185
+ {
186
+ "type": "message",
187
+ "id": "a1b2c3d4",
188
+ "parentId": None,
189
+ "timestamp": "2026-01-01T00:00:01.000Z",
190
+ "message": {
191
+ "role": "user",
192
+ "content": older,
193
+ "timestamp": 1767225601000,
194
+ },
195
+ },
196
+ {
197
+ "type": "message",
198
+ "id": "b2c3d4e5",
199
+ "parentId": "a1b2c3d4",
200
+ "timestamp": "2026-01-01T00:00:02.000Z",
201
+ "message": {
202
+ "role": "user",
203
+ "content": newer,
204
+ "timestamp": 1767225602000,
205
+ },
206
+ },
207
+ ]
208
+ with session_path.open('w', encoding='utf-8') as fh:
209
+ for entry in entries:
210
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
211
+ PY
212
+
213
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
214
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
215
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$DISCUSSION_SNAPSHOT_ZERO_LATEST_WINDOW" \
216
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
217
+ pi --session "$SESSION_ZERO_LATEST_WINDOW" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-context-proposal-latest-window.out" 2>"$TMPDIR/pi-completion-context-proposal-latest-window.err"
218
+
219
+ python3 - "$DISCUSSION_SNAPSHOT_ZERO_LATEST_WINDOW" <<'PY'
220
+ import json
221
+ import sys
222
+ from pathlib import Path
223
+
224
+ mission = 'Fix login redirect callback behavior.'
225
+ proposal = json.loads(Path(sys.argv[1]).read_text())
226
+ state = json.loads(Path('.agent/state.json').read_text())
227
+ plan = json.loads(Path('.agent/plan.json').read_text())
228
+ active = json.loads(Path('.agent/active-slice.json').read_text())
229
+
230
+ assert proposal['mission'] == mission, 'latest structured discussion should win over older structured context'
231
+ assert proposal['scope'] == ['Update the callback redirect decision logic.'], 'latest structured discussion should preserve the latest scope only'
232
+ assert proposal['analysis']['suppressedNegatedTopics'] == ['Do not refactor the broader auth flow.'], 'latest structured discussion should preserve negated implementation topics separately from the mission'
233
+ assert state['mission_anchor'] == mission, 'latest structured discussion should initialize state.json with the latest mission'
234
+ assert plan['mission_anchor'] == mission, 'latest structured discussion should initialize plan.json with the latest mission'
235
+ assert active['mission_anchor'] == mission, 'latest structured discussion should initialize active-slice.json with the latest mission'
236
+ PY
237
+
238
+ rm -rf .agent
239
+
161
240
  # No workflow yet: bare /cook should fail closed when a required structured section is missing and analyst output is unavailable.
162
241
  SESSION_ZERO_MISSING="$TMPDIR/session-zero-missing-section.jsonl"
163
242
  DISCUSSION_ZERO_MISSING=$'Mission: Remove the completion status line while keeping the completion widget.\nScope:\n- Keep the non-running completion widget.\n- Suppress the widget while a completion role is active.\nConstraints:\n- Do not reintroduce any other completion status surface.'
@@ -182,26 +261,35 @@ assert '/cook failed closed' in output, 'missing-section structured discussion s
182
261
  assert 'Mission/Scope/Constraints/Acceptance' in output, 'missing-section structured discussion should explain the strict fallback requirement'
183
262
  PY
184
263
 
185
- # No workflow yet: bare /cook should fail closed on ambiguous structured discussion when analyst output is unavailable.
264
+ # No workflow yet: when one structured discussion message contains multiple complete mission blocks,
265
+ # bare /cook should prioritize the latest block and preserve earlier blocks as alternate missions.
186
266
  SESSION_ZERO_AMBIG="$TMPDIR/session-zero-ambiguous.jsonl"
187
- DISCUSSION_ZERO_AMBIG=$'Mission: Remove the completion status line while keeping the completion widget.\nScope:\n- Keep the non-running completion widget.\nConstraints:\n- Do not reintroduce any other completion status surface.\nAcceptance:\n- Update README to match the shipped behavior.\nMission: Ship an unrelated widget overhaul.\nScope:\n- Replace the widget entirely.'
267
+ DISCUSSION_ZERO_AMBIG=$'Mission: Remove the completion status line while keeping the completion widget.\nScope:\n- Keep the non-running completion widget.\nConstraints:\n- Do not reintroduce any other completion status surface.\nAcceptance:\n- Update README to match the shipped behavior.\nMission: Ship an unrelated widget overhaul.\nScope:\n- Replace the widget entirely.\nConstraints:\n- Do not modify the completion widget.\nAcceptance:\n- Land the unrelated overhaul changes only.'
268
+ DISCUSSION_SNAPSHOT_ZERO_AMBIG="$TMPDIR/context-proposal-ambiguous-latest-block.json"
188
269
  write_session "$SESSION_ZERO_AMBIG" "$ROOT" "$DISCUSSION_ZERO_AMBIG"
189
270
 
190
271
  PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
191
272
  PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
273
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$DISCUSSION_SNAPSHOT_ZERO_AMBIG" \
192
274
  PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
193
275
  pi --session "$SESSION_ZERO_AMBIG" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-context-proposal-ambiguous.out" 2>"$TMPDIR/pi-completion-context-proposal-ambiguous.err"
194
276
 
195
- python3 - "$TMPDIR/pi-completion-context-proposal-ambiguous.out" "$TMPDIR/pi-completion-context-proposal-ambiguous.err" <<'PY'
277
+ python3 - "$DISCUSSION_SNAPSHOT_ZERO_AMBIG" <<'PY'
278
+ import json
196
279
  import sys
197
280
  from pathlib import Path
198
281
 
199
- output = Path(sys.argv[1]).read_text() + Path(sys.argv[2]).read_text()
200
- assert not Path('.agent').exists(), 'ambiguous structured discussion should fail closed without writing canonical state'
201
- assert '/cook failed closed' in output, 'ambiguous structured discussion should explain the fail-closed startup outcome'
202
- assert 'Mission/Scope/Constraints/Acceptance' in output, 'ambiguous structured discussion should explain the strict fallback requirement'
282
+ mission = 'Ship an unrelated widget overhaul.'
283
+ proposal = json.loads(Path(sys.argv[1]).read_text())
284
+ state = json.loads(Path('.agent/state.json').read_text())
285
+
286
+ assert proposal['mission'] == mission, 'latest complete mission block should win inside a single structured discussion message'
287
+ assert proposal['analysis']['alternateMissions'] == ['Remove the completion status line while keeping the completion widget.'], 'earlier complete mission blocks should be preserved as alternate missions'
288
+ assert state['mission_anchor'] == mission, 'latest complete mission block should initialize canonical mission state'
203
289
  PY
204
290
 
291
+ rm -rf .agent
292
+
205
293
  # No workflow yet: bare /cook structured fallback should normalize placeholder planning phrasing
206
294
  # into the concrete implementation mission when scope/acceptance clearly describe shipped work.
207
295
  SESSION_ZERO_NORMALIZED="$TMPDIR/session-zero-normalized.jsonl"
@@ -699,6 +787,100 @@ assert plan['mission_anchor'] == mission, 'summary-only active bare /cook should
699
787
  assert active['mission_anchor'] == mission, 'summary-only active bare /cook should keep active-slice.json unchanged'
700
788
  PY
701
789
 
790
+ # Active workflow: when recent discussion suggests a different implementation goal but the proposal is
791
+ # still incomplete, bare /cook should surface the chooser instead of silently resuming the current workflow.
792
+ SESSION_ONE_AMBIGUOUS_CHOOSER="$TMPDIR/session-one-ambiguous-chooser.jsonl"
793
+ DISCUSSION_ONE_AMBIGUOUS_CHOOSER=$'Mission: Fix login redirect callback behavior.\nScope:\n- Update the callback redirect decision logic for the current auth flow.'
794
+ AMBIGUOUS_ROUTING_ONE="$TMPDIR/active-ambiguous-routing.json"
795
+ AMBIGUOUS_CHOOSER_ONE="$TMPDIR/active-ambiguous-chooser.json"
796
+ AMBIGUOUS_RESUME_PROMPT_ONE="$TMPDIR/unexpected-active-ambiguous-resume.txt"
797
+ AMBIGUOUS_PROPOSAL_ONE="$TMPDIR/unexpected-active-ambiguous-proposal.json"
798
+ write_session "$SESSION_ONE_AMBIGUOUS_CHOOSER" "$ROOT" "$DISCUSSION_ONE_AMBIGUOUS_CHOOSER"
799
+
800
+ PI_COMPLETION_EXISTING_WORKFLOW_ACTION=cancel \
801
+ PI_COMPLETION_CONTEXT_PROPOSAL_ANALYST_OUTPUT='{"mission":"Fix login redirect callback behavior.","scope":["Update the callback redirect decision logic for the current auth flow."],"constraints":[],"acceptance":[],"task_type":"completion-workflow","evaluation_profile":"completion-rubric-v1","confidence":0.72,"possible_noise":["older completion widget cleanup"]}' \
802
+ PI_COMPLETION_TEST_ACTIVE_WORKFLOW_ROUTING_PATH="$AMBIGUOUS_ROUTING_ONE" \
803
+ PI_COMPLETION_TEST_EXISTING_WORKFLOW_CHOOSER_PATH="$AMBIGUOUS_CHOOSER_ONE" \
804
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$AMBIGUOUS_RESUME_PROMPT_ONE" \
805
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$AMBIGUOUS_PROPOSAL_ONE" \
806
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
807
+ pi --session "$SESSION_ONE_AMBIGUOUS_CHOOSER" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-context-proposal-active-ambiguous-chooser.out" 2>"$TMPDIR/pi-completion-context-proposal-active-ambiguous-chooser.err"
808
+
809
+ python3 - "$AMBIGUOUS_ROUTING_ONE" "$AMBIGUOUS_CHOOSER_ONE" "$AMBIGUOUS_RESUME_PROMPT_ONE" "$AMBIGUOUS_PROPOSAL_ONE" "$TMPDIR/pi-completion-context-proposal-active-ambiguous-chooser.out" "$TMPDIR/pi-completion-context-proposal-active-ambiguous-chooser.err" <<'PY'
810
+ import json
811
+ import sys
812
+ from pathlib import Path
813
+
814
+ mission = 'Remove the completion status line while keeping the completion widget.'
815
+ routing = json.loads(Path(sys.argv[1]).read_text())
816
+ chooser = json.loads(Path(sys.argv[2]).read_text())
817
+ resume_path = Path(sys.argv[3])
818
+ proposal_path = Path(sys.argv[4])
819
+ output = Path(sys.argv[5]).read_text() + Path(sys.argv[6]).read_text()
820
+ state = json.loads(Path('.agent/state.json').read_text())
821
+ plan = json.loads(Path('.agent/plan.json').read_text())
822
+ active = json.loads(Path('.agent/active-slice.json').read_text())
823
+
824
+ assert routing['mode'] == 'bare', 'ambiguous active bare /cook should snapshot bare routing mode'
825
+ assert routing['action'] == 'unclear', 'incomplete replacement discussion should stay ambiguous until the chooser resolves it'
826
+ assert routing['reason'] == 'ambiguous_discussion', 'incomplete replacement discussion should record the ambiguous-discussion reason'
827
+ assert routing['currentMissionAnchor'] == mission, 'ambiguous chooser routing should preserve the current mission anchor'
828
+ assert routing['proposedMissionAnchor'] == 'Fix login redirect callback behavior.', 'ambiguous chooser routing should expose the latest inferred mission'
829
+ assert chooser['title'].startswith('Existing completion workflow found'), 'ambiguous active bare /cook should still open the existing-workflow chooser'
830
+ assert chooser['choices'][0].startswith('Continue current workflow'), 'ambiguous chooser should keep the continue option'
831
+ assert chooser['choices'][1].startswith('Start new workflow from recent discussion'), 'ambiguous chooser should offer the recent-discussion replacement option'
832
+ assert not resume_path.exists(), 'ambiguous active bare /cook should not silently queue a resume prompt before the chooser resolves it'
833
+ assert not proposal_path.exists(), 'ambiguous chooser cancel should not open the final proposal confirmation'
834
+ assert 'Discuss changes in the main chat and rerun /cook.' in output, 'ambiguous chooser cancel should redirect users back to the main chat and rerun /cook'
835
+ assert state['mission_anchor'] == mission, 'ambiguous chooser cancel should keep state.json unchanged'
836
+ assert plan['mission_anchor'] == mission, 'ambiguous chooser cancel should keep plan.json unchanged'
837
+ assert active['mission_anchor'] == mission, 'ambiguous chooser cancel should keep active-slice.json unchanged'
838
+ PY
839
+
840
+ # Active workflow: when recent discussion contains multiple complete replacement missions, bare /cook should
841
+ # surface all candidates and allow the chooser to select a non-primary alternate mission.
842
+ SESSION_ONE_MULTI_CANDIDATE="$TMPDIR/session-one-multi-candidate.jsonl"
843
+ DISCUSSION_ONE_MULTI_CANDIDATE=$'Mission: Fix login redirect callback behavior.\nScope:\n- Update the callback redirect decision logic for the current auth flow.\nConstraints:\n- Do not refactor the broader auth flow.\nAcceptance:\n- Add a regression test for returning to the requested page.\nMission: Add logout redirect regression coverage.\nScope:\n- Add coverage for logout redirect behavior.\nConstraints:\n- Do not change login redirect behavior in this pass.\nAcceptance:\n- Land a dedicated logout redirect regression test.'
844
+ MULTI_ROUTING_ONE="$TMPDIR/active-multi-routing.json"
845
+ MULTI_CHOOSER_ONE="$TMPDIR/active-multi-chooser.json"
846
+ MULTI_PROPOSAL_ONE="$TMPDIR/active-multi-proposal.json"
847
+ write_session "$SESSION_ONE_MULTI_CANDIDATE" "$ROOT" "$DISCUSSION_ONE_MULTI_CANDIDATE"
848
+
849
+ PI_COMPLETION_EXISTING_WORKFLOW_MISSION='Fix login redirect callback behavior.' \
850
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
851
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
852
+ PI_COMPLETION_TEST_ACTIVE_WORKFLOW_ROUTING_PATH="$MULTI_ROUTING_ONE" \
853
+ PI_COMPLETION_TEST_EXISTING_WORKFLOW_CHOOSER_PATH="$MULTI_CHOOSER_ONE" \
854
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$MULTI_PROPOSAL_ONE" \
855
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
856
+ pi --session "$SESSION_ONE_MULTI_CANDIDATE" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-context-proposal-active-multi.out" 2>"$TMPDIR/pi-completion-context-proposal-active-multi.err"
857
+
858
+ python3 - "$MULTI_ROUTING_ONE" "$MULTI_CHOOSER_ONE" "$MULTI_PROPOSAL_ONE" <<'PY'
859
+ import json
860
+ import sys
861
+ from pathlib import Path
862
+
863
+ selected = 'Fix login redirect callback behavior.'
864
+ routing = json.loads(Path(sys.argv[1]).read_text())
865
+ chooser = json.loads(Path(sys.argv[2]).read_text())
866
+ proposal = json.loads(Path(sys.argv[3]).read_text())
867
+ state = json.loads(Path('.agent/state.json').read_text())
868
+ plan = json.loads(Path('.agent/plan.json').read_text())
869
+ active = json.loads(Path('.agent/active-slice.json').read_text())
870
+
871
+ assert routing['action'] == 'unclear', 'multi-candidate replacement discussion should stay ambiguous until the chooser selects one mission'
872
+ assert routing['reason'] == 'ambiguous_discussion', 'multi-candidate replacement discussion should record ambiguous-discussion routing'
873
+ assert routing['alternateMissions'] == ['Fix login redirect callback behavior.'], 'routing snapshot should preserve alternate candidate missions'
874
+ assert chooser['candidateMissions'] == ['Add logout redirect regression coverage.', 'Fix login redirect callback behavior.'], 'chooser snapshot should list the primary and alternate candidate missions'
875
+ assert len(chooser['choices']) == 4, 'chooser should expose continue, primary, alternate, and cancel choices'
876
+ assert 'Scope\n- Add coverage for logout redirect behavior.' in chooser['choices'][1], 'primary chooser option should summarize the candidate scope'
877
+ assert 'Acceptance\n- Add a regression test for returning to the requested page.' in chooser['choices'][2], 'alternate chooser option should summarize the candidate acceptance'
878
+ assert proposal['mission'] == selected, 'selected alternate mission should flow into the final proposal confirmation'
879
+ assert state['mission_anchor'] == selected, 'selected alternate mission should rewrite state.json after approval'
880
+ assert plan['mission_anchor'] == selected, 'selected alternate mission should rewrite plan.json after approval'
881
+ assert active['mission_anchor'] == selected, 'selected alternate mission should rewrite active-slice.json after approval'
882
+ PY
883
+
702
884
  # Active workflow: bare /cook with a placeholder planning mission should still route through the existing
703
885
  # refocus chooser and final Start/Cancel gate before canonical state is rewritten.
704
886
  SESSION_ONE_REFOCUS_NORMALIZED="$TMPDIR/session-one-refocus-normalized.jsonl"
@@ -747,10 +929,93 @@ assert plan['mission_anchor'] == mission, 'active bare /cook refocus should rewr
747
929
  assert active['mission_anchor'] == mission, 'active bare /cook refocus should rewrite active-slice.json only after approval'
748
930
  PY
749
931
 
750
- # Completed workflow: bare /cook should normalize placeholder planning phrasing for the next workflow
751
- # round too, not only for fresh startup.
932
+ # Completed workflow: bare /cook should suppress proposals that simply restate the completed mission
933
+ # without a clear reopen or next-round signal.
752
934
  mark_done
753
935
 
936
+ SESSION_TWO_COMPLETED_SUPPRESS="$TMPDIR/session-two-completed-suppress.jsonl"
937
+ CURRENT_DONE_MISSION="$(python3 - <<'PY'
938
+ import json
939
+ from pathlib import Path
940
+ print(json.loads(Path('.agent/state.json').read_text())['mission_anchor'])
941
+ PY
942
+ )"
943
+ DISCUSSION_TWO_COMPLETED_SUPPRESS="Mission: ${CURRENT_DONE_MISSION}
944
+ Scope:
945
+ - Keep the current completed mission exactly as-is.
946
+ Constraints:
947
+ - Do not start a different workflow from this discussion.
948
+ Acceptance:
949
+ - Keep the finished mission closed and unchanged."
950
+ DISCUSSION_SNAPSHOT_TWO_COMPLETED_SUPPRESS="$TMPDIR/context-proposal-next-round-completed-suppress.json"
951
+ write_session "$SESSION_TWO_COMPLETED_SUPPRESS" "$ROOT" "$DISCUSSION_TWO_COMPLETED_SUPPRESS"
952
+
953
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
954
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
955
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$DISCUSSION_SNAPSHOT_TWO_COMPLETED_SUPPRESS" \
956
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
957
+ pi --session "$SESSION_TWO_COMPLETED_SUPPRESS" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-context-proposal-next-round-completed-suppress.out" 2>"$TMPDIR/pi-completion-context-proposal-next-round-completed-suppress.err"
958
+
959
+ python3 - "$TMPDIR/pi-completion-context-proposal-next-round-completed-suppress.out" "$TMPDIR/pi-completion-context-proposal-next-round-completed-suppress.err" "$DISCUSSION_SNAPSHOT_TWO_COMPLETED_SUPPRESS" "$CURRENT_DONE_MISSION" <<'PY'
960
+ import json
961
+ import sys
962
+ from pathlib import Path
963
+
964
+ output = Path(sys.argv[1]).read_text() + Path(sys.argv[2]).read_text()
965
+ snapshot = Path(sys.argv[3])
966
+ expected = sys.argv[4]
967
+ state = json.loads(Path('.agent/state.json').read_text())
968
+
969
+ assert state['mission_anchor'] == expected, 'completed-topic suppression should keep the done workflow mission anchor unchanged'
970
+ assert state['continuation_policy'] == 'done', 'completed-topic suppression should keep the workflow closed'
971
+ assert not snapshot.exists(), 'completed-topic suppression should not emit a proposal snapshot when the latest discussion only repeats finished work'
972
+ assert '/cook failed closed' in output, 'completed-topic suppression should fail closed instead of reopening the finished mission'
973
+ PY
974
+
975
+ # Completed workflow: bare /cook should also suppress proposals that merely restate canonical
976
+ # verification evidence for already verified work.
977
+ python3 - <<'PY'
978
+ import json
979
+ from pathlib import Path
980
+
981
+ state = json.loads(Path('.agent/state.json').read_text())
982
+ state['latest_verified_slice'] = 'verified-logout-redirect'
983
+ Path('.agent/state.json').write_text(json.dumps(state, indent=2) + '\n')
984
+
985
+ evidence = json.loads(Path('.agent/verification-evidence.json').read_text())
986
+ evidence.update({
987
+ 'subject_type': 'selected_slice',
988
+ 'slice_id': 'verified-logout-redirect',
989
+ 'goal': 'Add logout redirect regression coverage.',
990
+ 'summary': 'Verified logout redirect regression coverage already matches the selected slice and current HEAD.',
991
+ 'outcome': 'pass',
992
+ })
993
+ Path('.agent/verification-evidence.json').write_text(json.dumps(evidence, indent=2) + '\n')
994
+ PY
995
+
996
+ SESSION_TWO_VERIFIED_SUPPRESS="$TMPDIR/session-two-verified-suppress.jsonl"
997
+ DISCUSSION_TWO_VERIFIED_SUPPRESS=$'Mission: Add logout redirect regression coverage.\nScope:\n- Add coverage for logout redirect behavior.\nConstraints:\n- Do not change the verified logout redirect work.\nAcceptance:\n- Keep the verified logout redirect regression coverage unchanged.'
998
+ DISCUSSION_SNAPSHOT_TWO_VERIFIED_SUPPRESS="$TMPDIR/context-proposal-next-round-verified-suppress.json"
999
+ write_session "$SESSION_TWO_VERIFIED_SUPPRESS" "$ROOT" "$DISCUSSION_TWO_VERIFIED_SUPPRESS"
1000
+
1001
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
1002
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
1003
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$DISCUSSION_SNAPSHOT_TWO_VERIFIED_SUPPRESS" \
1004
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
1005
+ pi --session "$SESSION_TWO_VERIFIED_SUPPRESS" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-context-proposal-next-round-verified-suppress.out" 2>"$TMPDIR/pi-completion-context-proposal-next-round-verified-suppress.err"
1006
+
1007
+ python3 - "$TMPDIR/pi-completion-context-proposal-next-round-verified-suppress.out" "$TMPDIR/pi-completion-context-proposal-next-round-verified-suppress.err" "$DISCUSSION_SNAPSHOT_TWO_VERIFIED_SUPPRESS" <<'PY'
1008
+ import sys
1009
+ from pathlib import Path
1010
+
1011
+ output = Path(sys.argv[1]).read_text() + Path(sys.argv[2]).read_text()
1012
+ snapshot = Path(sys.argv[3])
1013
+ assert not snapshot.exists(), 'verification-evidence overlap suppression should not emit a proposal snapshot for already verified work'
1014
+ assert '/cook failed closed' in output, 'verification-evidence overlap suppression should fail closed when the latest discussion only repeats verified work'
1015
+ PY
1016
+
1017
+ # Completed workflow: bare /cook should normalize placeholder planning phrasing for the next workflow
1018
+ # round too, not only for fresh startup.
754
1019
  SESSION_TWO_NORMALIZED="$TMPDIR/session-two-normalized.jsonl"
755
1020
  DISCUSSION_TWO_NORMALIZED=$'Mission: 開始實作這個方案\nScope:\n- Normalize bare /cook planning phrasing for the next workflow round.\n- Reset canonical state for the new implementation mission.\nConstraints:\n- Do not resume the completed workflow when the new round is clearly different.\nAcceptance:\n- Start a new round with the normalized mission anchor.'
756
1021
  DISCUSSION_SNAPSHOT_TWO_NORMALIZED="$TMPDIR/context-proposal-next-round-normalized.json"