@linimin/pi-letscook 0.1.56 → 0.1.58

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.
@@ -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,48 @@ 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_handoff";
63
+ first_slice_goal: string;
64
+ first_slice_non_goals: string[];
65
+ implementation_surfaces: string[];
66
+ verification_commands: string[];
67
+ why_this_slice_first: string;
68
+ task_type?: string;
69
+ evaluation_profile?: string;
70
+ why_cook_now?: string;
71
+ };
72
+
73
+ export type CookHandoffProposalAssessment =
74
+ | {
75
+ status: "none";
76
+ }
77
+ | {
78
+ status: "startable";
79
+ proposal: ContextProposal;
80
+ }
81
+ | {
82
+ status: "fresh_but_not_startable";
83
+ message: string;
84
+ };
85
+
44
86
  export type ContextProposalDecision = {
45
87
  missionAnchor: string;
46
88
  goalText: string;
@@ -113,6 +155,10 @@ function localAsStringArray(value: unknown): string[] {
113
155
  : [];
114
156
  }
115
157
 
158
+ function localAsNumber(value: unknown): number | undefined {
159
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
160
+ }
161
+
116
162
  function localIsRecord(value: unknown): value is JsonRecord {
117
163
  return typeof value === "object" && value !== null && !Array.isArray(value);
118
164
  }
@@ -300,11 +346,12 @@ export function missionAnchorsLikelyEquivalent(left: string, right: string): boo
300
346
  return missionAnchorBigramOverlapRatio(leftTokens, rightTokens) >= 0.5;
301
347
  }
302
348
 
