@linimin/pi-letscook 0.1.26 → 0.1.28

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
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.28
4
+
5
+ ### Changed
6
+
7
+ - added model-assisted `/cook` startup proposal analysis for natural recent discussion with a live `/cook proposal analyst` progress overlay, removed the built-in discussion-parser fallback for discussion-only startup, and preserved explicit-goal mission anchoring even when analyst output is unavailable
8
+ - replaced the crowded built-in `/cook` startup proposal selector presentation with a custom confirmation UI that separates proposal content from explicit Start, Edit, and Cancel actions
9
+ - fixed `/cook proposal analyst` overlay input handling and improved proposal body readability in the confirmation UI
10
+
11
+ ## 0.1.27
12
+
13
+ ### Changed
14
+
15
+ - added package metadata for npm and pi.dev discovery, fixed README publishing links for npm rendering, and refined install and workflow guidance after the `v0.1.26` tag
16
+
3
17
  ## 0.1.26
4
18
 
5
19
  ### Changed
package/README.md CHANGED
@@ -6,7 +6,8 @@ Pi package for long-running coding workflows with canonical repo-local `.agent/*
6
6
 
7
7
  - `/cook` as the single workflow command
8
8
  - `/cook <goal>` to bootstrap or continue with an explicit goal, enriched by recent discussion before canonical state is written
9
- - `/cook` with no goal to resume an active canonical `.agent/**` workflow, or propose a new round from recent discussion when no active workflow is running
9
+ - `/cook` with no goal to resume an active canonical `.agent/**` workflow, or propose a new round from recent discussion through the proposal analyst when no active workflow is running
10
+ - model-assisted startup proposal analysis for natural recent discussion, with a live `/cook proposal analyst` overlay; if that analysis is unavailable, use `/cook <goal>` instead of a built-in discussion parser fallback
10
11
  - `/cook <new goal>` on an active workflow asks whether to continue the current mission or abandon it for a replacement workflow; on a completed workflow it starts the next round from the new goal instead of reopening continue/refocus choices
11
12
  - no duplicate prompt-template aliases for core workflow commands
12
13
  - role-based isolated subprocess execution via `completion_role`
@@ -89,9 +90,9 @@ After install, run `/reload` in pi. For this package, it is safest to reload whe
89
90
 
90
91
  | Repo state | `/cook` | `/cook <goal>` |
91
92
  |---|---|---|
92
- | No canonical workflow yet | Proposes a startup plan from recent discussion, then asks for confirmation | Builds a startup proposal anchored on the explicit goal, enriches it from recent discussion, then asks for confirmation |
93
+ | No canonical workflow yet | Proposes a startup plan from recent discussion when the proposal analyst can summarize it, then asks for confirmation | Builds a startup proposal anchored on the explicit goal, enriches it from recent discussion when analyst output is available, then asks for confirmation |
93
94
  | Active workflow exists | Resumes the active workflow from canonical `.agent/**` state | Asks whether to continue the current workflow or replace it with a new one |
94
- | Previous workflow is already `done` | Proposes the next workflow round from recent discussion, then asks for confirmation | Starts the next workflow round from the explicit goal, using recent discussion only as supplemental proposal context |
95
+ | Previous workflow is already `done` | Proposes the next workflow round from recent discussion when the proposal analyst can summarize it, then asks for confirmation | Starts the next workflow round from the explicit goal, using recent discussion only as supplemental proposal context when analyst output is available |
95
96
 
96
97
  ### One-step start
97
98
 
@@ -101,7 +102,7 @@ After install, run `/reload` in pi. For this package, it is safest to reload whe
101
102
 
102
103
  This bootstraps `.agent/**` if missing, derives a clean initial `MISSION ANCHOR`, builds a startup proposal, lets you confirm or edit it, re-grounds canonical state, creates a slice plan, and drives the workflow.
103
104
 
104
- When you pass an explicit goal, that goal stays the mission anchor. Recent discussion is only used to fill in extra scope, constraints, and acceptance details before canonical state is written.
105
+ When a model is available, `/cook` first asks it to summarize the recent natural-language discussion into a structured proposal and shows a live `/cook proposal analyst` progress overlay while that analysis is running. Discussion-only startup now relies on that analyst path only; if analyst output is unavailable, use `/cook <goal>` instead of expecting a built-in rule-based parser fallback. When you pass an explicit goal, that goal still stays the mission anchor. Recent discussion is only used to fill in extra scope, constraints, and acceptance details before canonical state is written when analyst output is available.
105
106
 
106
107
  ### Resume later
107
108
 
@@ -138,7 +139,7 @@ If the previous workflow is already `done`, `/cook <goal>` starts the next workf
138
139
  /cook
139
140
  ```
140
141
 
141
- If the previous workflow is already `done`, `/cook` with no goal tries to infer the next plan from recent discussion, asks you to confirm it, and then starts the next workflow round from refreshed canonical state.
142
+ If the previous workflow is already `done`, `/cook` with no goal tries to infer the next plan from recent discussion through the proposal analyst, asks you to confirm it, and then starts the next workflow round from refreshed canonical state. If analyst output is unavailable, provide an explicit goal with `/cook <goal>` instead.
142
143
 
143
144
  ## Canonical repo files
144
145
 
@@ -209,7 +210,7 @@ npm run release-check
209
210
 
210
211
  `npm run release-check` is the broader packaged-release verifier. It reruns the smoke, refocus, and context-proposal checks, includes the deterministic observability regression coverage, and finishes with `npm pack --dry-run`.
211
212
 
212
- See [PUBLISHING.md](./PUBLISHING.md) for GitHub and npm release steps.
213
+ See [PUBLISHING.md](https://github.com/linimin/pi-letscook/blob/main/PUBLISHING.md) for GitHub and npm release steps.
213
214
 
214
215
  ## Notes
215
216
 
@@ -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",
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "@linimin/pi-letscook",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
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,
7
+ "author": "linimin",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/linimin/pi-letscook.git"
11
+ },
12
+ "homepage": "https://github.com/linimin/pi-letscook#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/linimin/pi-letscook/issues"
15
+ },
7
16
  "keywords": ["pi-package", "pi", "workflow", "completion", "agent", "subagent"],
8
17
  "files": [
9
18
  "extensions",
@@ -101,12 +101,30 @@ mkdir -p "$ROOT"
101
101
  cd "$ROOT"
102
102
  git init -q
103
103
 
104
- # No workflow yet: /cook with no goal should infer from recent discussion.
104
+ # No workflow yet: /cook with no goal should not bootstrap from discussion alone when analyst output is unavailable.
105
+ SESSION_ZERO="$TMPDIR/session-zero.jsonl"
106
+ DISCUSSION_ZERO=$'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.\nAcceptance:\n- Update README to match the shipped behavior.\n- Keep observability regression coverage truthful.'
107
+ write_session "$SESSION_ZERO" "$ROOT" "$DISCUSSION_ZERO"
108
+
109
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
110
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
111
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
112
+ pi --session "$SESSION_ZERO" -e "$PKG_ROOT" -p "/cook" >/tmp/pi-completion-context-proposal-no-analyst.out 2>/tmp/pi-completion-context-proposal-no-analyst.err
113
+
114
+ python3 - <<'PY'
115
+ from pathlib import Path
116
+
117
+ assert not Path('.agent').exists(), '/cook should not bootstrap canonical state from discussion alone without analyst output'
118
+ PY
119
+
120
+ # No workflow yet: /cook with no goal should infer from recent discussion through analyst output.
105
121
  SESSION_ONE="$TMPDIR/session-one.jsonl"
106
- DISCUSSION_ONE=$'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.\nAcceptance:\n- Update README to match the shipped behavior.\n- Keep observability regression coverage truthful.'
122
+ DISCUSSION_ONE="$DISCUSSION_ZERO"
123
+ ANALYST_OUTPUT_ONE='{"mission":"Remove the completion status line while keeping the completion widget.","scope":["Keep the non-running completion widget.","Suppress the widget while a completion role is active."],"constraints":["Do not reintroduce any other completion status surface."],"acceptance":["Update README to match the shipped behavior.","Keep observability regression coverage truthful."],"confidence":0.94}'
107
124
  write_session "$SESSION_ONE" "$ROOT" "$DISCUSSION_ONE"
108
125
 
109
126
  PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
127
+ PI_COMPLETION_CONTEXT_PROPOSAL_ANALYST_OUTPUT="$ANALYST_OUTPUT_ONE" \
110
128
  PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
111
129
  pi --session "$SESSION_ONE" -e "$PKG_ROOT" -p "/cook" >/tmp/pi-completion-context-proposal-bootstrap.out 2>/tmp/pi-completion-context-proposal-bootstrap.err
112
130
 
@@ -120,22 +138,24 @@ state = json.loads(Path('.agent/state.json').read_text())
120
138
  plan = json.loads(Path('.agent/plan.json').read_text())
121
139
  active = json.loads(Path('.agent/active-slice.json').read_text())
122
140
 
123
- assert mission in mission_text, '.agent/mission.md did not record the context-derived mission anchor'
124
- assert state['mission_anchor'] == mission, 'state.json mission_anchor mismatch after context-derived bootstrap'
125
- assert plan['mission_anchor'] == mission, 'plan.json mission_anchor mismatch after context-derived bootstrap'
126
- assert active['mission_anchor'] == mission, 'active-slice.json mission_anchor mismatch after context-derived bootstrap'
127
- assert state['current_phase'] == 'reground', 'state.json current_phase should start at reground after context-derived bootstrap'
128
- assert state['next_mandatory_role'] == 'completion-regrounder', 'next_mandatory_role should start at completion-regrounder after context-derived bootstrap'
141
+ assert mission in mission_text, '.agent/mission.md did not record the analyst-derived mission anchor'
142
+ assert state['mission_anchor'] == mission, 'state.json mission_anchor mismatch after analyst-derived bootstrap'
143
+ assert plan['mission_anchor'] == mission, 'plan.json mission_anchor mismatch after analyst-derived bootstrap'
144
+ assert active['mission_anchor'] == mission, 'active-slice.json mission_anchor mismatch after analyst-derived bootstrap'
145
+ assert state['current_phase'] == 'reground', 'state.json current_phase should start at reground after analyst-derived bootstrap'
146
+ assert state['next_mandatory_role'] == 'completion-regrounder', 'next_mandatory_role should start at completion-regrounder after analyst-derived bootstrap'
129
147
  PY
130
148
 
131
- # Completed workflow: /cook with no goal should infer the next round from recent discussion.
149
+ # Completed workflow: /cook with no goal should infer the next round from recent discussion through analyst output.
132
150
  mark_done
133
151
 
134
152
  SESSION_TWO="$TMPDIR/session-two.jsonl"
135
153
  DISCUSSION_TWO=$'Mission: Ship the next workflow round for richer context-derived /cook startup.\nScope:\n- Start a new workflow round from recent discussion after the previous one is done.\n- Keep using canonical .agent state after confirmation.\nConstraints:\n- Do not resume the completed workflow when the new round is clearly different.\nAcceptance:\n- Reset canonical state back to reground for the new mission.\n- Preserve the tracked completion control-plane files.'
154
+ ANALYST_OUTPUT_TWO='{"mission":"Ship the next workflow round for richer context-derived /cook startup.","scope":["Start a new workflow round from recent discussion after the previous one is done.","Keep using canonical .agent state after confirmation."],"constraints":["Do not resume the completed workflow when the new round is clearly different."],"acceptance":["Reset canonical state back to reground for the new mission.","Preserve the tracked completion control-plane files."],"confidence":0.93}'
136
155
  write_session "$SESSION_TWO" "$ROOT" "$DISCUSSION_TWO"
137
156
 
138
157
  PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
158
+ PI_COMPLETION_CONTEXT_PROPOSAL_ANALYST_OUTPUT="$ANALYST_OUTPUT_TWO" \
139
159
  PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
140
160
  pi --session "$SESSION_TWO" -e "$PKG_ROOT" -p "/cook" >/tmp/pi-completion-context-proposal-next-round.out 2>/tmp/pi-completion-context-proposal-next-round.err
141
161
 
@@ -163,14 +183,15 @@ assert plan['plan_basis'] == 'user_refocus', 'plan_basis should reset to user_re
163
183
  assert active['status'] == 'idle', 'active-slice should reset to idle for the next workflow round'
164
184
  PY
165
185
 
166
- # Active workflow: /cook <goal> plus refocus should use the explicit goal as the mission anchor,
167
- # while still allowing recent discussion to enrich the proposal before confirmation.
186
+ # Active workflow: /cook <goal> plus refocus should use the explicit goal as the mission anchor
187
+ # even when analyst output is unavailable, without falling back to session-derived proposal parsing.
168
188
  SESSION_THREE="$TMPDIR/session-three.jsonl"
169
189
  DISCUSSION_THREE=$'Scope:\n- Preserve the richer proposal structure from discussion.\nConstraints:\n- Keep explicit goals as the mission anchor when they conflict with earlier text.\nAcceptance:\n- Refresh canonical state from the replacement mission.'
170
190
  write_session "$SESSION_THREE" "$ROOT" "$DISCUSSION_THREE"
171
191
 
172
192
  PI_COMPLETION_EXISTING_WORKFLOW_ACTION=refocus \
173
193
  PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
194
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
174
195
  PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
175
196
  pi --session "$SESSION_THREE" -e "$PKG_ROOT" -p "/cook Explicit replacement mission for the active workflow" >/tmp/pi-completion-context-proposal-active-goal.out 2>/tmp/pi-completion-context-proposal-active-goal.err
176
197
 
@@ -192,31 +213,37 @@ assert state['current_phase'] == 'reground', 'current_phase should reset to regr
192
213
  assert state['continuation_policy'] == 'continue', 'continuation_policy should stay continue after explicit-goal replacement'
193
214
  assert state['next_mandatory_role'] == 'completion-regrounder', 'next role should reset to completion-regrounder after explicit-goal replacement'
194
215
  assert state['continuation_reason'].startswith('User refocused workflow via /cook:'), 'continuation_reason should record the explicit-goal replacement'
216
+ assert 'Preserve the richer proposal structure from discussion.' not in state['continuation_reason'], 'session scope should not be merged when analyst output is unavailable'
217
+ assert 'Keep explicit goals as the mission anchor when they conflict with earlier text.' not in state['continuation_reason'], 'session constraints should not be merged when analyst output is unavailable'
218
+ assert 'Refresh canonical state from the replacement mission.' not in state['continuation_reason'], 'session acceptance should not be merged when analyst output is unavailable'
195
219
  assert plan['plan_basis'] == 'user_refocus', 'plan_basis should be user_refocus after explicit-goal replacement'
196
220
  assert active['status'] == 'idle', 'active slice should reset to idle after explicit-goal replacement'
197
221
  PY
198
222
 
199
223
  # Completed workflow again: /cook <goal> should start the next round directly from the explicit goal
200
- # without requiring existing-workflow continue/refocus confirmation.
224
+ # even when analyst output is unavailable, without merging session-derived scope, constraints, or acceptance.
201
225
  mark_done
202
226
 
203
227
  SESSION_FOUR="$TMPDIR/session-four.jsonl"
204
- DISCUSSION_FOUR=$'Mission: This older discussion should not override the explicit next-round goal.\nScope:\n- Reuse discussion details only as supplemental proposal context.\nAcceptance:\n- Start the next round from the explicit goal.'
228
+ DISCUSSION_FOUR=$'Scope:\n- Add session-only scope.\n- Restyle widget.\nConstraints:\n- Keep rules.\nAcceptance:\n- Add test.'
229
+ EXPLICIT_GOAL_FOUR=$'Mission: Filter scope by mission.\nScope:\n- Keep explicit scope.'
205
230
  write_session "$SESSION_FOUR" "$ROOT" "$DISCUSSION_FOUR"
206
231
 
207
232
  PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
233
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
208
234
  PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
209
- pi --session "$SESSION_FOUR" -e "$PKG_ROOT" -p "/cook Explicit goal for the next completed-workflow round" >/tmp/pi-completion-context-proposal-done-goal.out 2>/tmp/pi-completion-context-proposal-done-goal.err
235
+ pi --session "$SESSION_FOUR" -e "$PKG_ROOT" -p "/cook $EXPLICIT_GOAL_FOUR" >/tmp/pi-completion-context-proposal-done-goal.out 2>/tmp/pi-completion-context-proposal-done-goal.err
210
236
 
211
237
  python3 - <<'PY'
212
238
  import json
213
239
  from pathlib import Path
214
240
 
215
- mission = 'Explicit goal for the next completed-workflow round.'
241
+ mission = 'Filter scope by mission.'
216
242
  mission_text = Path('.agent/mission.md').read_text()
217
243
  state = json.loads(Path('.agent/state.json').read_text())
218
244
  plan = json.loads(Path('.agent/plan.json').read_text())
219
245
  active = json.loads(Path('.agent/active-slice.json').read_text())
246
+ continuation_reason = state['continuation_reason']
220
247
 
221
248
  assert mission in mission_text, '.agent/mission.md did not update to the explicit next-round mission anchor'
222
249
  assert state['mission_anchor'] == mission, 'state.json mission_anchor mismatch after explicit-goal next-round start'
@@ -227,9 +254,146 @@ assert state['continuation_policy'] == 'continue', 'continuation_policy should r
227
254
  assert state['project_done'] is False, 'project_done should reset to false after explicit-goal next-round start'
228
255
  assert state['requires_reground'] is True, 'requires_reground should reset to true after explicit-goal next-round start'
229
256
  assert state['next_mandatory_role'] == 'completion-regrounder', 'next role should reset to completion-regrounder after explicit-goal next-round start'
230
- assert state['continuation_reason'].startswith('User refocused workflow via /cook:'), 'continuation_reason should record the explicit-goal next-round start'
257
+ assert continuation_reason.startswith('User refocused workflow via /cook:'), 'continuation_reason should record the explicit-goal next-round start'
258
+ assert 'Keep explicit scope.' in continuation_reason, 'explicit scope should remain in the explicit-goal proposal'
259
+ assert 'Add session-only scope.' not in continuation_reason, 'session-derived scope should not be merged when analyst output is unavailable'
260
+ assert 'Restyle widget.' not in continuation_reason, 'unrelated session-derived scope should not be merged when analyst output is unavailable'
261
+ assert 'Keep rules.' not in continuation_reason, 'session-derived constraints should not merge when analyst output is unavailable'
262
+ assert 'Add test.' not in continuation_reason, 'session-derived acceptance should not merge when analyst output is unavailable'
231
263
  assert plan['plan_basis'] == 'user_refocus', 'plan_basis should be user_refocus after explicit-goal next-round start'
232
264
  assert active['status'] == 'idle', 'active slice should reset to idle after explicit-goal next-round start'
233
265
  PY
234
266
 
267
+ # Completed workflow again: /cook with no goal should be able to use model-assisted
268
+ # analysis of natural discussion when discussion-only startup depends on analyst output.
269
+ mark_done
270
+
271
+ SESSION_FIVE="$TMPDIR/session-five.jsonl"
272
+ DISCUSSION_FIVE=$'I do not want to rewrite the parser. The safer path is to let /cook analyze the discussion first, keep the user\'s explicit mission if they provided one, and ignore stale scope that drifted in from earlier turns. We should still prove it with a regression test before writing canonical state.'
273
+ ANALYST_OUTPUT_FIVE='{"mission":"Use a proposal analyst to summarize natural discussion before /cook writes canonical state.","scope":["Keep explicit goals anchored.","Drop stale scope from earlier turns."],"constraints":["Do not rewrite the parser."],"acceptance":["Add a regression test."],"confidence":0.91,"possible_noise":["old unrelated scope"]}'
274
+ write_session "$SESSION_FIVE" "$ROOT" "$DISCUSSION_FIVE"
275
+
276
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
277
+ PI_COMPLETION_CONTEXT_PROPOSAL_ANALYST_OUTPUT="$ANALYST_OUTPUT_FIVE" \
278
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
279
+ pi --session "$SESSION_FIVE" -e "$PKG_ROOT" -p "/cook" >/tmp/pi-completion-context-proposal-analyst.out 2>/tmp/pi-completion-context-proposal-analyst.err
280
+
281
+ python3 - <<'PY'
282
+ import json
283
+ from pathlib import Path
284
+
285
+ mission = 'Use a proposal analyst to summarize natural discussion before /cook writes canonical state.'
286
+ mission_text = Path('.agent/mission.md').read_text()
287
+ state = json.loads(Path('.agent/state.json').read_text())
288
+ plan = json.loads(Path('.agent/plan.json').read_text())
289
+ active = json.loads(Path('.agent/active-slice.json').read_text())
290
+ continuation_reason = state['continuation_reason']
291
+
292
+ assert mission in mission_text, '.agent/mission.md did not record the analyst-derived mission anchor'
293
+ assert state['mission_anchor'] == mission, 'state.json mission_anchor mismatch after analyst-derived bootstrap'
294
+ assert plan['mission_anchor'] == mission, 'plan.json mission_anchor mismatch after analyst-derived bootstrap'
295
+ assert active['mission_anchor'] == mission, 'active-slice.json mission_anchor mismatch after analyst-derived bootstrap'
296
+ assert state['current_phase'] == 'reground', 'current_phase should reset to reground after analyst-derived bootstrap'
297
+ assert state['next_mandatory_role'] == 'completion-regrounder', 'next role should reset to completion-regrounder after analyst-derived bootstrap'
298
+ assert continuation_reason.startswith('User refocused workflow via /cook:'), 'continuation_reason should record the analyst-derived restart'
299
+ assert 'Keep explicit goals anchored.' in continuation_reason, 'analyst-derived scope should be preserved'
300
+ PY
301
+
302
+ # Custom confirmation UI: start should render proposal content separately from explicit Start/Edit/Cancel actions.
303
+ UI_ROOT_START="$TMPDIR/ui-root-start"
304
+ mkdir -p "$UI_ROOT_START"
305
+ cd "$UI_ROOT_START"
306
+ git init -q
307
+
308
+ UI_SESSION_START="$TMPDIR/ui-session-start.jsonl"
309
+ UI_DISCUSSION_START=$'Mission: Replace the crowded selector with a clearer action layout.\nScope:\n- Separate proposal text from actions.\nConstraints:\n- Preserve Start/Edit/Cancel behavior.\nAcceptance:\n- Add regression coverage.'
310
+ UI_ANALYST_OUTPUT_START='{"mission":"Replace the crowded selector with a clearer action layout.","scope":["Separate proposal text from actions."],"constraints":["Preserve Start/Edit/Cancel behavior."],"acceptance":["Add regression coverage."],"confidence":0.95}'
311
+ UI_SNAPSHOT_START="$TMPDIR/context-proposal-ui-start.json"
312
+ write_session "$UI_SESSION_START" "$UI_ROOT_START" "$UI_DISCUSSION_START"
313
+
314
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_UI_ACTION=start \
315
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_UI_PATH="$UI_SNAPSHOT_START" \
316
+ PI_COMPLETION_CONTEXT_PROPOSAL_ANALYST_OUTPUT="$UI_ANALYST_OUTPUT_START" \
317
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
318
+ pi --session "$UI_SESSION_START" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-context-proposal-ui-start.out" 2>"$TMPDIR/pi-completion-context-proposal-ui-start.err"
319
+
320
+ python3 - "$UI_SNAPSHOT_START" <<'PY'
321
+ import json
322
+ import sys
323
+ from pathlib import Path
324
+
325
+ snapshot = json.loads(Path(sys.argv[1]).read_text())
326
+ state = json.loads(Path('.agent/state.json').read_text())
327
+
328
+ assert snapshot['proposalHeading'] == 'Proposed workflow', 'custom confirmation snapshot should expose a dedicated proposal section'
329
+ assert 'Mission\nReplace the crowded selector with a clearer action layout.' in snapshot['proposalBody'], 'proposal body should be captured separately from the action list'
330
+ assert [action['id'] for action in snapshot['actions']] == ['start', 'edit', 'cancel'], 'custom confirmation actions should stay Start/Edit/Cancel'
331
+ assert [action['label'] for action in snapshot['actions']] == ['Start', 'Edit', 'Cancel'], 'custom confirmation action labels should be concise'
332
+ for action in snapshot['actions']:
333
+ assert 'Replace the crowded selector with a clearer action layout.' not in action['label'], 'proposal mission should not be embedded in action labels'
334
+ assert 'Separate proposal text from actions.' not in action['description'], 'proposal scope should not be embedded in action descriptions'
335
+ assert state['mission_anchor'] == 'Replace the crowded selector with a clearer action layout.', 'start action should still accept the proposed mission'
336
+ PY
337
+
338
+ # Custom confirmation UI: edit should keep the existing editor/parsing flow when the action comes from the new layout.
339
+ UI_ROOT_EDIT="$TMPDIR/ui-root-edit"
340
+ mkdir -p "$UI_ROOT_EDIT"
341
+ cd "$UI_ROOT_EDIT"
342
+ git init -q
343
+
344
+ UI_SESSION_EDIT="$TMPDIR/ui-session-edit.jsonl"
345
+ UI_DISCUSSION_EDIT=$'Mission: Keep editing support in the custom confirmation UI.\nScope:\n- Preserve the proposal editor.\nConstraints:\n- Keep parsing structured proposal text.\nAcceptance:\n- Update the mission anchor after edit.'
346
+ UI_ANALYST_OUTPUT_EDIT='{"mission":"Keep editing support in the custom confirmation UI.","scope":["Preserve the proposal editor."],"constraints":["Keep parsing structured proposal text."],"acceptance":["Update the mission anchor after edit."],"confidence":0.94}'
347
+ UI_EDIT_TEXT=$'Mission: Edited mission from the custom confirmation UI.\nScope:\n- Preserve parsing after edit.\nConstraints:\n- Keep the shared confirmation flow.\nAcceptance:\n- Start the workflow from the edited proposal.'
348
+ write_session "$UI_SESSION_EDIT" "$UI_ROOT_EDIT" "$UI_DISCUSSION_EDIT"
349
+
350
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_UI_ACTION=edit \
351
+ PI_COMPLETION_CONTEXT_PROPOSAL_EDIT_TEXT="$UI_EDIT_TEXT" \
352
+ PI_COMPLETION_CONTEXT_PROPOSAL_ANALYST_OUTPUT="$UI_ANALYST_OUTPUT_EDIT" \
353
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
354
+ pi --session "$UI_SESSION_EDIT" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-context-proposal-ui-edit.out" 2>"$TMPDIR/pi-completion-context-proposal-ui-edit.err"
355
+
356
+ python3 - <<'PY'
357
+ import json
358
+ from pathlib import Path
359
+
360
+ state = json.loads(Path('.agent/state.json').read_text())
361
+ plan = json.loads(Path('.agent/plan.json').read_text())
362
+ active = json.loads(Path('.agent/active-slice.json').read_text())
363
+ mission = 'Edited mission from the custom confirmation UI.'
364
+
365
+ assert state['mission_anchor'] == mission, 'edit action should still route through the proposal parser and update state.json'
366
+ assert plan['mission_anchor'] == mission, 'edit action should still route through the proposal parser and update plan.json'
367
+ assert active['mission_anchor'] == mission, 'edit action should still route through the proposal parser and update active-slice.json'
368
+ assert state['current_phase'] == 'reground', 'edit action should still bootstrap/reground the workflow'
369
+ PY
370
+
371
+ # Custom confirmation UI: cancel should exit without writing canonical state.
372
+ UI_ROOT_CANCEL="$TMPDIR/ui-root-cancel"
373
+ mkdir -p "$UI_ROOT_CANCEL"
374
+ cd "$UI_ROOT_CANCEL"
375
+ git init -q
376
+
377
+ UI_SESSION_CANCEL="$TMPDIR/ui-session-cancel.jsonl"
378
+ UI_DISCUSSION_CANCEL=$'Mission: Cancel from the custom confirmation UI without writing state.\nScope:\n- Show the proposal separately from the actions.\nConstraints:\n- Keep cancellation side-effect free.\nAcceptance:\n- Leave .agent absent after cancel.'
379
+ UI_ANALYST_OUTPUT_CANCEL='{"mission":"Cancel from the custom confirmation UI without writing state.","scope":["Show the proposal separately from the actions."],"constraints":["Keep cancellation side-effect free."],"acceptance":["Leave .agent absent after cancel."],"confidence":0.92}'
380
+ UI_SNAPSHOT_CANCEL="$TMPDIR/context-proposal-ui-cancel.json"
381
+ write_session "$UI_SESSION_CANCEL" "$UI_ROOT_CANCEL" "$UI_DISCUSSION_CANCEL"
382
+
383
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_UI_ACTION=cancel \
384
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_UI_PATH="$UI_SNAPSHOT_CANCEL" \
385
+ PI_COMPLETION_CONTEXT_PROPOSAL_ANALYST_OUTPUT="$UI_ANALYST_OUTPUT_CANCEL" \
386
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
387
+ pi --session "$UI_SESSION_CANCEL" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-context-proposal-ui-cancel.out" 2>"$TMPDIR/pi-completion-context-proposal-ui-cancel.err"
388
+
389
+ python3 - "$UI_SNAPSHOT_CANCEL" <<'PY'
390
+ import json
391
+ import sys
392
+ from pathlib import Path
393
+
394
+ snapshot = json.loads(Path(sys.argv[1]).read_text())
395
+ assert [action['id'] for action in snapshot['actions']] == ['start', 'edit', 'cancel'], 'cancel snapshot should still expose Start/Edit/Cancel actions'
396
+ assert not Path('.agent').exists(), 'cancel action should not write canonical workflow state'
397
+ PY
398
+
235
399
  echo "context proposal test passed: $ROOT"