@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 +14 -0
- package/README.md +7 -6
- package/extensions/completion/index.ts +588 -89
- package/package.json +10 -1
- package/scripts/context-proposal-test.sh +180 -16
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](
|
|
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
|
|
966
|
+
function buildContextProposalConfirmationActions(): ContextProposalConfirmationActionItem[] {
|
|
551
967
|
return [
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
570
|
-
|
|
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(
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
775
|
-
|
|
776
|
-
|
|
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> (
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
124
|
-
assert state['mission_anchor'] == mission, 'state.json mission_anchor mismatch after
|
|
125
|
-
assert plan['mission_anchor'] == mission, 'plan.json mission_anchor mismatch after
|
|
126
|
-
assert active['mission_anchor'] == mission, 'active-slice.json mission_anchor mismatch after
|
|
127
|
-
assert state['current_phase'] == 'reground', 'state.json current_phase should start at reground after
|
|
128
|
-
assert state['next_mandatory_role'] == 'completion-regrounder', 'next_mandatory_role should start at completion-regrounder after
|
|
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
|
-
#
|
|
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
|
|
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=$'
|
|
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
|
|
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 = '
|
|
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
|
|
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"
|