303
- export function collectRecentDiscussionEntries(ctx: { sessionManager: any }, deps: {
349
+ export function collectRecentSessionMessages(ctx: { sessionManager: any }, deps: {
304
350
  isRecord: (value: unknown) => boolean;
305
351
  asString?: (value: unknown) => string | undefined;
352
+ asNumber?: (value: unknown) => number | undefined;
306
353
  isStaleContextError?: (error: unknown) => boolean;
307
- }, limit = 8): RecentDiscussionEntry[] {
354
+ }, limit = 24): RecentSessionMessage[] {
308
355
  let branch: any[] = [];
309
356
  try {
310
357
  branch = ctx.sessionManager?.getBranch?.() ?? [];
@@ -313,27 +360,42 @@ export function collectRecentDiscussionEntries(ctx: { sessionManager: any }, dep
313
360
  throw error;
314
361
  }
315
362
  const asStringValue = deps.asString ?? localAsString;
316
- const entries: RecentDiscussionEntry[] = [];
363
+ const asNumberValue = deps.asNumber ?? localAsNumber;
364
+ const entries: RecentSessionMessage[] = [];
317
365
  for (let index = branch.length - 1; index >= 0; index -= 1) {
318
366
  const entry = branch[index];
319
367
  if (!deps.isRecord(entry) || entry.type !== "message" || !deps.isRecord(entry.message)) continue;
320
368
  const message = entry.message as JsonRecord;
321
- let text = "";
322
- let role: RecentDiscussionEntry["role"] | undefined;
323
369
  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;
370
+ if (messageRole !== "user" && messageRole !== "assistant" && messageRole !== "custom" && messageRole !== "summary") continue;
371
+ const text = extractTextFromMessageContent(message.content);
372
+ if (!text) continue;
329
373
  const trimmed = text.trim();
330
- if (!trimmed || /^\/(?:cook|complete)\b/i.test(trimmed)) continue;
331
- entries.push({ role, text: trimmed });
374
+ if (!trimmed) continue;
375
+ entries.push({
376
+ role: messageRole as RecentDiscussionEntry["role"],
377
+ text: trimmed,
378
+ messageId: asStringValue((entry as JsonRecord).id),
379
+ timestampMs: asNumberValue(message.timestamp),
380
+ isCommand: /^\//.test(trimmed),
381
+ });
332
382
  if (entries.length >= limit) break;
333
383
  }
334
384
  return entries;
335
385
  }
336
386
 
387
+ export function collectRecentDiscussionEntries(ctx: { sessionManager: any }, deps: {
388
+ isRecord: (value: unknown) => boolean;
389
+ asString?: (value: unknown) => string | undefined;
390
+ asNumber?: (value: unknown) => number | undefined;
391
+ isStaleContextError?: (error: unknown) => boolean;
392
+ }, limit = 8): RecentDiscussionEntry[] {
393
+ return collectRecentSessionMessages(ctx, deps, Math.max(limit * 3, limit))
394
+ .filter((entry) => (entry.role === "user" || entry.role === "custom") && !entry.isCommand)
395
+ .slice(0, limit)
396
+ .map((entry) => ({ role: entry.role, text: entry.text }));
397
+ }
398
+
337
399
  export function serializeRecentDiscussionEntries(entries: RecentDiscussionEntry[]): string {
338
400
  return entries
339
401
  .slice()
@@ -1182,6 +1244,270 @@ export function extractContextProposalFromStructuredSession(
1182
1244
  return parseStrictStructuredSessionProposal(structuredTexts.join("\n\n"), projectName, deps);
1183
1245
  }
1184
1246
 
1247
+ const COOK_HANDOFF_BLOCK_REGEX = /```cook_handoff\s*([\s\S]*?)```/giu;
1248
+ const COOK_HANDOFF_MAX_AGE_MS = 45 * 60 * 1000;
1249
+ const COOK_HANDOFF_MAX_LATER_NON_COMMAND_MESSAGES = 2;
1250
+ const COOK_HANDOFF_NEGATIVE_MISSION_REGEX =
1251
+ /(?:\b(?:do not|don't|dont|not|never|avoid|skip|refuse|recognize that|suppress|ignore|block|prevent)\b|(?:不要|別|别|勿|禁止|避免|忽略|阻止))/iu;
1252
+ const COOK_HANDOFF_WORKFLOW_ONLY_ACCEPTANCE_REGEX =
1253
+ /(?:\b(?:confirm|discuss|clarify|decide|review|align(?: on)?|agree(?: on)?|explain|summari(?:s|z)e|describe|plan|proposal|spec(?:ification)?|design(?: doc(?:ument)?)?|next step|handoff|workflow|readiness)\b|(?:確認|确认|討論|讨论|釐清|厘清|決定|决定|審查|审查|對齊|对齐|同意|说明|說明|總結|总结|描述|規劃|规划|提案|方案|工作流|就緒|就绪))/iu;
1254
+ const COOK_HANDOFF_VERIFICATION_ACCEPTANCE_REGEX =
1255
+ /(?:\b(?:test|tests|testing|verify|verification|validated?|regression|coverage|assert(?:ion)?s?|check|checks|smoke|snapshot(?:s)?)\b|(?:測試|测试|驗證|验证|回歸|回归|覆蓋|覆盖|斷言|断言|檢查|检查|快照))/iu;
1256
+ const COOK_HANDOFF_VERIFICATION_ACTION_REGEX =
1257
+ /(?:\b(?:add|update|keep|run|rerun|cover|verify|validate|check|assert|exercise|prove)\b|(?:新增|更新|保持|執行|执行|重跑|覆蓋|覆盖|驗證|验证|檢查|检查|斷言|断言|證明|证明))/iu;
1258
+
1259
+ function parseCookHandoffCapsulesFromText(
1260
+ text: string,
1261
+ messageId: string | undefined,
1262
+ timestampMs: number | undefined,
1263
+ deps: Pick<ProposalParseDeps, "asString" | "asStringArray">,
1264
+ ): CookHandoffCapsule[] {
1265
+ const capsules: CookHandoffCapsule[] = [];
1266
+ for (const match of text.matchAll(COOK_HANDOFF_BLOCK_REGEX)) {
1267
+ const rawJson = deps.asString(match[1]);
1268
+ if (!rawJson) continue;
1269
+ let parsed: unknown;
1270
+ try {
1271
+ parsed = JSON.parse(rawJson);
1272
+ } catch {
1273
+ continue;
1274
+ }
1275
+ if (!localIsRecord(parsed)) continue;
1276
+ if (deps.asString(parsed.kind) !== "cook_handoff") continue;
1277
+ if (deps.asString(parsed.source) !== "primary_agent") continue;
1278
+ if (deps.asString(parsed.handoff_kind) !== "implementation_workflow_handoff") continue;
1279
+ const mission = deps.asString(parsed.mission);
1280
+ const firstSliceGoal = deps.asString(parsed.first_slice_goal ?? parsed.firstSliceGoal);
1281
+ const whyThisSliceFirst = deps.asString(parsed.why_this_slice_first ?? parsed.whyThisSliceFirst);
1282
+ if (!mission || !firstSliceGoal || !whyThisSliceFirst) continue;
1283
+ const scope = deps.asStringArray(parsed.scope);
1284
+ const constraints = deps.asStringArray(parsed.constraints);
1285
+ const nonGoals = deps.asStringArray(parsed.non_goals ?? parsed.nonGoals);
1286
+ const acceptance = deps.asStringArray(parsed.acceptance);
1287
+ const risks = deps.asStringArray(parsed.risks);
1288
+ const notes = deps.asStringArray(parsed.notes);
1289
+ const firstSliceNonGoals = deps.asStringArray(parsed.first_slice_non_goals ?? parsed.firstSliceNonGoals);
1290
+ const implementationSurfaces = deps.asStringArray(parsed.implementation_surfaces ?? parsed.implementationSurfaces);
1291
+ const verificationCommands = deps.asStringArray(parsed.verification_commands ?? parsed.verificationCommands);
1292
+ const capturedAt = deps.asString(parsed.captured_at) ?? (timestampMs ? new Date(timestampMs).toISOString() : undefined);
1293
+ const sourceTurnId = deps.asString(parsed.source_turn_id) ?? messageId;
1294
+ if (!capturedAt || !sourceTurnId) continue;
1295
+ capsules.push({
1296
+ kind: "cook_handoff",
1297
+ source: "primary_agent",
1298
+ captured_at: capturedAt,
1299
+ source_turn_id: sourceTurnId,
1300
+ mission,
1301
+ scope,
1302
+ constraints,
1303
+ non_goals: nonGoals,
1304
+ acceptance,
1305
+ risks,
1306
+ notes,
1307
+ handoff_kind: "implementation_workflow_handoff",
1308
+ first_slice_goal: firstSliceGoal,
1309
+ first_slice_non_goals: firstSliceNonGoals,
1310
+ implementation_surfaces: implementationSurfaces,
1311
+ verification_commands: verificationCommands,
1312
+ why_this_slice_first: whyThisSliceFirst,
1313
+ task_type: deps.asString(parsed.task_type),
1314
+ evaluation_profile: deps.asString(parsed.evaluation_profile),
1315
+ why_cook_now: deps.asString(parsed.why_cook_now),
1316
+ });
1317
+ }
1318
+ return capsules;
1319
+ }
1320
+
1321
+ function buildCookHandoffBasisPreview(capsule: CookHandoffCapsule): string {
1322
+ const parts = [
1323
+ capsule.mission,
1324
+ ...capsule.scope,
1325
+ ...capsule.constraints,
1326
+ ...capsule.non_goals,
1327
+ ...capsule.acceptance,
1328
+ `first_slice_goal: ${capsule.first_slice_goal}`,
1329
+ ...capsule.first_slice_non_goals.map((item) => `first_slice_non_goals: ${item}`),
1330
+ ...capsule.implementation_surfaces.map((item) => `implementation_surfaces: ${item}`),
1331
+ ...capsule.verification_commands.map((item) => `verification_commands: ${item}`),
1332
+ `why_this_slice_first: ${capsule.why_this_slice_first}`,
1333
+ ];
1334
+ if (capsule.why_cook_now) parts.push(`why_cook_now: ${capsule.why_cook_now}`);
1335
+ return parts.join("\n").trim();
1336
+ }
1337
+
1338
+ function cookHandoffAcceptanceItemIsRepoChangeOrVerificationOriented(item: string): boolean {
1339
+ const normalized = normalizeProposalLine(item);
1340
+ if (!normalized) return false;
1341
+ if (hasExplicitPlanningOnlyDeliverable([normalized])) return false;
1342
+ if (hasClearNoImplementationSignal([normalized])) return false;
1343
+ if (implementationMissionSourceCandidateText(normalized)) return true;
1344
+ if (COOK_HANDOFF_WORKFLOW_ONLY_ACCEPTANCE_REGEX.test(normalized)) return false;
1345
+ return COOK_HANDOFF_VERIFICATION_ACCEPTANCE_REGEX.test(normalized) && COOK_HANDOFF_VERIFICATION_ACTION_REGEX.test(normalized);
1346
+ }
1347
+
1348
+ function cookHandoffAcceptanceIsRepoChangeOriented(capsule: CookHandoffCapsule): boolean {
1349
+ if (capsule.acceptance.length === 0) return false;
1350
+ return capsule.acceptance.some((item) => cookHandoffAcceptanceItemIsRepoChangeOrVerificationOriented(item));
1351
+ }
1352
+
1353
+ function cookHandoffStartabilityFailures(
1354
+ capsule: CookHandoffCapsule,
1355
+ deps: Pick<ProposalParseDeps, "normalizeMissionAnchorText" | "isWeakMissionAnchor">,
1356
+ ): string[] {
1357
+ const failures: string[] = [];
1358
+ const mission = deps.normalizeMissionAnchorText(capsule.mission);
1359
+ if (!mission || deps.isWeakMissionAnchor(mission)) failures.push("mission is missing a concrete implementation anchor");
1360
+ else if (COOK_HANDOFF_NEGATIVE_MISSION_REGEX.test(mission)) failures.push("mission is negative or workflow-suppression-only");
1361
+ if (capsule.scope.length === 0) failures.push("scope is empty");
1362
+ if (capsule.acceptance.length === 0) failures.push("acceptance is empty");
1363
+ else if (!cookHandoffAcceptanceIsRepoChangeOriented(capsule)) {
1364
+ failures.push("acceptance is not anchored to concrete repo changes or verification");
1365
+ }
1366
+ const firstSliceGoal = deps.normalizeMissionAnchorText(capsule.first_slice_goal);
1367
+ if (!firstSliceGoal || deps.isWeakMissionAnchor(firstSliceGoal) || COOK_HANDOFF_NEGATIVE_MISSION_REGEX.test(firstSliceGoal)) {
1368
+ failures.push("first_slice_goal is not a bounded implementation slice");
1369
+ } else if (hasExplicitPlanningOnlyDeliverable([capsule.first_slice_goal]) || hasClearNoImplementationSignal([capsule.first_slice_goal])) {
1370
+ failures.push("first_slice_goal is planning-only instead of a repo-change slice");
1371
+ }
1372
+ if (capsule.implementation_surfaces.length === 0) failures.push("implementation_surfaces is empty");
1373
+ if (capsule.verification_commands.length === 0) failures.push("verification_commands is empty");
1374
+ return failures;
1375
+ }
1376
+
1377
+ function buildNonStartableCookHandoffMessage(failures: string[]): string {
1378
+ return [
1379
+ "/cook failed closed because a fresh explicit primary-agent handoff exists, but it is not concrete enough to start implementation workflow yet.",
1380
+ "Tighten the handoff in the main chat so it names a bounded first implementation slice, repo-change-oriented acceptance, implementation_surfaces, and verification_commands, then rerun /cook.",
1381
+ `Blocking details: ${failures.join("; ")}.`,
1382
+ ].join(" ");
1383
+ }
1384
+
1385
+ function isStartableCookHandoffCapsule(
1386
+ capsule: CookHandoffCapsule,
1387
+ deps: Pick<ProposalParseDeps, "normalizeMissionAnchorText" | "isWeakMissionAnchor">,
1388
+ ): boolean {
1389
+ return cookHandoffStartabilityFailures(capsule, deps).length === 0;
1390
+ }
1391
+
1392
+ function laterMessagesInvalidateCookHandoff(
1393
+ laterMessages: RecentSessionMessage[],
1394
+ deps: Pick<ProposalParseDeps, "stripCodeBlocks">,
1395
+ ): boolean {
1396
+ const laterNonCommandMessages = laterMessages.filter((entry) => !entry.isCommand);
1397
+ if (laterNonCommandMessages.length > COOK_HANDOFF_MAX_LATER_NON_COMMAND_MESSAGES) return true;
1398
+ return laterNonCommandMessages.some((entry) => {
1399
+ if (entry.role === "summary") return false;
1400
+ if (!hasRecentDiscussionImplementationIntent(entry.text, deps.stripCodeBlocks)) return false;
1401
+ return true;
1402
+ });
1403
+ }
1404
+
1405
+ function cookHandoffIsFreshEnough(capsule: CookHandoffCapsule, laterMessages: RecentSessionMessage[]): boolean {
1406
+ const capturedAtMs = Date.parse(capsule.captured_at);
1407
+ if (!Number.isFinite(capturedAtMs)) return false;
1408
+ const laterTimestamps = laterMessages.map((entry) => entry.timestampMs).filter((value): value is number => value !== undefined);
1409
+ if (laterTimestamps.length === 0) return true;
1410
+ return Math.max(...laterTimestamps) - capturedAtMs <= COOK_HANDOFF_MAX_AGE_MS;
1411
+ }
1412
+
1413
+ function buildContextProposalFromCookHandoffCapsule(
1414
+ capsule: CookHandoffCapsule,
1415
+ projectName: string,
1416
+ deps: ProposalParseDeps,
1417
+ ): ContextProposal | undefined {
1418
+ if (!isStartableCookHandoffCapsule(capsule, deps)) return undefined;
1419
+ const constraints = uniqueProposalItems([...capsule.constraints, ...capsule.non_goals]);
1420
+ const mission = deps.assessMissionAnchor(capsule.mission, projectName).derived;
1421
+ const goalText = buildContextProposalGoalText({
1422
+ mission,
1423
+ scope: capsule.scope,
1424
+ constraints,
1425
+ acceptance: capsule.acceptance,
1426
+ });
1427
+ const proposal: ContextProposal = {
1428
+ mission,
1429
+ scope: [...capsule.scope],
1430
+ constraints,
1431
+ acceptance: [...capsule.acceptance],
1432
+ analysis: finalizeContextProposalAnalysis(
1433
+ {
1434
+ taskType: capsule.task_type,
1435
+ evaluationProfile: capsule.evaluation_profile,
1436
+ critique: [
1437
+ ...capsule.notes,
1438
+ `First slice goal: ${capsule.first_slice_goal}`,
1439
+ ...(capsule.first_slice_non_goals.length > 0 ? [`First slice non-goals: ${capsule.first_slice_non_goals.join(" | ")}`] : []),
1440
+ ...(capsule.implementation_surfaces.length > 0 ? [`Implementation surfaces: ${capsule.implementation_surfaces.join(" | ")}`] : []),
1441
+ ...(capsule.verification_commands.length > 0 ? [`Verification commands: ${capsule.verification_commands.join(" | ")}`] : []),
1442
+ `Why this slice first: ${capsule.why_this_slice_first}`,
1443
+ ...(capsule.why_cook_now ? [`Primary-agent /cook handoff rationale: ${capsule.why_cook_now}`] : []),
1444
+ ],
1445
+ risks: capsule.risks,
1446
+ possibleNoise: [],
1447
+ alternateMissions: [],
1448
+ suppressedCompletedTopics: [],
1449
+ suppressedNegatedTopics: [],
1450
+ },
1451
+ [
1452
+ mission,
1453
+ goalText,
1454
+ capsule.mission,
1455
+ ...capsule.scope,
1456
+ ...constraints,
1457
+ ...capsule.acceptance,
1458
+ capsule.first_slice_goal,
1459
+ ...capsule.first_slice_non_goals,
1460
+ ...capsule.implementation_surfaces,
1461
+ ...capsule.verification_commands,
1462
+ capsule.why_this_slice_first,
1463
+ ],
1464
+ ),
1465
+ goalText,
1466
+ basisPreview: buildCookHandoffBasisPreview(capsule),
1467
+ source: "handoff_capsule",
1468
+ alternateProposals: [],
1469
+ };
1470
+ return finalizeContextProposal(proposal, projectName, deps);
1471
+ }
1472
+
1473
+ export function assessLatestCookHandoffProposal(
1474
+ recentMessages: RecentSessionMessage[],
1475
+ projectName: string,
1476
+ deps: ProposalParseDeps,
1477
+ ): CookHandoffProposalAssessment {
1478
+ for (let index = 0; index < recentMessages.length; index += 1) {
1479
+ const entry = recentMessages[index];
1480
+ if (entry.role !== "assistant" || entry.isCommand) continue;
1481
+ const capsules = parseCookHandoffCapsulesFromText(entry.text, entry.messageId, entry.timestampMs, deps);
1482
+ if (capsules.length === 0) continue;
1483
+ for (let capsuleIndex = capsules.length - 1; capsuleIndex >= 0; capsuleIndex -= 1) {
1484
+ const capsule = capsules[capsuleIndex];
1485
+ const laterMessages = recentMessages.slice(0, index);
1486
+ if (!cookHandoffIsFreshEnough(capsule, laterMessages)) continue;
1487
+ if (laterMessagesInvalidateCookHandoff(laterMessages, deps)) continue;
1488
+ const failures = cookHandoffStartabilityFailures(capsule, deps);
1489
+ if (failures.length > 0) {
1490
+ return {
1491
+ status: "fresh_but_not_startable",
1492
+ message: buildNonStartableCookHandoffMessage(failures),
1493
+ };
1494
+ }
1495
+ const proposal = buildContextProposalFromCookHandoffCapsule(capsule, projectName, deps);
1496
+ if (proposal) return { status: "startable", proposal };
1497
+ }
1498
+ }
1499
+ return { status: "none" };
1500
+ }
1501
+
1502
+ export function extractLatestCookHandoffProposal(
1503
+ recentMessages: RecentSessionMessage[],
1504
+ projectName: string,
1505
+ deps: ProposalParseDeps,
1506
+ ): ContextProposal | undefined {
1507
+ const assessment = assessLatestCookHandoffProposal(recentMessages, projectName, deps);
1508
+ return assessment.status === "startable" ? assessment.proposal : undefined;
1509
+ }
1510
+
1185
1511
  export async function deriveCookContextProposalFromRecentDiscussion(
1186
1512
  projectName: string,
1187
1513
  recentEntries: RecentDiscussionEntry[],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linimin/pi-letscook",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
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,