@linimin/pi-letscook 0.1.27 → 0.1.29

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.
@@ -5,8 +5,8 @@ import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { StringEnum } from "@mariozechner/pi-ai";
7
7
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
- import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
9
- import { Text } from "@mariozechner/pi-tui";
8
+ import { DynamicBorder, parseFrontmatter } from "@mariozechner/pi-coding-agent";
9
+ import { Container, matchesKey, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
10
10
  import { Type } from "typebox";
11
11
 
12
12
  const PROTOCOL_ID = "completion";
@@ -115,7 +115,12 @@ type ContextProposal = {
115
115
  acceptance: string[];
116
116
  goalText: string;
117
117
  basisPreview: string;
118
- source: "session";
118
+ source: "session" | "analyst";
119
+ };
120
+
121
+ type RecentDiscussionEntry = {
122
+ role: "user" | "assistant" | "custom" | "summary";
123
+ text: string;
119
124
  };
120
125
 
121
126
  type ContextProposalDecision = {
@@ -123,12 +128,77 @@ type ContextProposalDecision = {
123
128
  goalText: string;
124
129
  };
125
130
 
131
+ type ContextProposalConfirmAction = "start" | "edit" | "cancel";
132
+
133
+ type ContextProposalConfirmationActionItem = {
134
+ id: ContextProposalConfirmAction;
135
+ label: string;
136
+ description: string;
137
+ };
138
+
139
+ type ContextProposalConfirmationLayout = {
140
+ title: string;
141
+ intro: string;
142
+ proposalHeading: string;
143
+ proposalBody: string;
144
+ actionsHeading: string;
145
+ actions: ContextProposalConfirmationActionItem[];
146
+ footer: string;
147
+ };
148
+
126
149
  type ContextProposalConfirmOptions = {
127
150
  title: string;
128
151
  nonInteractiveBehavior?: "accept" | "cancel";
129
152
  editorPrompt?: string;
130
153
  };
131
154
 
155
+ class StartupAnalystOverlay extends Container {
156
+ private readonly border: DynamicBorder;
157
+ private readonly title: Text;
158
+ private readonly body: Text;
159
+ private readonly footer: Text;
160
+ private lines: string[] = [];
161
+ onAbort?: () => void;
162
+
163
+ constructor(private readonly theme: any) {
164
+ super();
165
+ this.border = new DynamicBorder((s: string) => this.theme.fg("accent", s));
166
+ this.title = new Text("", 1, 0);
167
+ this.body = new Text("", 1, 1);
168
+ this.footer = new Text("", 1, 0);
169
+ this.addChild(this.border);
170
+ this.addChild(this.title);
171
+ this.addChild(this.body);
172
+ this.addChild(this.footer);
173
+ this.updateDisplay();
174
+ }
175
+
176
+ setLines(lines: string[]): void {
177
+ this.lines = [...lines];
178
+ this.updateDisplay();
179
+ this.invalidate();
180
+ }
181
+
182
+ private updateDisplay(): void {
183
+ this.title.setText(this.theme.fg("accent", this.theme.bold("/cook proposal analyst")));
184
+ this.body.setText(this.theme.fg("dim", this.lines.join("\n")));
185
+ this.footer.setText(this.theme.fg("muted", "Esc cancel • This analysis runs before /cook writes canonical workflow state"));
186
+ }
187
+
188
+ override handleInput(data: string): void {
189
+ if (data === "\u001b") {
190
+ this.onAbort?.();
191
+ return;
192
+ }
193
+ // Container does not implement handleInput; ignore all other keys.
194
+ }
195
+
196
+ override invalidate(): void {
197
+ super.invalidate();
198
+ this.updateDisplay();
199
+ }
200
+ }
201
+
132
202
  const liveRoleActivityByRoot = new Map<string, LiveRoleActivity>();
133
203
  const LIVE_ROLE_WAITING_MS = 15_000;
134
204
  const LIVE_ROLE_STALLED_MS = 45_000;
@@ -420,6 +490,23 @@ function completionTestContextProposalEditText(): string | undefined {
420
490
  return asString(process.env.PI_COMPLETION_CONTEXT_PROPOSAL_EDIT_TEXT);
421
491
  }
422
492
 
493
+ function completionTestContextProposalUiActionOverride(): ContextProposalConfirmAction | undefined {
494
+ const raw = process.env.PI_COMPLETION_TEST_CONTEXT_PROPOSAL_UI_ACTION?.trim().toLowerCase();
495
+ return raw === "start" || raw === "edit" || raw === "cancel" ? raw : undefined;
496
+ }
497
+
498
+ function completionTestContextProposalUiSnapshotPath(): string | undefined {
499
+ return asString(process.env.PI_COMPLETION_TEST_CONTEXT_PROPOSAL_UI_PATH);
500
+ }
501
+
502
+ function shouldDisableContextProposalAnalyst(): boolean {
503
+ return process.env.PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST === "1";
504
+ }
505
+
506
+ function completionTestContextProposalAnalystOutput(): string | undefined {
507
+ return asString(process.env.PI_COMPLETION_CONTEXT_PROPOSAL_ANALYST_OUTPUT);
508
+ }
509
+
423
510
  function isWorkflowDone(snapshot: CompletionStateSnapshot | undefined): boolean {
424
511
  return asString(snapshot?.state?.continuation_policy) === "done";
425
512
  }
@@ -508,6 +595,335 @@ function uniqueProposalItems(items: string[]): string[] {
508
595
  return result;
509
596
  }
510
597
 
598
+ const MISSION_SCOPE_FILTER_STOPWORDS = new Set([
599
+ "a",
600
+ "an",
601
+ "and",
602
+ "are",
603
+ "as",
604
+ "at",
605
+ "be",
606
+ "by",
607
+ "for",
608
+ "from",
609
+ "goal",
610
+ "goals",
611
+ "in",
612
+ "into",
613
+ "is",
614
+ "it",
615
+ "its",
616
+ "mission",
617
+ "of",
618
+ "on",
619
+ "or",
620
+ "scope",
621
+ "that",
622
+ "the",
623
+ "their",
624
+ "this",
625
+ "to",
626
+ "using",
627
+ "with",
628
+ "workflow",
629
+ ]);
630
+
631
+ function missionScopeFilterTokens(text: string): string[] {
632
+ const normalized = normalizeProposalLine(text).toLowerCase();
633
+ const tokens = normalized.match(/[\p{L}\p{N}]+/gu) ?? [];
634
+ return tokens.filter((token) => {
635
+ if (/^[\p{Script=Han}]+$/u.test(token)) return token.length >= 2;
636
+ if (token.length < 2) return false;
637
+ return !MISSION_SCOPE_FILTER_STOPWORDS.has(token);
638
+ });
639
+ }
640
+
641
+ function isSessionScopeItemMissionRelevant(item: string, mission: string): boolean {
642
+ const normalizedItem = normalizeProposalLine(item).toLowerCase();
643
+ const normalizedMission = normalizeMissionAnchorText(mission).toLowerCase();
644
+ if (!normalizedItem || !normalizedMission) return true;
645
+ if (normalizedItem.includes(normalizedMission) || normalizedMission.includes(normalizedItem)) return true;
646
+ const itemTokens = [...new Set(missionScopeFilterTokens(normalizedItem))];
647
+ const missionTokens = new Set(missionScopeFilterTokens(normalizedMission));
648
+ if (itemTokens.length === 0 || missionTokens.size === 0) return true;
649
+ const overlap = itemTokens.filter((token) => missionTokens.has(token));
650
+ if (overlap.length >= 2) return true;
651
+ return overlap.some((token) => token.length >= 6 || /[\p{Script=Han}]/u.test(token));
652
+ }
653
+
654
+ const CONTEXT_PROPOSAL_ANALYST_SYSTEM_PROMPT = [
655
+ "You analyze recent /cook startup discussion and return a strict JSON object.",
656
+ "Do not emit markdown, code fences, or commentary.",
657
+ "Return exactly one JSON object with keys: mission, scope, constraints, acceptance, confidence, possible_noise.",
658
+ "mission must be a concise implementation mission anchor sentence.",
659
+ "scope must contain only work items that directly support the mission.",
660
+ "constraints must contain guardrails or non-goals explicitly stated or strongly implied by the discussion.",
661
+ "acceptance must contain verifiable outcomes explicitly stated or strongly implied by the discussion.",
662
+ "possible_noise should list discussion points that look stale, weakly related, or unsafe to promote into scope.",
663
+ "When an explicit goal is provided, keep the mission anchored to that goal instead of replacing it with a broader or different mission.",
664
+ "When discussion is insufficient, prefer empty arrays and a low confidence value over invention.",
665
+ ].join(" ");
666
+
667
+ function collectRecentDiscussionEntries(ctx: { sessionManager: any }, limit = 8): RecentDiscussionEntry[] {
668
+ let branch: any[] = [];
669
+ try {
670
+ branch = ctx.sessionManager?.getBranch?.() ?? [];
671
+ } catch (error) {
672
+ if (isStaleContextError(error)) return [];
673
+ throw error;
674
+ }
675
+ const entries: RecentDiscussionEntry[] = [];
676
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
677
+ const entry = branch[index];
678
+ if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) continue;
679
+ const message = entry.message as JsonRecord;
680
+ let text = "";
681
+ let role: RecentDiscussionEntry["role"] | undefined;
682
+ const messageRole = asString(message.role);
683
+ if (messageRole === "user" || messageRole === "assistant" || messageRole === "custom") {
684
+ text = extractTextFromMessageContent(message.content);
685
+ role = messageRole;
686
+ } else if (messageRole === "branchSummary" || messageRole === "compactionSummary") {
687
+ text = asString(message.summary) ?? "";
688
+ role = "summary";
689
+ }
690
+ if (!text || !role) continue;
691
+ const trimmed = text.trim();
692
+ if (!trimmed || /^\/(?:cook|complete)\b/i.test(trimmed)) continue;
693
+ entries.push({ role, text: trimmed });
694
+ if (entries.length >= limit) break;
695
+ }
696
+ return entries;
697
+ }
698
+
699
+ function serializeRecentDiscussionEntries(entries: RecentDiscussionEntry[]): string {
700
+ return entries
701
+ .slice()
702
+ .reverse()
703
+ .map((entry, index) => `[${index + 1}] ${entry.role.toUpperCase()}\n${entry.text}`)
704
+ .join("\n\n");
705
+ }
706
+
707
+ function extractJsonObjectFromText(text: string): string | undefined {
708
+ const trimmed = text.trim();
709
+ if (!trimmed) return undefined;
710
+ const unfenced = trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
711
+ if (unfenced.startsWith("{") && unfenced.endsWith("}")) return unfenced;
712
+ const start = unfenced.indexOf("{");
713
+ const end = unfenced.lastIndexOf("}");
714
+ if (start < 0 || end <= start) return undefined;
715
+ return unfenced.slice(start, end + 1);
716
+ }
717
+
718
+ function parseContextProposalAnalystOutput(
719
+ raw: string,
720
+ projectName: string,
721
+ explicitGoal?: string,
722
+ ): ContextProposal | undefined {
723
+ const jsonText = extractJsonObjectFromText(raw);
724
+ if (!jsonText) return undefined;
725
+ let parsed: unknown;
726
+ try {
727
+ parsed = JSON.parse(jsonText);
728
+ } catch {
729
+ return undefined;
730
+ }
731
+ if (!isRecord(parsed)) return undefined;
732
+ const explicit = explicitGoal ? parseContextProposal(explicitGoal, projectName) : undefined;
733
+ const missionSource = explicit?.mission ?? explicitGoal ?? asString(parsed.mission);
734
+ if (!missionSource) return undefined;
735
+ const assessment = assessMissionAnchor(missionSource, projectName);
736
+ const normalizedMission = normalizeMissionAnchorText(missionSource);
737
+ if (!normalizedMission || isWeakMissionAnchor(normalizedMission)) return undefined;
738
+ const mission = assessment.derived;
739
+ const scope = uniqueProposalItems(asStringArray(parsed.scope));
740
+ const constraints = uniqueProposalItems(asStringArray(parsed.constraints));
741
+ const acceptance = uniqueProposalItems(asStringArray(parsed.acceptance));
742
+ const goalText = buildContextProposalGoalText({ mission, scope, constraints, acceptance });
743
+ return {
744
+ mission,
745
+ scope,
746
+ constraints,
747
+ acceptance,
748
+ goalText,
749
+ basisPreview: raw.replace(/\s+/g, " ").trim(),
750
+ source: "analyst",
751
+ };
752
+ }
753
+
754
+ function contextProposalAnalystModelArg(model: unknown): string | undefined {
755
+ if (!isRecord(model)) return undefined;
756
+ const provider = asString(model.provider);
757
+ const id = asString(model.id);
758
+ return provider && id ? `${provider}/${id}` : undefined;
759
+ }
760
+
761
+ function buildContextProposalAnalystPrompt(projectName: string, recentEntries: RecentDiscussionEntry[], explicitGoal?: string): string {
762
+ const discussion = serializeRecentDiscussionEntries(recentEntries);
763
+ return [
764
+ `Project: ${projectName}`,
765
+ explicitGoal
766
+ ? `Explicit goal (keep this mission anchor):\n${explicitGoal}`
767
+ : "Explicit goal: none provided; infer the current mission from the discussion.",
768
+ "",
769
+ "Recent discussion:",
770
+ discussion,
771
+ ].join("\n");
772
+ }
773
+
774
+ function contextProposalAnalystProgressLines(activity: LiveRoleActivity): string[] {
775
+ return [
776
+ ...buildInlineRunningLines({
777
+ role: activity.role,
778
+ startedAt: activity.startedAt,
779
+ updatedAt: activity.updatedAt,
780
+ currentAction: activity.currentAction,
781
+ toolActivity: activity.toolActivity,
782
+ toolRecentActivity: activity.toolRecentActivity,
783
+ recentActivity: activity.recentActivity,
784
+ assistantSummary: activity.assistantSummary,
785
+ progress: activity.progress,
786
+ rationale: activity.rationale,
787
+ nextStep: activity.nextStep,
788
+ verifying: activity.verifying,
789
+ stateDeltas: activity.stateDeltas,
790
+ }),
791
+ "",
792
+ "This step only prepares a proposal for confirmation.",
793
+ ];
794
+ }
795
+
796
+ async function runContextProposalAnalystSubprocess(
797
+ ctx: { cwd: string; hasUI: boolean; ui: any; model?: any },
798
+ projectName: string,
799
+ recentEntries: RecentDiscussionEntry[],
800
+ explicitGoal?: string,
801
+ ): Promise<string | undefined> {
802
+ const modelArg = contextProposalAnalystModelArg(ctx.model);
803
+ if (!modelArg) return undefined;
804
+ const cwd = getCtxCwd(ctx);
805
+ const runCwd = findCompletionRoot(cwd) ?? findRepoRoot(cwd) ?? cwd;
806
+ const rootKey = completionRootKey(undefined, cwd);
807
+ const prompt = buildContextProposalAnalystPrompt(projectName, recentEntries, explicitGoal);
808
+ const systemPromptTemp = await writeTempFile("pi-cook-proposal-analyst-", CONTEXT_PROPOSAL_ANALYST_SYSTEM_PROMPT);
809
+ const analystRole = "cook-proposal-analyst";
810
+ const args: string[] = ["--mode", "json", "-p", "--no-session", "--append-system-prompt", systemPromptTemp.filePath, "--model", modelArg, prompt];
811
+ const invocation = getPiInvocation(args);
812
+ const liveActivity = createLiveRoleActivity(analystRole);
813
+ liveActivity.progress = "Analyzing recent discussion";
814
+ liveActivity.currentAction = "Reading recent discussion and preparing a startup proposal";
815
+ liveActivity.assistantSummary = liveActivity.progress;
816
+ liveActivity.recentActivity = pushRecentActivity(liveActivity.recentActivity, `assistant: ${liveActivity.progress}`);
817
+ const messages: RoleMessage[] = [];
818
+ let stderr = "";
819
+ let overlay: StartupAnalystOverlay | undefined;
820
+ let finishOverlay: ((value: string | undefined) => void) | undefined;
821
+ let overlaySettled = false;
822
+ const settleOverlay = (value: string | undefined) => {
823
+ if (overlaySettled) return;
824
+ overlaySettled = true;
825
+ finishOverlay?.(value);
826
+ };
827
+ const updateActivity = (fresh = false) => {
828
+ if (fresh) liveActivity.updatedAt = nowMs();
829
+ liveRoleActivityByRoot.set(rootKey, cloneLiveRoleActivity(liveActivity, { status: "running" }));
830
+ void refreshStatus(ctx);
831
+ overlay?.setLines(contextProposalAnalystProgressLines(liveActivity));
832
+ };
833
+ const heartbeat = setInterval(() => updateActivity(false), LIVE_ROLE_HEARTBEAT_MS);
834
+ const run = async (): Promise<string | undefined> => {
835
+ try {
836
+ updateActivity(true);
837
+ const output = await new Promise<string | undefined>((resolve) => {
838
+ const proc = spawn(invocation.command, invocation.args, {
839
+ cwd: runCwd,
840
+ env: process.env,
841
+ stdio: ["ignore", "pipe", "pipe"],
842
+ shell: false,
843
+ });
844
+ let buffer = "";
845
+ const processLine = (line: string) => {
846
+ if (!line.trim()) return;
847
+ try {
848
+ const event = JSON.parse(line) as JsonRecord;
849
+ if (applyLiveRoleEvent(liveActivity, event, messages)) updateActivity(true);
850
+ } catch {
851
+ // ignore malformed lines
852
+ }
853
+ };
854
+ proc.stdout.on("data", (chunk) => {
855
+ buffer += chunk.toString();
856
+ const lines = buffer.split("\n");
857
+ buffer = lines.pop() ?? "";
858
+ for (const line of lines) processLine(line);
859
+ });
860
+ proc.stderr.on("data", (chunk) => {
861
+ stderr += chunk.toString();
862
+ });
863
+ proc.on("close", (code) => {
864
+ if (buffer.trim()) processLine(buffer);
865
+ resolve(code === 0 ? liveActivity.lastAssistantText?.trim() || undefined : undefined);
866
+ });
867
+ proc.on("error", () => resolve(undefined));
868
+ if (overlay) {
869
+ overlay.onAbort = () => {
870
+ proc.kill("SIGTERM");
871
+ resolve(undefined);
872
+ };
873
+ }
874
+ });
875
+ liveRoleActivityByRoot.set(rootKey, cloneLiveRoleActivity(liveActivity, { status: output ? "ok" : "error" }));
876
+ await refreshStatus(ctx);
877
+ return output;
878
+ } finally {
879
+ clearInterval(heartbeat);
880
+ setTimeout(() => {
881
+ const current = liveRoleActivityByRoot.get(rootKey);
882
+ if (current && current.role === analystRole && current.status !== "running") {
883
+ liveRoleActivityByRoot.delete(rootKey);
884
+ void refreshStatus(ctx);
885
+ }
886
+ }, 10_000);
887
+ await fsp.rm(systemPromptTemp.dir, { recursive: true, force: true });
888
+ }
889
+ };
890
+ if (getCtxHasUI(ctx)) {
891
+ const ui = getCtxUi(ctx);
892
+ if (ui) {
893
+ return await ui.custom<string | undefined>((_tui, theme, _kb, done) => {
894
+ finishOverlay = done;
895
+ overlay = new StartupAnalystOverlay(theme);
896
+ overlay.setLines(contextProposalAnalystProgressLines(liveActivity));
897
+ run().then(settleOverlay).catch(() => settleOverlay(undefined));
898
+ return overlay;
899
+ });
900
+ }
901
+ }
902
+ return await run();
903
+ }
904
+
905
+ async function analyzeContextProposalWithAgent(
906
+ ctx: { cwd: string; hasUI: boolean; ui: any; model?: any; modelRegistry?: any },
907
+ projectName: string,
908
+ recentEntries: RecentDiscussionEntry[],
909
+ explicitGoal?: string,
910
+ ): Promise<ContextProposal | undefined> {
911
+ if (shouldDisableContextProposalAnalyst()) return undefined;
912
+ const testOutput = completionTestContextProposalAnalystOutput();
913
+ if (testOutput) {
914
+ return parseContextProposalAnalystOutput(testOutput, projectName, explicitGoal);
915
+ }
916
+ if (recentEntries.length === 0) return undefined;
917
+ try {
918
+ const raw = await runContextProposalAnalystSubprocess(ctx, projectName, recentEntries, explicitGoal);
919
+ if (!raw) return undefined;
920
+ return parseContextProposalAnalystOutput(raw, projectName, explicitGoal);
921
+ } catch (error) {
922
+ console.warn("[completion] context proposal analyst failed", error);
923
+ return undefined;
924
+ }
925
+ }
926
+
511
927
  function buildContextProposalGoalText(proposal: {
512
928
  mission: string;
513
929
  scope: string[];
@@ -547,27 +963,141 @@ function buildContextProposalDisplayText(proposal: ContextProposal): string {
547
963
  return lines.join("\n");
548
964
  }
549
965
 
550
- function buildContextProposalSelectionText(proposal: ContextProposal): string {
966
+ function buildContextProposalConfirmationActions(): ContextProposalConfirmationActionItem[] {
551
967
  return [
552
- "I found a likely implementation plan in the recent discussion.",
553
- "Confirm it before /cook writes canonical workflow state.",
554
- "",
555
- buildContextProposalDisplayText(proposal),
556
- "",
557
- "Start the workflow with this proposal.",
558
- ].join("\n");
968
+ {
969
+ id: "start",
970
+ label: "Start",
971
+ description: "Accept this proposal and let /cook write or refocus canonical workflow state.",
972
+ },
973
+ {
974
+ id: "edit",
975
+ label: "Edit",
976
+ description: "Open the existing proposal editor before starting the workflow.",
977
+ },
978
+ {
979
+ id: "cancel",
980
+ label: "Cancel",
981
+ description: "Exit without changing canonical workflow state.",
982
+ },
983
+ ];
559
984
  }
560
985
 
561
- function buildContextProposalEditChoiceText(): string {
562
- return [
563
- "Edit this proposal before starting.",
564
- "",
565
- "Use this when the mission is right but the scope, constraints, or acceptance details need cleanup.",
566
- ].join("\n");
986
+ function buildContextProposalConfirmationLayout(
987
+ title: string,
988
+ proposal: ContextProposal,
989
+ ): ContextProposalConfirmationLayout {
990
+ return {
991
+ title,
992
+ intro: "Review the proposed mission, scope, constraints, and acceptance details before /cook writes canonical workflow state.",
993
+ proposalHeading: "Proposed workflow",
994
+ proposalBody: buildContextProposalDisplayText(proposal),
995
+ actionsHeading: "Actions",
996
+ actions: buildContextProposalConfirmationActions(),
997
+ footer: "↑↓ navigate • enter select • esc cancel",
998
+ };
999
+ }
1000
+
1001
+ function maybeWriteContextProposalConfirmationSnapshot(layout: ContextProposalConfirmationLayout): void {
1002
+ const snapshotPath = completionTestContextProposalUiSnapshotPath();
1003
+ if (!snapshotPath) return;
1004
+ try {
1005
+ fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
1006
+ fs.writeFileSync(snapshotPath, `${JSON.stringify(layout, null, 2)}\n`, "utf8");
1007
+ } catch {
1008
+ // ignore malformed or unwritable test snapshot paths
1009
+ }
1010
+ }
1011
+
1012
+ function buildContextProposalConfirmationSelectItems(layout: ContextProposalConfirmationLayout): SelectItem[] {
1013
+ return layout.actions.map((action) => ({
1014
+ value: action.id,
1015
+ label: action.label,
1016
+ description: action.description,
1017
+ }));
1018
+ }
1019
+
1020
+ async function promptContextProposalConfirmationAction(
1021
+ ui: any,
1022
+ layout: ContextProposalConfirmationLayout,
1023
+ ): Promise<ContextProposalConfirmAction | undefined> {
1024
+ const items = buildContextProposalConfirmationSelectItems(layout);
1025
+ return await ui.custom<ContextProposalConfirmAction | undefined>((tui: any, theme: any, _kb: any, done: any) => {
1026
+ const container = new Container();
1027
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1028
+ container.addChild(new Text(theme.fg("accent", theme.bold(layout.title)), 1, 0));
1029
+ container.addChild(new Text(theme.fg("dim", layout.intro), 1, 0));
1030
+ container.addChild(new Text("", 0, 0));
1031
+ container.addChild(new Text(theme.fg("accent", theme.bold(layout.proposalHeading)), 1, 0));
1032
+ container.addChild(new Text(layout.proposalBody, 1, 0));
1033
+ container.addChild(new Text("", 0, 0));
1034
+ container.addChild(new Text(theme.fg("accent", theme.bold(layout.actionsHeading)), 1, 0));
1035
+ const selectList = new SelectList(items, items.length, {
1036
+ selectedPrefix: (text) => theme.fg("accent", text),
1037
+ selectedText: (text) => theme.fg("accent", text),
1038
+ description: (text) => theme.fg("muted", text),
1039
+ scrollInfo: (text) => theme.fg("dim", text),
1040
+ noMatch: (text) => theme.fg("warning", text),
1041
+ });
1042
+ selectList.onSelect = (item) => done(item.value as ContextProposalConfirmAction);
1043
+ selectList.onCancel = () => done(undefined);
1044
+ container.addChild(selectList);
1045
+ container.addChild(new Text(theme.fg("dim", layout.footer), 1, 0));
1046
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1047
+
1048
+ return {
1049
+ render: (width: number) => container.render(width),
1050
+ invalidate: () => container.invalidate(),
1051
+ handleInput: (data: string) => {
1052
+ if (matchesKey(data, "escape")) {
1053
+ done(undefined);
1054
+ return;
1055
+ }
1056
+ selectList.handleInput(data);
1057
+ tui.requestRender();
1058
+ },
1059
+ };
1060
+ });
567
1061
  }
568
1062
 
569
- function buildContextProposalCancelChoiceText(): string {
570
- return ["Cancel", "", "Do not start a workflow yet."].join("\n");
1063
+ async function resolveEditedContextProposalDecision(
1064
+ ctx: { hasUI: boolean; ui: any },
1065
+ projectName: string,
1066
+ editedText: string,
1067
+ confirmMissionWhenNeeded: boolean,
1068
+ ): Promise<ContextProposalDecision | undefined> {
1069
+ if (!editedText.trim()) return undefined;
1070
+ const editedProposal = parseContextProposal(editedText, projectName);
1071
+ if (editedProposal) return { missionAnchor: editedProposal.mission, goalText: editedProposal.goalText };
1072
+ const assessment = assessMissionAnchor(editedText, projectName);
1073
+ if (!confirmMissionWhenNeeded) {
1074
+ return { missionAnchor: assessment.derived, goalText: editedText.trim() };
1075
+ }
1076
+ const missionAnchor = await confirmMissionAnchor(ctx, assessment);
1077
+ if (!missionAnchor) return undefined;
1078
+ return { missionAnchor, goalText: editedText.trim() };
1079
+ }
1080
+
1081
+ async function resolveContextProposalConfirmationAction(
1082
+ ctx: { hasUI: boolean; ui: any },
1083
+ proposal: ContextProposal,
1084
+ projectName: string,
1085
+ options: ContextProposalConfirmOptions,
1086
+ action: ContextProposalConfirmAction,
1087
+ editedTextOverride?: string,
1088
+ ): Promise<ContextProposalDecision | undefined> {
1089
+ if (action === "cancel") return undefined;
1090
+ if (action === "start") {
1091
+ return { missionAnchor: proposal.mission, goalText: proposal.goalText };
1092
+ }
1093
+ const editedText =
1094
+ editedTextOverride ??
1095
+ (await getCtxUi(ctx)?.editor(
1096
+ options.editorPrompt ?? `${options.title}\n\nEdit the proposed mission, scope, constraints, and acceptance details below.`,
1097
+ buildContextProposalEditorText(proposal),
1098
+ ));
1099
+ if (!editedText?.trim()) return undefined;
1100
+ return await resolveEditedContextProposalDecision(ctx, projectName, editedText, editedTextOverride === undefined);
571
1101
  }
572
1102
 
573
1103
  function buildContextProposalEditorText(proposal: ContextProposal): string {
@@ -680,53 +1210,28 @@ function parseContextProposal(text: string, projectName: string): ContextProposa
680
1210
  };
681
1211
  }
682
1212
 
683
- function extractContextProposalFromSession(ctx: { sessionManager: any }, projectName: string): ContextProposal | undefined {
684
- let branch: any[] = [];
685
- try {
686
- branch = ctx.sessionManager?.getBranch?.() ?? [];
687
- } catch (error) {
688
- if (isStaleContextError(error)) return undefined;
689
- throw error;
690
- }
691
- const candidates: string[] = [];
692
- for (let index = branch.length - 1; index >= 0; index -= 1) {
693
- const entry = branch[index];
694
- if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) continue;
695
- const message = entry.message as JsonRecord;
696
- let text = "";
697
- const role = asString(message.role);
698
- if (role === "user" || role === "assistant" || role === "custom") {
699
- text = extractTextFromMessageContent(message.content);
700
- } else if (role === "branchSummary" || role === "compactionSummary") {
701
- text = asString(message.summary) ?? "";
702
- }
703
- if (!text) continue;
704
- const trimmed = text.trim();
705
- if (!trimmed || /^\/(?:cook|complete)\b/i.test(trimmed)) continue;
706
- candidates.push(trimmed);
707
- }
708
- for (const candidate of candidates) {
709
- const parsed = parseContextProposal(candidate, projectName);
710
- if (parsed) return parsed;
711
- }
712
- if (candidates.length > 1) {
713
- const combined = candidates.slice(0, 4).reverse().join("\n\n");
714
- return parseContextProposal(combined, projectName);
715
- }
716
- return undefined;
1213
+ async function extractContextProposalFromSession(
1214
+ ctx: { cwd: string; hasUI: boolean; ui: any; sessionManager: any; model?: any; modelRegistry?: any },
1215
+ projectName: string,
1216
+ explicitGoal?: string,
1217
+ ): Promise<ContextProposal | undefined> {
1218
+ const recentEntries = collectRecentDiscussionEntries(ctx);
1219
+ return await analyzeContextProposalWithAgent(ctx, projectName, recentEntries, explicitGoal);
717
1220
  }
718
1221
 
719
- function buildGoalAnchoredContextProposal(
720
- ctx: { sessionManager: any },
1222
+ async function buildGoalAnchoredContextProposal(
1223
+ ctx: { cwd: string; hasUI: boolean; ui: any; sessionManager: any; model?: any; modelRegistry?: any },
721
1224
  goal: string,
722
1225
  projectName: string,
723
- ): ContextProposal {
1226
+ ): Promise<ContextProposal> {
724
1227
  const explicit = parseContextProposal(goal, projectName);
725
- const sessionProposal = extractContextProposalFromSession(ctx, projectName);
1228
+ const sessionProposal = await extractContextProposalFromSession(ctx, projectName, goal);
726
1229
  const missionSource = explicit?.mission ?? goal;
727
1230
  const assessment = assessMissionAnchor(missionSource, projectName);
728
1231
  const mission = assessment.derived;
729
- const scope = uniqueProposalItems([...(explicit?.scope ?? []), ...(sessionProposal?.scope ?? [])]);
1232
+ const explicitScope = explicit?.scope ?? [];
1233
+ const sessionScope = (sessionProposal?.scope ?? []).filter((item) => isSessionScopeItemMissionRelevant(item, mission));
1234
+ const scope = uniqueProposalItems([...explicitScope, ...sessionScope]);
730
1235
  const constraints = uniqueProposalItems([...(explicit?.constraints ?? []), ...(sessionProposal?.constraints ?? [])]);
731
1236
  const acceptance = uniqueProposalItems([...(explicit?.acceptance ?? []), ...(sessionProposal?.acceptance ?? [])]);
732
1237
  const goalText = buildContextProposalGoalText({ mission, scope, constraints, acceptance });
@@ -737,7 +1242,7 @@ function buildGoalAnchoredContextProposal(
737
1242
  acceptance,
738
1243
  goalText,
739
1244
  basisPreview: sessionProposal?.basisPreview ?? explicit?.basisPreview ?? goal,
740
- source: "session",
1245
+ source: sessionProposal?.source ?? "session",
741
1246
  };
742
1247
  }
743
1248
 
@@ -755,10 +1260,20 @@ async function confirmContextProposal(
755
1260
  if (actionOverride === "edit") {
756
1261
  const editedText = completionTestContextProposalEditText();
757
1262
  if (!editedText) return undefined;
758
- const editedProposal = parseContextProposal(editedText, projectName);
759
- if (editedProposal) return { missionAnchor: editedProposal.mission, goalText: editedProposal.goalText };
760
- const assessment = assessMissionAnchor(editedText, projectName);
761
- return { missionAnchor: assessment.derived, goalText: editedText.trim() };
1263
+ return await resolveEditedContextProposalDecision(ctx, projectName, editedText, false);
1264
+ }
1265
+ const layout = buildContextProposalConfirmationLayout(options.title, proposal);
1266
+ maybeWriteContextProposalConfirmationSnapshot(layout);
1267
+ const uiActionOverride = completionTestContextProposalUiActionOverride();
1268
+ if (uiActionOverride) {
1269
+ return await resolveContextProposalConfirmationAction(
1270
+ ctx,
1271
+ proposal,
1272
+ projectName,
1273
+ options,
1274
+ uiActionOverride,
1275
+ uiActionOverride === "edit" ? completionTestContextProposalEditText() : undefined,
1276
+ );
762
1277
  }
763
1278
  if (!getCtxHasUI(ctx)) {
764
1279
  return options.nonInteractiveBehavior === "accept"
@@ -771,25 +1286,9 @@ async function confirmContextProposal(
771
1286
  ? { missionAnchor: proposal.mission, goalText: proposal.goalText }
772
1287
  : undefined;
773
1288
  }
774
- const useChoice = buildContextProposalSelectionText(proposal);
775
- const editChoice = buildContextProposalEditChoiceText();
776
- const cancelChoice = buildContextProposalCancelChoiceText();
777
- const choice = await ui.select(options.title, [useChoice, editChoice, cancelChoice]);
778
- if (!choice || choice === cancelChoice) return undefined;
779
- if (choice === editChoice) {
780
- const editedText = await ui.editor(
781
- options.editorPrompt ?? `${options.title}\n\nEdit the proposed mission, scope, constraints, and acceptance details below.`,
782
- buildContextProposalEditorText(proposal),
783
- );
784
- if (!editedText?.trim()) return undefined;
785
- const editedProposal = parseContextProposal(editedText, projectName);
786
- if (editedProposal) return { missionAnchor: editedProposal.mission, goalText: editedProposal.goalText };
787
- const assessment = assessMissionAnchor(editedText, projectName);
788
- const missionAnchor = await confirmMissionAnchor(ctx, assessment);
789
- if (!missionAnchor) return undefined;
790
- return { missionAnchor, goalText: editedText.trim() };
791
- }
792
- return { missionAnchor: proposal.mission, goalText: proposal.goalText };
1289
+ const choice = await promptContextProposalConfirmationAction(ui, layout);
1290
+ if (!choice) return undefined;
1291
+ return await resolveContextProposalConfirmationAction(ctx, proposal, projectName, options, choice);
793
1292
  }
794
1293
 
795
1294
  function currentMissionAnchor(snapshot: CompletionStateSnapshot): string {
@@ -2422,11 +2921,11 @@ export default function completionExtension(pi: ExtensionAPI) {
2422
2921
  const root = findRepoRoot(cwd) ?? cwd;
2423
2922
  const projectName = path.basename(root);
2424
2923
  if (!goal) {
2425
- const proposal = extractContextProposalFromSession(ctx, projectName);
2924
+ const proposal = await extractContextProposalFromSession(ctx, projectName);
2426
2925
  if (!proposal) {
2427
2926
  emitCommandText(
2428
2927
  ctx,
2429
- "Usage: /cook <goal> (or finish shaping the plan in discussion, then rerun /cook to confirm the proposed workflow)",
2928
+ "Usage: /cook <goal> (discussion-only startup needs proposal analyst output; otherwise pass an explicit goal)",
2430
2929
  "error",
2431
2930
  );
2432
2931
  return;
@@ -2443,7 +2942,7 @@ export default function completionExtension(pi: ExtensionAPI) {
2443
2942
  goal = decision.goalText;
2444
2943
  kickoffMissionAnchor = decision.missionAnchor;
2445
2944
  } else {
2446
- const proposal = buildGoalAnchoredContextProposal(ctx, goal, projectName);
2945
+ const proposal = await buildGoalAnchoredContextProposal(ctx, goal, projectName);
2447
2946
  const decision = await confirmContextProposal(ctx, proposal, projectName, {
2448
2947
  title: "Start a completion workflow from this goal?",
2449
2948
  nonInteractiveBehavior: "accept",
@@ -2472,11 +2971,11 @@ export default function completionExtension(pi: ExtensionAPI) {
2472
2971
  if (!goal) {
2473
2972
  if (workflowDone) {
2474
2973
  const projectName = path.basename(snapshot.files.root);
2475
- const proposal = extractContextProposalFromSession(ctx, projectName);
2974
+ const proposal = await extractContextProposalFromSession(ctx, projectName);
2476
2975
  if (!proposal) {
2477
2976
  emitCommandText(
2478
2977
  ctx,
2479
- "The previous completion workflow is already done. Shape the next plan in discussion or provide a new goal, then rerun /cook to start the next round.",
2978
+ "The previous completion workflow is already done. Provide /cook <goal>, or rerun /cook when the proposal analyst can summarize the next round from discussion.",
2480
2979
  "info",
2481
2980
  );
2482
2981
  return;
@@ -2512,7 +3011,7 @@ export default function completionExtension(pi: ExtensionAPI) {
2512
3011
  if (hadSnapshot && explicitGoal) {
2513
3012
  if (workflowDone) {
2514
3013
  const projectName = path.basename(snapshot.files.root);
2515
- const proposal = buildGoalAnchoredContextProposal(ctx, goal, projectName);
3014
+ const proposal = await buildGoalAnchoredContextProposal(ctx, goal, projectName);
2516
3015
  const decision = await confirmContextProposal(ctx, proposal, projectName, {
2517
3016
  title: "Start the next workflow round from this goal?",
2518
3017
  nonInteractiveBehavior: "accept",
@@ -2539,7 +3038,7 @@ export default function completionExtension(pi: ExtensionAPI) {
2539
3038
  kickoffMissionAnchor = decision.currentMissionAnchor;
2540
3039
  if (decision.action === "refocus") {
2541
3040
  const projectName = path.basename(snapshot.files.root);
2542
- const proposal = buildGoalAnchoredContextProposal(ctx, goal, projectName);
3041
+ const proposal = await buildGoalAnchoredContextProposal(ctx, goal, projectName);
2543
3042
  const proposalDecision = await confirmContextProposal(ctx, proposal, projectName, {
2544
3043
  title: "Start the replacement workflow from this goal?",
2545
3044
  nonInteractiveBehavior: "accept",