@linimin/pi-letscook 0.1.55 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.57
6
+
5
7
  ### Changed
6
8
 
7
- - made `/cook` derive a confirmable startup brief from recent discussion before any canonical workflow rewrite, then preserve the confirmed brief in canonical state as advisory intake for later re-grounding
9
+ - made explicit primary-agent `/cook` handoff the preferred startup-intake path by teaching ordinary-chat handoff turns to emit a structured `cook_handoff` capsule and letting `/cook` prefer that capsule over broad context re-inference when it is fresh and valid
10
+ - kept context-derived startup as a fallback only, so stale, drifted, or non-startable handoff capsules still fail closed or fall back to recent discussion instead of silently rewriting canonical state
11
+ - made finished-workflow suppression stay a safety layer instead of a replacement mission when a fresh explicit `/cook` handoff exists, and blocked negative rejection/suppression text from becoming a Startable startup mission
8
12
  - removed inline `/cook` arguments from the shipped entry path again so explicit bare `/cook` is the only public command, and fail closed when recent discussion is insufficient or unreliable
13
+ - added a pre-`/cook` ordinary-chat handoff boundary so the primary agent is instructed to stop at `/cook` once a task has matured into completion-workflow scope instead of starting long-running implementation directly in ordinary chat
9
14
 
10
15
  ## 0.1.54
11
16
 
package/README.md CHANGED
@@ -49,12 +49,13 @@ Then run `/reload` in Pi.
49
49
 
50
50
  ## What `/cook` expects
51
51
 
52
- - recent main-chat discussion about concrete repo changes
52
+ - preferably a fresh explicit primary-agent `/cook` handoff capsule from the immediately preceding ordinary-chat turn
53
+ - otherwise recent main-chat discussion about concrete repo changes
53
54
  - enough detail to derive a startup brief with mission, scope, constraints or non-goals, acceptance, and notes or risks
54
55
  - README/CHANGELOG updates still count as concrete repo changes
55
- - assistant-produced summaries and plan/spec/design-doc/proposal-only artifacts do not
56
+ - assistant-produced summaries and plan/spec/design-doc/proposal-only artifacts still do not count unless they include the explicit structured `/cook` handoff capsule
56
57
 
57
- If recent discussion is missing, weak, ambiguous, assistant-produced, or only describes planning artifacts instead of concrete repo changes, `/cook` fails closed, leaves canonical `.agent/**` state unchanged, and tells you to clarify the mission in the main chat before rerunning `/cook`.
58
+ If no fresh valid handoff exists and recent discussion is missing, weak, ambiguous, assistant-produced, or only describes planning artifacts instead of concrete repo changes, `/cook` fails closed, leaves canonical `.agent/**` state unchanged, and tells you to clarify the mission in the main chat before rerunning `/cook`.
58
59
 
59
60
  If you pass inline arguments to `/cook`, it also fails closed and tells you to move that intent into the main chat before rerunning bare `/cook`.
60
61
 
@@ -62,10 +63,15 @@ If you pass inline arguments to `/cook`, it also fails closed and tells you to m
62
63
 
63
64
  Only explicit `/cook` enters the workflow. Ordinary prompts stay in the main chat and go straight to the primary agent.
64
65
 
66
+ If a task has clearly matured into completion-workflow scope, the primary agent should hand you off to `/cook` instead of starting long-running implementation directly in ordinary chat.
67
+
68
+ That handoff should include an explicit structured `/cook` capsule in the assistant reply so `/cook` can confirm the already-formed mission instead of re-deriving it from broad ambient context.
69
+
65
70
  Important behavior:
66
71
  - `/cook` is the canonical workflow boundary and manual entry point
67
72
  - startup, refocus, and next-round routing stay confirm-first; nothing silently starts a workflow
68
73
  - explicit slash commands other than `/cook` continue normally in the main chat
74
+ - ordinary main-chat discussion may clarify or propose, but mature long-running implementation should be handed off to `/cook`
69
75
 
70
76
  ## Typical examples
71
77
 
@@ -78,13 +84,13 @@ I want to add login redirect handling and tests.
78
84
 
79
85
  ## What happens when you run `/cook`
80
86
 
81
- `/cook` first derives a startup brief from recent discussion, then shows the existing approval-only Start/Cancel gate.
87
+ `/cook` first looks for a fresh explicit primary-agent handoff capsule. If one is valid, `/cook` builds the startup brief from that handoff and only uses recent discussion as validation or supplemental notes. If no valid handoff exists, `/cook` falls back to deriving a startup brief from recent discussion before showing the existing approval-only Start/Cancel gate.
82
88
 
83
89
  | Repo state | What you'll see |
84
90
  |---|---|
85
- | No workflow yet | A startup brief built from recent main-chat discussion. You choose **Start** or **Cancel**. Weak, unreliable, or planning-only discussion fails closed. |
86
- | Active workflow exists | Usually a resume of the current workflow. If recent discussion clearly points to a different concrete repo change, `/cook` shows a chooser first and only rewrites canonical state after you confirm the new startup brief. Ambiguous discussion stays conservative. |
87
- | Previous workflow is `done` | A next-round startup brief from recent main-chat discussion, again behind **Start** or **Cancel**. Discussion that only restates already-finished work fails closed. |
91
+ | No workflow yet | If a fresh explicit handoff capsule exists, a startup brief built from that handoff. Otherwise a startup brief built from recent main-chat discussion. You choose **Start** or **Cancel**. Weak, unreliable, stale, or planning-only intake fails closed. |
92
+ | Active workflow exists | Usually a resume of the current workflow. If a fresh explicit handoff capsule or recent discussion clearly points to a different concrete repo change, `/cook` shows a chooser first and only rewrites canonical state after you confirm the new startup brief. Ambiguous intake stays conservative. |
93
+ | Previous workflow is `done` | A fresh explicit handoff capsule can still start the next implementation round behind **Start** or **Cancel**. Without that, `/cook` falls back to recent discussion. Discussion that only restates already-finished work still fails closed. |
88
94
 
89
95
  ## Confirmation and fail-closed behavior
90
96
 
@@ -93,13 +99,15 @@ I want to add login redirect handling and tests.
93
99
  - startup, next-round, and refocus proposals are approval-only
94
100
  - actions are **Start** and **Cancel**
95
101
  - **Cancel** is side-effect free: discuss changes in the main chat and rerun `/cook`
96
- - weak, ambiguous, assistant-produced, or planning-only discussion does not start a workflow
102
+ - weak, ambiguous, stale, invalid, assistant-produced, or planning-only intake does not start a workflow
97
103
  - when recent discussion suggests a different workflow, `/cook` shows a chooser before any canonical state rewrite
98
104
 
99
105
  When you accept startup or refocus, `/cook` persists the chosen workflow state in canonical `.agent/**` files before the re-ground round begins.
100
106
 
101
107
  The confirmed startup brief is also preserved there as advisory intake for later re-grounding. It does not replace `.agent/plan.json` or `.agent/active-slice.json`, which remain under regrounder authority.
102
108
 
109
+ The pre-`/cook` handoff capsule itself is not canonical workflow state. It is only startup intake for `/cook`.
110
+
103
111
  ## Observability
104
112
 
105
113
  When canonical `.agent/**` state exists and no role is actively running, the extension shows a completion widget sourced from that state. The widget summarizes:
@@ -16,7 +16,9 @@ import {
16
16
  import {
17
17
  assessMissionAnchor,
18
18
  collectRecentDiscussionEntries,
19
+ collectRecentSessionMessages,
19
20
  deriveCookContextProposalFromRecentDiscussion,
21
+ extractLatestCookHandoffProposal,
20
22
  finalizeContextProposalAnalysis,
21
23
  isWeakMissionAnchor,
22
24
  missionAnchorsLikelyEquivalent,
@@ -38,6 +40,7 @@ import {
38
40
  buildContextProposalConfirmationLayout as buildExtractedContextProposalConfirmationLayout,
39
41
  buildContextProposalConfirmationSelectItems,
40
42
  buildContextProposalContinuationReason as buildExtractedContextProposalContinuationReason,
43
+ buildCookHandoffBoundaryReminder as buildExtractedCookHandoffBoundaryReminder,
41
44
  buildEvaluationRoleContextLines as buildExtractedEvaluationRoleContextLines,
42
45
  buildEvaluationRoleReminderText as buildExtractedEvaluationRoleReminderText,
43
46
  buildResumeCapsule as buildExtractedResumeCapsule,
@@ -181,6 +184,10 @@ function completionTestSystemReminderPath(): string | undefined {
181
184
  return asString(process.env.PI_COMPLETION_TEST_SYSTEM_REMINDER_PATH);
182
185
  }
183
186
 
187
+ function completionTestCookHandoffReminderPath(): string | undefined {
188
+ return asString(process.env.PI_COMPLETION_TEST_COOK_HANDOFF_REMINDER_PATH);
189
+ }
190
+
184
191
  function maybeWriteTestSnapshot(targetPath: string | undefined, content: string): void {
185
192
  if (!targetPath) return;
186
193
  try {
@@ -225,6 +232,19 @@ function shouldInjectCompletionWorkflowContext(snapshot: CompletionStateSnapshot
225
232
  return hasCompletionRoutingActivation(snapshot) && isCompletionDriverPromptTurn(ctx);
226
233
  }
227
234
 
235
+ function shouldInjectCookHandoffBoundary(event: { prompt?: string }, ctx: { sessionManager?: any }): boolean {
236
+ if (roleFromEnv()) return false;
237
+ if (isCompletionDriverPromptTurn(ctx)) return false;
238
+ const prompt = typeof event.prompt === "string" ? event.prompt.trim() : "";
239
+ if (!prompt) return false;
240
+ if (prompt.startsWith("/")) return false;
241
+ return true;
242
+ }
243
+
244
+ function buildCookHandoffBoundaryReminder(): string {
245
+ return buildExtractedCookHandoffBoundaryReminder();
246
+ }
247
+
228
248
  function buildDoneWorkflowBoundaryReminder(snapshot: CompletionStateSnapshot): string {
229
249
  const missionAnchor = asString(snapshot.state?.mission_anchor) ?? asString(snapshot.plan?.mission_anchor) ?? "(unknown)";
230
250
  const continuationReason = asString(snapshot.state?.continuation_reason) ?? "(unknown)";
@@ -346,7 +366,11 @@ async function deriveCookContextProposal(
346
366
  ctx: { cwd: string; hasUI: boolean; ui: any; sessionManager: any; model?: any; modelRegistry?: any },
347
367
  projectName: string,
348
368
  ): Promise<ContextProposal | undefined> {
349
- const recentEntries = collectRecentDiscussionEntries(ctx, { isRecord, asString, isStaleContextError });
369
+ const recentMessages = collectRecentSessionMessages(ctx, { isRecord, asString, asNumber, isStaleContextError });
370
+ const recentEntries = recentMessages
371
+ .filter((entry) => (entry.role === "user" || entry.role === "custom") && !entry.isCommand)
372
+ .slice(0, 8)
373
+ .map((entry) => ({ role: entry.role, text: entry.text }));
350
374
  const snapshot = await loadCompletionSnapshot(getCtxCwd(ctx));
351
375
  const workflowContextLines = snapshot
352
376
  ? [
@@ -360,6 +384,16 @@ async function deriveCookContextProposal(
360
384
  `verification summary: ${asString(snapshot.verificationEvidence?.summary) ?? "(none)"}`,
361
385
  ]
362
386
  : [];
387
+ const explicitHandoff = extractLatestCookHandoffProposal(recentMessages, projectName, {
388
+ asString,
389
+ asStringArray,
390
+ assessMissionAnchor,
391
+ normalizeMissionAnchorText,
392
+ isWeakMissionAnchor,
393
+ missionAnchorsStrictlyEquivalent,
394
+ stripCodeBlocks,
395
+ });
396
+ if (explicitHandoff) return explicitHandoff;
363
397
  return await deriveCookContextProposalFromRecentDiscussion(projectName, recentEntries, {
364
398
  asString,
365
399
  asStringArray,
@@ -926,7 +960,7 @@ export default function completionExtension(pi: ExtensionAPI) {
926
960
  }
927
961
  });
928
962
 
929
- pi.on("before_agent_start", async (_event, ctx) => {
963
+ pi.on("before_agent_start", async (event, ctx) => {
930
964
  const loaded = await loadCompletionDataForReminder(getCtxCwd(ctx));
931
965
  const driverPromptTurn = isCompletionDriverPromptTurn(ctx);
932
966
  if (loaded && driverPromptTurn) {
@@ -934,28 +968,35 @@ export default function completionExtension(pi: ExtensionAPI) {
934
968
  const fingerprint = completionContinuationFingerprint(loaded.snapshot);
935
969
  if (fingerprint) markQueuedDriverPromptInFlight(rootKey, fingerprint);
936
970
  }
937
- if (!loaded || !shouldInjectCompletionWorkflowContext(loaded.snapshot, ctx)) return;
938
- const additions = isWorkflowDone(loaded.snapshot)
939
- ? [buildDoneWorkflowBoundaryReminder(loaded.snapshot)]
940
- : [composeSystemReminder(loaded.snapshot, loaded.sliceHistory, loaded.stopHistory)];
941
- if (!isWorkflowDone(loaded.snapshot)) {
942
- const markerText = await readText(loaded.snapshot.files.compactionMarkerPath);
943
- let marker: JsonRecord | undefined;
944
- if (markerText) {
945
- try {
946
- const parsed = JSON.parse(markerText);
947
- marker = isRecord(parsed) ? parsed : undefined;
948
- } catch {
949
- marker = undefined;
971
+ const systemPrompt = getSystemPromptSafe(ctx);
972
+ if (!systemPrompt) return;
973
+ if (loaded && shouldInjectCompletionWorkflowContext(loaded.snapshot, ctx)) {
974
+ const additions = isWorkflowDone(loaded.snapshot)
975
+ ? [buildDoneWorkflowBoundaryReminder(loaded.snapshot)]
976
+ : [composeSystemReminder(loaded.snapshot, loaded.sliceHistory, loaded.stopHistory)];
977
+ if (!isWorkflowDone(loaded.snapshot)) {
978
+ const markerText = await readText(loaded.snapshot.files.compactionMarkerPath);
979
+ let marker: JsonRecord | undefined;
980
+ if (markerText) {
981
+ try {
982
+ const parsed = JSON.parse(markerText);
983
+ marker = isRecord(parsed) ? parsed : undefined;
984
+ } catch {
985
+ marker = undefined;
986
+ }
950
987
  }
988
+ if (marker) additions.push(buildPostCompactionDriverInstructions(loaded.snapshot, marker));
951
989
  }
952
- if (marker) additions.push(buildPostCompactionDriverInstructions(loaded.snapshot, marker));
990
+ maybeWriteTestSnapshot(completionTestSystemReminderPath(), additions.join("\n\n"));
991
+ return {
992
+ systemPrompt: `${systemPrompt}\n\n${additions.join("\n\n")}`,
993
+ };
953
994
  }
954
- maybeWriteTestSnapshot(completionTestSystemReminderPath(), additions.join("\n\n"));
955
- const systemPrompt = getSystemPromptSafe(ctx);
956
- if (!systemPrompt) return;
995
+ if (!shouldInjectCookHandoffBoundary(event, ctx)) return;
996
+ const handoffReminder = buildCookHandoffBoundaryReminder();
997
+ maybeWriteTestSnapshot(completionTestCookHandoffReminderPath(), handoffReminder);
957
998
  return {
958
- systemPrompt: `${systemPrompt}\n\n${additions.join("\n\n")}`,
999
+ systemPrompt: `${systemPrompt}\n\n${handoffReminder}`,
959
1000
  };
960
1001
  });
961
1002
 
@@ -10,7 +10,7 @@ import type {
10
10
 
11
11
  export type AdvisoryStartupBrief = {
12
12
  kind: "startup_brief";
13
- source: "recent_discussion";
13
+ source: "recent_discussion" | "primary_agent_handoff";
14
14
  confirmed: true;
15
15
  captured_at: string;
16
16
  goal_text: string;
@@ -24,6 +24,20 @@ export type AdvisoryStartupBrief = {
24
24
  evaluation_profile?: string;
25
25
  };
26
26
 
27
+ export function buildCookHandoffBoundaryReminder(): string {
28
+ return [
29
+ "You are still in ordinary main chat before any explicit /cook workflow entry.",
30
+ "Use ordinary chat to clarify requirements, discuss tradeoffs, and propose implementation approaches.",
31
+ "/cook is the only explicit entrypoint into long-running completion workflow.",
32
+ "When you judge that the task has matured into completion-workflow scope — for example the user has clearly shifted from exploration into implementation intent, you have just produced a concrete plan or proposal whose next step would naturally be implementation, or the task spans multiple files, steps, or verification surfaces — stop short of long-running implementation and tell the user to run /cook.",
33
+ "At that handoff point, do not begin long-running product implementation in ordinary chat, do not edit tracked product files for that workflow-level task, and do not act as though /cook had already been invoked.",
34
+ "When handing off, explain that /cook will first look for a fresh explicit primary-agent handoff capsule and otherwise fall back to recent discussion.",
35
+ "Also append one exact fenced block in the same assistant reply using ```cook_handoff ... ``` JSON with kind/source/handoff_kind plus mission, scope, constraints or non_goals, acceptance, risks, notes, captured_at, source_turn_id, and optional task_type/evaluation_profile/why_cook_now.",
36
+ "The capsule is startup intake for /cook only: do not present it as canonical .agent state, an active slice, or a persistent repo contract.",
37
+ "If the task is still ordinary Q&A, lightweight brainstorming, or a tiny one-off fix, continue normally without forcing /cook.",
38
+ ].join(" ");
39
+ }
40
+
27
41
  export function buildContextProposalGoalText(proposal: {
28
42
  mission: string;
29
43
  scope: string[];
@@ -72,13 +86,13 @@ function buildAdvisoryStartupBriefNotes(analysis: ContextProposalAnalysis): stri
72
86
  }
73
87
 
74
88
  export function buildAdvisoryStartupBrief(args: {
75
- proposal: Pick<ContextProposal, "goalText" | "mission" | "scope" | "constraints" | "acceptance">;
89
+ proposal: Pick<ContextProposal, "goalText" | "mission" | "scope" | "constraints" | "acceptance" | "source">;
76
90
  analysis: ContextProposalAnalysis;
77
91
  capturedAt?: string;
78
92
  }): AdvisoryStartupBrief {
79
93
  return {
80
94
  kind: "startup_brief",
81
- source: "recent_discussion",
95
+ source: args.proposal.source === "handoff_capsule" ? "primary_agent_handoff" : "recent_discussion",
82
96
  confirmed: true,
83
97
  captured_at: args.capturedAt ?? new Date().toISOString(),
84
98
  goal_text: args.proposal.goalText,
@@ -27,7 +27,7 @@ export type ContextProposalAlternate = {
27
27
  analysis: ContextProposalAnalysis;
28
28
  goalText: string;
29
29
  basisPreview: string;
30
- source: "session" | "analyst";
30
+ source: "session" | "analyst" | "handoff_capsule";
31
31
  };
32
32
 
33
33
  export type ContextProposal = ContextProposalAlternate & {
@@ -41,6 +41,30 @@ export type RecentDiscussionEntry = {
41
41
  text: string;
42
42
  };
43
43
 
44
+ export type RecentSessionMessage = RecentDiscussionEntry & {
45
+ messageId?: string;
46
+ timestampMs?: number;
47
+ isCommand: boolean;
48
+ };
49
+
50
+ export type CookHandoffCapsule = {
51
+ kind: "cook_handoff";
52
+ source: "primary_agent";
53
+ captured_at: string;
54
+ source_turn_id: string;
55
+ mission: string;
56
+ scope: string[];
57
+ constraints: string[];
58
+ non_goals: string[];
59
+ acceptance: string[];
60
+ risks: string[];
61
+ notes: string[];
62
+ handoff_kind: "implementation_workflow_ready";
63
+ task_type?: string;
64
+ evaluation_profile?: string;
65
+ why_cook_now?: string;
66
+ };
67
+
44
68
  export type ContextProposalDecision = {
45
69
  missionAnchor: string;
46
70
  goalText: string;
@@ -113,6 +137,10 @@ function localAsStringArray(value: unknown): string[] {
113
137
  : [];
114
138
  }
115
139
 
140
+ function localAsNumber(value: unknown): number | undefined {
141
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
142
+ }
143
+
116
144
  function localIsRecord(value: unknown): value is JsonRecord {
117
145
  return typeof value === "object" && value !== null && !Array.isArray(value);
118
146
  }
@@ -300,11 +328,12 @@ export function missionAnchorsLikelyEquivalent(left: string, right: string): boo
300
328
  return missionAnchorBigramOverlapRatio(leftTokens, rightTokens) >= 0.5;
301
329
  }
302
330
 
303
- export function collectRecentDiscussionEntries(ctx: { sessionManager: any }, deps: {
331
+ export function collectRecentSessionMessages(ctx: { sessionManager: any }, deps: {
304
332
  isRecord: (value: unknown) => boolean;
305
333
  asString?: (value: unknown) => string | undefined;
334
+ asNumber?: (value: unknown) => number | undefined;
306
335
  isStaleContextError?: (error: unknown) => boolean;
307
- }, limit = 8): RecentDiscussionEntry[] {
336
+ }, limit = 24): RecentSessionMessage[] {
308
337
  let branch: any[] = [];
309
338
  try {
310
339
  branch = ctx.sessionManager?.getBranch?.() ?? [];
@@ -313,27 +342,42 @@ export function collectRecentDiscussionEntries(ctx: { sessionManager: any }, dep
313
342
  throw error;
314
343
  }
315
344
  const asStringValue = deps.asString ?? localAsString;
316
- const entries: RecentDiscussionEntry[] = [];
345
+ const asNumberValue = deps.asNumber ?? localAsNumber;
346
+ const entries: RecentSessionMessage[] = [];
317
347
  for (let index = branch.length - 1; index >= 0; index -= 1) {
318
348
  const entry = branch[index];
319
349
  if (!deps.isRecord(entry) || entry.type !== "message" || !deps.isRecord(entry.message)) continue;
320
350
  const message = entry.message as JsonRecord;
321
- let text = "";
322
- let role: RecentDiscussionEntry["role"] | undefined;
323
351
  const messageRole = asStringValue(message.role);
324
- if (messageRole === "user" || messageRole === "custom") {
325
- text = extractTextFromMessageContent(message.content);
326
- role = messageRole;
327
- }
328
- if (!text || !role) continue;
352
+ if (messageRole !== "user" && messageRole !== "assistant" && messageRole !== "custom" && messageRole !== "summary") continue;
353
+ const text = extractTextFromMessageContent(message.content);
354
+ if (!text) continue;
329
355
  const trimmed = text.trim();
330
- if (!trimmed || /^\/(?:cook|complete)\b/i.test(trimmed)) continue;
331
- entries.push({ role, text: trimmed });
356
+ if (!trimmed) continue;
357
+ entries.push({
358
+ role: messageRole as RecentDiscussionEntry["role"],
359
+ text: trimmed,
360
+ messageId: asStringValue((entry as JsonRecord).id),
361
+ timestampMs: asNumberValue(message.timestamp),
362
+ isCommand: /^\//.test(trimmed),
363
+ });
332
364
  if (entries.length >= limit) break;
333
365
  }
334
366
  return entries;
335
367
  }
336
368
 
369
+ export function collectRecentDiscussionEntries(ctx: { sessionManager: any }, deps: {
370
+ isRecord: (value: unknown) => boolean;
371
+ asString?: (value: unknown) => string | undefined;
372
+ asNumber?: (value: unknown) => number | undefined;
373
+ isStaleContextError?: (error: unknown) => boolean;
374
+ }, limit = 8): RecentDiscussionEntry[] {
375
+ return collectRecentSessionMessages(ctx, deps, Math.max(limit * 3, limit))
376
+ .filter((entry) => (entry.role === "user" || entry.role === "custom") && !entry.isCommand)
377
+ .slice(0, limit)
378
+ .map((entry) => ({ role: entry.role, text: entry.text }));
379
+ }
380
+
337
381
  export function serializeRecentDiscussionEntries(entries: RecentDiscussionEntry[]): string {
338
382
  return entries
339
383
  .slice()
@@ -1182,6 +1226,167 @@ export function extractContextProposalFromStructuredSession(
1182
1226
  return parseStrictStructuredSessionProposal(structuredTexts.join("\n\n"), projectName, deps);
1183
1227
  }
1184
1228
 
1229
+ const COOK_HANDOFF_BLOCK_REGEX = /```cook_handoff\s*([\s\S]*?)```/giu;
1230
+ const COOK_HANDOFF_MAX_AGE_MS = 45 * 60 * 1000;
1231
+ const COOK_HANDOFF_MAX_LATER_NON_COMMAND_MESSAGES = 2;
1232
+ const COOK_HANDOFF_NEGATIVE_MISSION_REGEX =
1233
+ /(?:\b(?:do not|don't|dont|not|never|avoid|skip|refuse|recognize that|suppress|ignore|block|prevent)\b|(?:不要|別|别|勿|禁止|避免|忽略|阻止))/iu;
1234
+
1235
+ function parseCookHandoffCapsulesFromText(
1236
+ text: string,
1237
+ messageId: string | undefined,
1238
+ timestampMs: number | undefined,
1239
+ deps: Pick<ProposalParseDeps, "asString" | "asStringArray">,
1240
+ ): CookHandoffCapsule[] {
1241
+ const capsules: CookHandoffCapsule[] = [];
1242
+ for (const match of text.matchAll(COOK_HANDOFF_BLOCK_REGEX)) {
1243
+ const rawJson = deps.asString(match[1]);
1244
+ if (!rawJson) continue;
1245
+ let parsed: unknown;
1246
+ try {
1247
+ parsed = JSON.parse(rawJson);
1248
+ } catch {
1249
+ continue;
1250
+ }
1251
+ if (!localIsRecord(parsed)) continue;
1252
+ if (deps.asString(parsed.kind) !== "cook_handoff") continue;
1253
+ if (deps.asString(parsed.source) !== "primary_agent") continue;
1254
+ if (deps.asString(parsed.handoff_kind) !== "implementation_workflow_ready") continue;
1255
+ const mission = deps.asString(parsed.mission);
1256
+ if (!mission) continue;
1257
+ const scope = deps.asStringArray(parsed.scope);
1258
+ const constraints = deps.asStringArray(parsed.constraints);
1259
+ const nonGoals = deps.asStringArray(parsed.non_goals ?? parsed.nonGoals);
1260
+ const acceptance = deps.asStringArray(parsed.acceptance);
1261
+ const risks = deps.asStringArray(parsed.risks);
1262
+ const notes = deps.asStringArray(parsed.notes);
1263
+ const capturedAt = deps.asString(parsed.captured_at) ?? (timestampMs ? new Date(timestampMs).toISOString() : undefined);
1264
+ const sourceTurnId = deps.asString(parsed.source_turn_id) ?? messageId;
1265
+ if (!capturedAt || !sourceTurnId) continue;
1266
+ capsules.push({
1267
+ kind: "cook_handoff",
1268
+ source: "primary_agent",
1269
+ captured_at: capturedAt,
1270
+ source_turn_id: sourceTurnId,
1271
+ mission,
1272
+ scope,
1273
+ constraints,
1274
+ non_goals: nonGoals,
1275
+ acceptance,
1276
+ risks,
1277
+ notes,
1278
+ handoff_kind: "implementation_workflow_ready",
1279
+ task_type: deps.asString(parsed.task_type),
1280
+ evaluation_profile: deps.asString(parsed.evaluation_profile),
1281
+ why_cook_now: deps.asString(parsed.why_cook_now),
1282
+ });
1283
+ }
1284
+ return capsules;
1285
+ }
1286
+
1287
+ function buildCookHandoffBasisPreview(capsule: CookHandoffCapsule): string {
1288
+ const parts = [capsule.mission, ...capsule.scope, ...capsule.constraints, ...capsule.non_goals, ...capsule.acceptance];
1289
+ if (capsule.why_cook_now) parts.push(`why_cook_now: ${capsule.why_cook_now}`);
1290
+ return parts.join("\n").trim();
1291
+ }
1292
+
1293
+ function isStartableCookHandoffCapsule(
1294
+ capsule: CookHandoffCapsule,
1295
+ deps: Pick<ProposalParseDeps, "normalizeMissionAnchorText" | "isWeakMissionAnchor">,
1296
+ ): boolean {
1297
+ const mission = deps.normalizeMissionAnchorText(capsule.mission);
1298
+ if (!mission || deps.isWeakMissionAnchor(mission)) return false;
1299
+ if (COOK_HANDOFF_NEGATIVE_MISSION_REGEX.test(mission)) return false;
1300
+ if (capsule.scope.length === 0 || capsule.acceptance.length === 0) return false;
1301
+ return true;
1302
+ }
1303
+
1304
+ function laterMessagesInvalidateCookHandoff(
1305
+ laterMessages: RecentSessionMessage[],
1306
+ deps: Pick<ProposalParseDeps, "stripCodeBlocks">,
1307
+ ): boolean {
1308
+ const laterNonCommandMessages = laterMessages.filter((entry) => !entry.isCommand);
1309
+ if (laterNonCommandMessages.length > COOK_HANDOFF_MAX_LATER_NON_COMMAND_MESSAGES) return true;
1310
+ return laterNonCommandMessages.some((entry) => {
1311
+ if (entry.role === "summary") return false;
1312
+ if (!hasRecentDiscussionImplementationIntent(entry.text, deps.stripCodeBlocks)) return false;
1313
+ return true;
1314
+ });
1315
+ }
1316
+
1317
+ function cookHandoffIsFreshEnough(capsule: CookHandoffCapsule, laterMessages: RecentSessionMessage[]): boolean {
1318
+ const capturedAtMs = Date.parse(capsule.captured_at);
1319
+ if (!Number.isFinite(capturedAtMs)) return false;
1320
+ const laterTimestamps = laterMessages.map((entry) => entry.timestampMs).filter((value): value is number => value !== undefined);
1321
+ if (laterTimestamps.length === 0) return true;
1322
+ return Math.max(...laterTimestamps) - capturedAtMs <= COOK_HANDOFF_MAX_AGE_MS;
1323
+ }
1324
+
1325
+ function buildContextProposalFromCookHandoffCapsule(
1326
+ capsule: CookHandoffCapsule,
1327
+ projectName: string,
1328
+ deps: ProposalParseDeps,
1329
+ ): ContextProposal | undefined {
1330
+ if (!isStartableCookHandoffCapsule(capsule, deps)) return undefined;
1331
+ const constraints = uniqueProposalItems([...capsule.constraints, ...capsule.non_goals]);
1332
+ const mission = deps.assessMissionAnchor(capsule.mission, projectName).derived;
1333
+ const goalText = buildContextProposalGoalText({
1334
+ mission,
1335
+ scope: capsule.scope,
1336
+ constraints,
1337
+ acceptance: capsule.acceptance,
1338
+ });
1339
+ const proposal: ContextProposal = {
1340
+ mission,
1341
+ scope: [...capsule.scope],
1342
+ constraints,
1343
+ acceptance: [...capsule.acceptance],
1344
+ analysis: finalizeContextProposalAnalysis(
1345
+ {
1346
+ taskType: capsule.task_type,
1347
+ evaluationProfile: capsule.evaluation_profile,
1348
+ critique: [
1349
+ ...capsule.notes,
1350
+ ...(capsule.why_cook_now ? [`Primary-agent /cook handoff rationale: ${capsule.why_cook_now}`] : []),
1351
+ ],
1352
+ risks: capsule.risks,
1353
+ possibleNoise: [],
1354
+ alternateMissions: [],
1355
+ suppressedCompletedTopics: [],
1356
+ suppressedNegatedTopics: [],
1357
+ },
1358
+ [mission, goalText, capsule.mission, ...capsule.scope, ...constraints, ...capsule.acceptance],
1359
+ ),
1360
+ goalText,
1361
+ basisPreview: buildCookHandoffBasisPreview(capsule),
1362
+ source: "handoff_capsule",
1363
+ alternateProposals: [],
1364
+ };
1365
+ return finalizeContextProposal(proposal, projectName, deps);
1366
+ }
1367
+
1368
+ export function extractLatestCookHandoffProposal(
1369
+ recentMessages: RecentSessionMessage[],
1370
+ projectName: string,
1371
+ deps: ProposalParseDeps,
1372
+ ): ContextProposal | undefined {
1373
+ for (let index = 0; index < recentMessages.length; index += 1) {
1374
+ const entry = recentMessages[index];
1375
+ if (entry.role !== "assistant" || entry.isCommand) continue;
1376
+ const capsules = parseCookHandoffCapsulesFromText(entry.text, entry.messageId, entry.timestampMs, deps);
1377
+ if (capsules.length === 0) continue;
1378
+ for (let capsuleIndex = capsules.length - 1; capsuleIndex >= 0; capsuleIndex -= 1) {
1379
+ const capsule = capsules[capsuleIndex];
1380
+ const laterMessages = recentMessages.slice(0, index);
1381
+ if (!cookHandoffIsFreshEnough(capsule, laterMessages)) continue;
1382
+ if (laterMessagesInvalidateCookHandoff(laterMessages, deps)) continue;
1383
+ const proposal = buildContextProposalFromCookHandoffCapsule(capsule, projectName, deps);
1384
+ if (proposal) return proposal;
1385
+ }
1386
+ }
1387
+ return undefined;
1388
+ }
1389
+
1185
1390
  export async function deriveCookContextProposalFromRecentDiscussion(
1186
1391
  projectName: string,
1187
1392
  recentEntries: RecentDiscussionEntry[],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linimin/pi-letscook",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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,
@@ -47,6 +47,49 @@ with session_path.open('w', encoding='utf-8') as fh:
47
47
  PY
48
48
  }
49
49
 
50
+ write_session_messages() {
51
+ local session_path="$1"
52
+ local cwd="$2"
53
+ local messages_json="$3"
54
+ python3 - "$session_path" "$cwd" "$messages_json" <<'PY'
55
+ import json
56
+ import sys
57
+ from pathlib import Path
58
+
59
+ session_path = Path(sys.argv[1])
60
+ cwd = sys.argv[2]
61
+ messages = json.loads(sys.argv[3])
62
+ session_path.parent.mkdir(parents=True, exist_ok=True)
63
+ entries = [
64
+ {
65
+ "type": "session",
66
+ "version": 3,
67
+ "id": "11111111-1111-4111-8111-111111111111",
68
+ "timestamp": "2026-01-01T00:00:00.000Z",
69
+ "cwd": cwd,
70
+ },
71
+ ]
72
+ parent_id = None
73
+ for index, message in enumerate(messages, start=1):
74
+ entry_id = f"m{index:04d}"
75
+ entries.append({
76
+ "type": "message",
77
+ "id": entry_id,
78
+ "parentId": parent_id,
79
+ "timestamp": f"2026-01-01T00:00:{index:02d}.000Z",
80
+ "message": {
81
+ "role": message["role"],
82
+ "content": message["content"],
83
+ "timestamp": 1767225600000 + index * 1000,
84
+ },
85
+ })
86
+ parent_id = entry_id
87
+ with session_path.open('w', encoding='utf-8') as fh:
88
+ for entry in entries:
89
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
90
+ PY
91
+ }
92
+
50
93
  mark_done() {
51
94
  python3 - <<'PY'
52
95
  import json
@@ -1358,7 +1401,253 @@ assert 'Discuss changes in the main chat and rerun /cook.' in output, 'cancel co
1358
1401
  assert not Path('.agent').exists(), 'cancel action should not write canonical workflow state'
1359
1402
  PY
1360
1403
 
1404
+ # Explicit primary-agent handoff: /cook should prefer the structured handoff capsule over broad context re-inference.
1405
+ HANDOFF_ROOT_START="$TMPDIR/handoff-root-start"
1406
+ mkdir -p "$HANDOFF_ROOT_START"
1407
+ cd "$HANDOFF_ROOT_START"
1408
+ git init -q
1409
+
1410
+ HANDOFF_SESSION_START="$TMPDIR/handoff-session-start.jsonl"
1411
+ HANDOFF_SNAPSHOT_START="$TMPDIR/handoff-proposal-start.json"
1412
+ HANDOFF_MESSAGES_START="$(python3 - <<'PY'
1413
+ import json
1414
+ capsule = {
1415
+ "kind": "cook_handoff",
1416
+ "source": "primary_agent",
1417
+ "captured_at": "2026-01-01T00:00:02.000Z",
1418
+ "source_turn_id": "m0002",
1419
+ "mission": "Fix login redirect callback behavior.",
1420
+ "scope": [
1421
+ "Update the callback redirect decision logic.",
1422
+ "Preserve the broader auth flow."
1423
+ ],
1424
+ "constraints": [
1425
+ "Do not refactor the broader auth flow."
1426
+ ],
1427
+ "acceptance": [
1428
+ "Add a regression test for returning to the requested page."
1429
+ ],
1430
+ "risks": [
1431
+ "Stale auth discussion could broaden the startup brief if the handoff is ignored."
1432
+ ],
1433
+ "notes": [
1434
+ "Keep the startup brief aligned with the explicit primary-agent plan."
1435
+ ],
1436
+ "handoff_kind": "implementation_workflow_ready",
1437
+ "task_type": "completion-workflow",
1438
+ "evaluation_profile": "completion-rubric-v1",
1439
+ "why_cook_now": "The implementation plan is concrete and ready for repo changes."
1440
+ }
1441
+ messages = [
1442
+ {"role": "user", "content": "Please think through the login redirect fix and tell me when it is ready for /cook."},
1443
+ {"role": "assistant", "content": "This task is now ready for /cook. Run /cook to confirm the startup brief.\n\n```cook_handoff\n" + json.dumps(capsule, ensure_ascii=False, indent=2) + "\n```"},
1444
+ ]
1445
+ print(json.dumps(messages, ensure_ascii=False))
1446
+ PY
1447
+ )"
1448
+ write_session_messages "$HANDOFF_SESSION_START" "$HANDOFF_ROOT_START" "$HANDOFF_MESSAGES_START"
1449
+
1450
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
1451
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$HANDOFF_SNAPSHOT_START" \
1452
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
1453
+ pi --session "$HANDOFF_SESSION_START" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-handoff-start.out" 2>"$TMPDIR/pi-completion-handoff-start.err"
1454
+
1455
+ python3 - "$HANDOFF_SNAPSHOT_START" <<'PY'
1456
+ import json
1457
+ import sys
1458
+ from pathlib import Path
1459
+
1460
+ snapshot = json.loads(Path(sys.argv[1]).read_text())
1461
+ state = json.loads(Path('.agent/state.json').read_text())
1462
+
1463
+ assert snapshot['source'] == 'handoff_capsule', 'explicit handoff startup should snapshot the handoff capsule as the proposal source'
1464
+ assert snapshot['mission'] == 'Fix login redirect callback behavior.', 'explicit handoff startup should preserve the primary-agent mission'
1465
+ assert state['mission_anchor'] == 'Fix login redirect callback behavior.', 'explicit handoff startup should use the handoff mission as canonical mission_anchor'
1466
+ assert state['advisory_startup_brief']['source'] == 'primary_agent_handoff', 'explicit handoff startup should preserve the advisory intake source'
1467
+ assert state['advisory_startup_brief']['risks'] == ['Stale auth discussion could broaden the startup brief if the handoff is ignored.'], 'explicit handoff startup should preserve handoff risks'
1468
+ assert 'Primary-agent /cook handoff rationale: The implementation plan is concrete and ready for repo changes.' in state['advisory_startup_brief']['notes'], 'explicit handoff startup should preserve why_cook_now as notes'
1469
+ PY
1470
+
1471
+ # Done workflow + fresh handoff: the fresh explicit handoff should override done-state suppression and start the new round.
1472
+ HANDOFF_ROOT_DONE="$TMPDIR/handoff-root-done"
1473
+ mkdir -p "$HANDOFF_ROOT_DONE"
1474
+ cd "$HANDOFF_ROOT_DONE"
1475
+ git init -q
1476
+
1477
+ DONE_SEED_SESSION="$TMPDIR/handoff-done-seed-session.jsonl"
1478
+ DONE_SEED_DISCUSSION=$'Mission: Seed a finished workflow before testing fresh handoff priority.\nScope:\n- Create canonical workflow state.\nConstraints:\n- Keep the seed minimal.\nAcceptance:\n- Mark the workflow done before the next test step.'
1479
+ write_session "$DONE_SEED_SESSION" "$HANDOFF_ROOT_DONE" "$DONE_SEED_DISCUSSION"
1480
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
1481
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
1482
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
1483
+ pi --session "$DONE_SEED_SESSION" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-handoff-done-seed.out" 2>"$TMPDIR/pi-completion-handoff-done-seed.err"
1484
+ mark_done
1485
+
1486
+ HANDOFF_SESSION_DONE="$TMPDIR/handoff-session-done.jsonl"
1487
+ HANDOFF_SNAPSHOT_DONE="$TMPDIR/handoff-proposal-done.json"
1488
+ HANDOFF_MESSAGES_DONE="$(python3 - <<'PY'
1489
+ import json
1490
+ capsule = {
1491
+ "kind": "cook_handoff",
1492
+ "source": "primary_agent",
1493
+ "captured_at": "2026-01-01T00:00:02.000Z",
1494
+ "source_turn_id": "m0002",
1495
+ "mission": "Reopen the login redirect work for the callback edge case.",
1496
+ "scope": [
1497
+ "Handle the callback edge case in the redirect logic.",
1498
+ "Keep the finished workflow as historical context only."
1499
+ ],
1500
+ "constraints": [
1501
+ "Do not turn done-state suppression into the startup mission."
1502
+ ],
1503
+ "acceptance": [
1504
+ "Add a regression test for the callback edge case."
1505
+ ],
1506
+ "risks": [
1507
+ "Done-state context could override the new mission if the handoff is ignored."
1508
+ ],
1509
+ "notes": [
1510
+ "This is a fresh implementation round, not a summary of the finished workflow."
1511
+ ],
1512
+ "handoff_kind": "implementation_workflow_ready",
1513
+ "task_type": "completion-workflow",
1514
+ "evaluation_profile": "completion-rubric-v1",
1515
+ "why_cook_now": "A new implementation-ready edge case was identified after the previous round closed."
1516
+ }
1517
+ messages = [
1518
+ {"role": "user", "content": "The previous round is done, but there is a fresh callback edge case to implement."},
1519
+ {"role": "assistant", "content": "The next round is ready for /cook. Run /cook to confirm this fresh implementation mission.\n\n```cook_handoff\n" + json.dumps(capsule, ensure_ascii=False, indent=2) + "\n```"},
1520
+ ]
1521
+ print(json.dumps(messages, ensure_ascii=False))
1522
+ PY
1523
+ )"
1524
+ write_session_messages "$HANDOFF_SESSION_DONE" "$HANDOFF_ROOT_DONE" "$HANDOFF_MESSAGES_DONE"
1525
+
1526
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
1527
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$HANDOFF_SNAPSHOT_DONE" \
1528
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
1529
+ pi --session "$HANDOFF_SESSION_DONE" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-handoff-done.out" 2>"$TMPDIR/pi-completion-handoff-done.err"
1530
+
1531
+ python3 - "$HANDOFF_SNAPSHOT_DONE" <<'PY'
1532
+ import json
1533
+ import sys
1534
+ from pathlib import Path
1535
+
1536
+ snapshot = json.loads(Path(sys.argv[1]).read_text())
1537
+ state = json.loads(Path('.agent/state.json').read_text())
1538
+
1539
+ assert snapshot['source'] == 'handoff_capsule', 'done-workflow handoff should still use the explicit handoff capsule'
1540
+ assert snapshot['mission'] == 'Reopen the login redirect work for the callback edge case.', 'done-workflow handoff should preserve the fresh mission'
1541
+ assert state['mission_anchor'] == 'Reopen the login redirect work for the callback edge case.', 'done-workflow handoff should override done-state suppression with the fresh mission'
1542
+ assert state['continuation_policy'] == 'continue', 'done-workflow handoff should reopen canonical workflow state for the new round'
1543
+ assert state['advisory_startup_brief']['source'] == 'primary_agent_handoff', 'done-workflow handoff should preserve the handoff advisory source'
1544
+ PY
1545
+
1546
+ # Stale handoff: later discussion should invalidate the older handoff capsule and fall back to the newer discussion mission.
1547
+ HANDOFF_ROOT_STALE="$TMPDIR/handoff-root-stale"
1548
+ mkdir -p "$HANDOFF_ROOT_STALE"
1549
+ cd "$HANDOFF_ROOT_STALE"
1550
+ git init -q
1551
+
1552
+ HANDOFF_SESSION_STALE="$TMPDIR/handoff-session-stale.jsonl"
1553
+ HANDOFF_SNAPSHOT_STALE="$TMPDIR/handoff-proposal-stale.json"
1554
+ HANDOFF_MESSAGES_STALE="$(python3 - <<'PY'
1555
+ import json
1556
+ capsule = {
1557
+ "kind": "cook_handoff",
1558
+ "source": "primary_agent",
1559
+ "captured_at": "2026-01-01T00:00:02.000Z",
1560
+ "source_turn_id": "m0002",
1561
+ "mission": "Fix the original login redirect callback behavior.",
1562
+ "scope": ["Update the original callback redirect logic."],
1563
+ "constraints": ["Do not refactor the auth stack."],
1564
+ "acceptance": ["Add the original callback regression test."],
1565
+ "risks": [],
1566
+ "notes": [],
1567
+ "handoff_kind": "implementation_workflow_ready"
1568
+ }
1569
+ newer_discussion = "Mission: Ship logout redirect consistency instead.\nScope:\n- Update the logout redirect path.\nConstraints:\n- Leave the login callback flow unchanged.\nAcceptance:\n- Add a logout redirect regression test."
1570
+ messages = [
1571
+ {"role": "user", "content": "Please plan the login redirect follow-up."},
1572
+ {"role": "assistant", "content": "Run /cook if you want to start the original follow-up.\n\n```cook_handoff\n" + json.dumps(capsule, ensure_ascii=False, indent=2) + "\n```"},
1573
+ {"role": "user", "content": newer_discussion},
1574
+ ]
1575
+ print(json.dumps(messages, ensure_ascii=False))
1576
+ PY
1577
+ )"
1578
+ write_session_messages "$HANDOFF_SESSION_STALE" "$HANDOFF_ROOT_STALE" "$HANDOFF_MESSAGES_STALE"
1579
+
1580
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
1581
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
1582
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$HANDOFF_SNAPSHOT_STALE" \
1583
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
1584
+ pi --session "$HANDOFF_SESSION_STALE" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-handoff-stale.out" 2>"$TMPDIR/pi-completion-handoff-stale.err"
1585
+
1586
+ python3 - "$HANDOFF_SNAPSHOT_STALE" <<'PY'
1587
+ import json
1588
+ import sys
1589
+ from pathlib import Path
1590
+
1591
+ snapshot = json.loads(Path(sys.argv[1]).read_text())
1592
+ state = json.loads(Path('.agent/state.json').read_text())
1593
+
1594
+ assert snapshot['source'] == 'session', 'stale handoff should fall back to the newer user discussion proposal'
1595
+ assert snapshot['mission'] == 'Ship logout redirect consistency instead.', 'stale handoff fallback should prefer the newer discussion mission'
1596
+ assert state['mission_anchor'] == 'Ship logout redirect consistency instead.', 'stale handoff fallback should not keep the older handoff mission'
1597
+ assert state['advisory_startup_brief']['source'] == 'recent_discussion', 'stale handoff fallback should preserve that the accepted startup came from discussion'
1598
+ PY
1599
+
1600
+ # Negative handoff rationale: a non-startable capsule must not become the startup mission.
1601
+ HANDOFF_ROOT_NEGATIVE="$TMPDIR/handoff-root-negative"
1602
+ mkdir -p "$HANDOFF_ROOT_NEGATIVE"
1603
+ cd "$HANDOFF_ROOT_NEGATIVE"
1604
+ git init -q
1605
+
1606
+ HANDOFF_SESSION_NEGATIVE="$TMPDIR/handoff-session-negative.jsonl"
1607
+ HANDOFF_SNAPSHOT_NEGATIVE="$TMPDIR/handoff-proposal-negative.json"
1608
+ HANDOFF_MESSAGES_NEGATIVE="$(python3 - <<'PY'
1609
+ import json
1610
+ capsule = {
1611
+ "kind": "cook_handoff",
1612
+ "source": "primary_agent",
1613
+ "captured_at": "2026-01-01T00:00:02.000Z",
1614
+ "source_turn_id": "m0002",
1615
+ "mission": "Do not reopen implementation for the finished workflow.",
1616
+ "scope": ["Keep the old workflow closed."],
1617
+ "constraints": ["Do not start repo changes."],
1618
+ "acceptance": ["Explain that the finished workflow should stay closed."],
1619
+ "risks": [],
1620
+ "notes": [],
1621
+ "handoff_kind": "implementation_workflow_ready"
1622
+ }
1623
+ messages = [
1624
+ {"role": "user", "content": "Should we reopen the finished workflow?"},
1625
+ {"role": "assistant", "content": "Do not reopen it directly.\n\n```cook_handoff\n" + json.dumps(capsule, ensure_ascii=False, indent=2) + "\n```"},
1626
+ ]
1627
+ print(json.dumps(messages, ensure_ascii=False))
1628
+ PY
1629
+ )"
1630
+ write_session_messages "$HANDOFF_SESSION_NEGATIVE" "$HANDOFF_ROOT_NEGATIVE" "$HANDOFF_MESSAGES_NEGATIVE"
1631
+
1632
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
1633
+ PI_COMPLETION_TEST_CONTEXT_PROPOSAL_PATH="$HANDOFF_SNAPSHOT_NEGATIVE" \
1634
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
1635
+ pi --session "$HANDOFF_SESSION_NEGATIVE" -e "$PKG_ROOT" -p "/cook" >"$TMPDIR/pi-completion-handoff-negative.out" 2>"$TMPDIR/pi-completion-handoff-negative.err"
1636
+
1637
+ python3 - "$HANDOFF_SNAPSHOT_NEGATIVE" "$TMPDIR/pi-completion-handoff-negative.out" "$TMPDIR/pi-completion-handoff-negative.err" <<'PY'
1638
+ import sys
1639
+ from pathlib import Path
1640
+
1641
+ snapshot = Path(sys.argv[1])
1642
+ output = Path(sys.argv[2]).read_text() + Path(sys.argv[3]).read_text()
1643
+
1644
+ assert not snapshot.exists(), 'negative handoff rationale should not emit a startup proposal snapshot'
1645
+ assert not Path('.agent').exists(), 'negative handoff rationale should fail closed without writing canonical state'
1646
+ assert '/cook failed closed' in output, 'negative handoff rationale should fail closed instead of becoming the startup mission'
1647
+ PY
1648
+
1361
1649
  grep -q 'export async function deriveCookContextProposalFromRecentDiscussion' "$PKG_ROOT/extensions/completion/proposal.ts"
1650
+ grep -q 'export function extractLatestCookHandoffProposal' "$PKG_ROOT/extensions/completion/proposal.ts"
1362
1651
  grep -q 'export function parseContextProposalAnalystOutput' "$PKG_ROOT/extensions/completion/proposal.ts"
1363
1652
  grep -q 'export function buildContextProposalConfirmationLayout' "$PKG_ROOT/extensions/completion/prompt-surfaces.ts"
1364
1653
  grep -q 'export function buildEvaluationRoleContextLines' "$PKG_ROOT/extensions/completion/prompt-surfaces.ts"
@@ -17,17 +17,21 @@ checks = {
17
17
  "README.md": [
18
18
  "`/cook` is the explicit workflow boundary for starting, continuing, refocusing, or beginning the next round of long-running repo work.",
19
19
  "Only explicit `/cook` enters the workflow. Ordinary prompts stay in the main chat and go straight to the primary agent.",
20
- "`/cook` is the canonical workflow boundary and manual entry point",
21
- "Discuss the concrete repo change in the main chat, then run `/cook`",
22
- "The confirmed startup brief is also preserved there as advisory intake for later re-grounding.",
20
+ "If a task has clearly matured into completion-workflow scope, the primary agent should hand you off to `/cook` instead of starting long-running implementation directly in ordinary chat.",
21
+ "That handoff should include an explicit structured `/cook` capsule in the assistant reply so `/cook` can confirm the already-formed mission instead of re-deriving it from broad ambient context.",
22
+ "`/cook` first looks for a fresh explicit primary-agent handoff capsule.",
23
+ "The pre-`/cook` handoff capsule itself is not canonical workflow state. It is only startup intake for `/cook`.",
23
24
  ],
24
25
  "CHANGELOG.md": [
25
- "made `/cook` derive a confirmable startup brief from recent discussion before any canonical workflow rewrite, then preserve the confirmed brief in canonical state as advisory intake for later re-grounding",
26
- "removed inline `/cook` arguments from the shipped entry path again so explicit bare `/cook` is the only public command, and fail closed when recent discussion is insufficient or unreliable",
26
+ "made explicit primary-agent `/cook` handoff the preferred startup-intake path by teaching ordinary-chat handoff turns to emit a structured `cook_handoff` capsule and letting `/cook` prefer that capsule over broad context re-inference when it is fresh and valid",
27
+ "kept context-derived startup as a fallback only, so stale, drifted, or non-startable handoff capsules still fail closed or fall back to recent discussion instead of silently rewriting canonical state",
28
+ "made finished-workflow suppression stay a safety layer instead of a replacement mission when a fresh explicit `/cook` handoff exists, and blocked negative rejection/suppression text from becoming a Startable startup mission",
27
29
  ],
28
- "extensions/completion/index.ts": [
29
- 'description: "/cook workflow: derive a startup brief from recent discussion, then start, continue, refocus, or start the next round from the explicit /cook command"',
30
- '"/cook failed closed because recent discussion did not produce a clear execution-ready startup brief with Mission/Scope/Constraints/Acceptance for concrete repo changes. Clarify the concrete repo changes in the main chat and rerun /cook."',
30
+ "extensions/completion/prompt-surfaces.ts": [
31
+ '"/cook is the only explicit entrypoint into long-running completion workflow."',
32
+ '"When you judge that the task has matured into completion-workflow scope',
33
+ '"Also append one exact fenced block in the same assistant reply using ```cook_handoff ... ``` JSON',
34
+ '"The capsule is startup intake for /cook only: do not present it as canonical .agent state',
31
35
  ],
32
36
  }
33
37
 
@@ -51,6 +51,7 @@ ROOT="$TMPDIR/repo"
51
51
  KICKOFF_PROMPT="$TMPDIR/kickoff-prompt.txt"
52
52
  RESUME_PROMPT="$TMPDIR/resume-prompt.txt"
53
53
  ORDINARY_SYSTEM_REMINDER="$TMPDIR/ordinary-system-reminder.txt"
54
+ ORDINARY_HANDOFF_REMINDER="$TMPDIR/ordinary-handoff-reminder.txt"
54
55
  UNCLEAR_ROUTING_SNAPSHOT="$TMPDIR/active-unclear-routing.json"
55
56
  UNCLEAR_CHOOSER_SNAPSHOT="$TMPDIR/unexpected-existing-workflow-chooser.json"
56
57
  ORDINARY_AUTO_RESUME_PROMPT="$TMPDIR/ordinary-auto-resume-prompt.txt"
@@ -146,23 +147,31 @@ assert f'- task_type: {expected_task_type}' in kickoff, 'kickoff prompt missing
146
147
  assert f'- evaluation_profile: {expected_eval_profile}' in kickoff, 'kickoff prompt missing canonical evaluation_profile'
147
148
  PY
148
149
 
149
- rm -f "$ORDINARY_SYSTEM_REMINDER" "$ORDINARY_AUTO_RESUME_PROMPT"
150
+ rm -f "$ORDINARY_SYSTEM_REMINDER" "$ORDINARY_HANDOFF_REMINDER" "$ORDINARY_AUTO_RESUME_PROMPT"
150
151
  PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
151
152
  PI_COMPLETION_TEST_SYSTEM_REMINDER_PATH="$ORDINARY_SYSTEM_REMINDER" \
153
+ PI_COMPLETION_TEST_COOK_HANDOFF_REMINDER_PATH="$ORDINARY_HANDOFF_REMINDER" \
152
154
  PI_COMPLETION_TEST_AUTO_CONTINUE_ON_SESSION_START=1 \
153
155
  PI_COMPLETION_TEST_AUTO_CONTINUE_PROMPT_PATH="$ORDINARY_AUTO_RESUME_PROMPT" \
154
156
  pi -e "$PKG_ROOT" -p "Summarize the repo briefly." \
155
157
  >"$TMPDIR/pi-completion-smoke-ordinary.out" 2>"$TMPDIR/pi-completion-smoke-ordinary.err"
156
158
 
157
- python3 - "$TMPDIR/pi-completion-smoke-ordinary.out" "$TMPDIR/pi-completion-smoke-ordinary.err" "$ORDINARY_SYSTEM_REMINDER" "$ORDINARY_AUTO_RESUME_PROMPT" <<'PY'
159
+ python3 - "$TMPDIR/pi-completion-smoke-ordinary.out" "$TMPDIR/pi-completion-smoke-ordinary.err" "$ORDINARY_SYSTEM_REMINDER" "$ORDINARY_HANDOFF_REMINDER" "$ORDINARY_AUTO_RESUME_PROMPT" <<'PY'
158
160
  import sys
159
161
  from pathlib import Path
160
162
 
161
163
  output = Path(sys.argv[1]).read_text() + Path(sys.argv[2]).read_text()
162
164
  reminder = Path(sys.argv[3])
163
- auto_resume = Path(sys.argv[4])
165
+ handoff = Path(sys.argv[4])
166
+ auto_resume = Path(sys.argv[5])
164
167
 
165
168
  assert not reminder.exists(), 'ordinary non-/cook turn should not inject completion reminder solely from canonical state'
169
+ assert handoff.exists(), 'ordinary non-/cook turn should inject the /cook handoff boundary reminder'
170
+ handoff_text = handoff.read_text()
171
+ assert '/cook is the only explicit entrypoint into long-running completion workflow.' in handoff_text, 'ordinary handoff reminder should preserve the explicit /cook workflow boundary'
172
+ assert 'stop short of long-running implementation and tell the user to run /cook.' in handoff_text, 'ordinary handoff reminder should require primary-agent handoff before implementation'
173
+ assert '```cook_handoff ... ``` JSON' in handoff_text, 'ordinary handoff reminder should require the explicit structured /cook handoff capsule'
174
+ assert 'The capsule is startup intake for /cook only' in handoff_text, 'ordinary handoff reminder should keep the capsule non-canonical'
166
175
  assert not auto_resume.exists(), 'ordinary non-/cook turn should not queue auto-resume before /cook activation'
167
176
  assert 'Skipped completion workflow auto-resume prompt (test mode)' not in output, 'ordinary non-/cook turn should not attempt auto-resume'
168
177
  PY
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: cook-handoff-boundary
3
+ description: Ordinary-chat boundary contract for deciding when a task has matured enough that the primary agent must stop short of long-running implementation and hand the user off to `/cook`.
4
+ ---
5
+
6
+ # /cook Handoff Boundary
7
+
8
+ Load or summarize this contract when the primary agent is operating in ordinary main chat before the user has explicitly entered `/cook`.
9
+
10
+ This skill governs the boundary between:
11
+
12
+ - ordinary main-chat discussion, clarification, and proposal work
13
+ - explicit transition into long-running completion workflow through `/cook`
14
+
15
+ ## Core Contract
16
+
17
+ - Ordinary chat may be used to clarify requirements, discuss tradeoffs, and propose implementation approaches.
18
+ - `/cook` is the only explicit entrypoint into long-running completion workflow.
19
+ - When the primary agent judges that a task has matured into completion-workflow scope, it must stop short of implementation and direct the user to `/cook`.
20
+
21
+ ## When To Hand Off To `/cook`
22
+
23
+ The primary agent should consider `/cook` handoff appropriate when one or more of the following are true:
24
+
25
+ - the user has clearly shifted from exploration into implementation intent
26
+ - the agent has just produced a concrete plan or proposal whose natural next step would be implementation
27
+ - the task spans multiple files, steps, or verification surfaces
28
+ - the task needs resumability, review, audit, or canonical workflow state
29
+ - the task is better treated as a long-running repo mission than a one-off answer or tiny fix
30
+
31
+ ## Required Handoff Behavior
32
+
33
+ When the task is judged ready for completion workflow, the primary agent must:
34
+
35
+ - stop before long-running implementation
36
+ - not edit tracked product files in ordinary chat for that workflow-level task
37
+ - tell the user to run `/cook`
38
+ - explain that `/cook` will first look for a fresh explicit primary-agent handoff and otherwise fall back to recent discussion before asking for confirmation
39
+ - append one exact structured `/cook` handoff capsule in the same assistant reply
40
+
41
+ Required capsule format:
42
+
43
+ ````text
44
+ ```cook_handoff
45
+ {
46
+ "kind": "cook_handoff",
47
+ "source": "primary_agent",
48
+ "captured_at": "<ISO-8601 timestamp>",
49
+ "source_turn_id": "<current assistant turn id>",
50
+ "mission": "<startable implementation mission>",
51
+ "scope": ["..."],
52
+ "constraints": ["..."],
53
+ "acceptance": ["..."],
54
+ "risks": ["..."],
55
+ "notes": ["..."],
56
+ "handoff_kind": "implementation_workflow_ready",
57
+ "task_type": "completion-workflow",
58
+ "evaluation_profile": "completion-rubric-v1",
59
+ "why_cook_now": "<why the task is ready for /cook now>"
60
+ }
61
+ ```
62
+ ````
63
+
64
+ Notes:
65
+
66
+ - `constraints` may be replaced or supplemented by `non_goals` when clearer.
67
+ - The mission must be positively startable implementation work; do not use rejection or suppression text as the mission.
68
+ - The capsule is startup intake for `/cook` only. It is not canonical `.agent/**` state, not active-slice state, and not a second repo contract source.
69
+
70
+ Suggested wording:
71
+
72
+ > This task is now mature enough for the `/cook` workflow. If you want me to start implementation, run `/cook`. I’ve also attached an explicit `/cook` handoff capsule so `/cook` can confirm this plan directly before the workflow begins.
73
+
74
+ A short recap may include mission, scope, or acceptance, but that recap must not be presented as canonical plan state.
75
+
76
+ ## Forbidden Behaviors
77
+
78
+ Once the task is judged ready for completion workflow, the primary agent must not:
79
+
80
+ - directly begin long-running implementation in ordinary chat
81
+ - modify tracked product files as part of that workflow-level task
82
+ - act as though `/cook` had already been invoked
83
+ - silently rewrite ordinary-chat discussion into active workflow state
84
+
85
+ ## Relationship To `completion-protocol`
86
+
87
+ This skill is only about pre-`/cook` ordinary-chat handoff behavior.
88
+
89
+ After the user explicitly enters `/cook`, the separate `completion-protocol` skill governs:
90
+
91
+ - canonical `.agent/**` workflow state
92
+ - workflow-driver behavior
93
+ - mandatory completion-role dispatch
94
+ - review, audit, and stop-wave rules