@agent-plan/pi-adapter 0.2.19-next.0 → 0.2.19-next.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,iCAAiC,CAAC;AAuzBtF,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAg7F9D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,iCAAiC,CAAC;AA0tBtF,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CA+6F9D"}
package/dist/index.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * - inject context into prompts
10
10
  */
11
11
  import { Type } from "typebox";
12
- import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation, findPhaseByRef } from "@agent-plan/core";
13
- import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef } from "@agent-plan/core/naming";
12
+ import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, migrateToUuids, withFeatureLock, needsMotivation, findPhaseByRef, buildRecap } from "@agent-plan/core";
13
+ import { createChecklistItemId, createFeatureId, createPhaseId, createShortId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase } from "@agent-plan/core/naming";
14
14
  import { join, dirname } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -227,106 +227,6 @@ function phaseLabel(phase) {
227
227
  function taskLabel(task) {
228
228
  return `T${formatSequence(task.number)} — ${task.title}`;
229
229
  }
230
- async function buildStartupResumeSummary(st) {
231
- const [plan, resume, handoffs] = await Promise.all([
232
- st.loadAll(),
233
- st.refreshResume(),
234
- st.listHandoffs(),
235
- ]);
236
- const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
237
- const orderedPhases = [
238
- ...plan.features.features.flatMap((feature) => {
239
- const linked = feature.phaseIds.map((id) => phaseById.get(id)).filter((phase) => Boolean(phase));
240
- const linkedIds = new Set(linked.map((phase) => phase.id));
241
- const inferred = plan.phases
242
- .filter((phase) => phase.featureId === feature.id && !linkedIds.has(phase.id))
243
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt));
244
- return [...linked, ...inferred];
245
- }),
246
- ...plan.phases
247
- .filter((phase) => !phase.featureId)
248
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt)),
249
- ];
250
- const allTasks = orderedPhases.flatMap((phase) => [...phase.tasks]
251
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
252
- .map((task) => ({ phase, task })));
253
- const totalFeatures = plan.features.features.length;
254
- const doneFeatures = plan.features.features.filter((feature) => feature.status === "done").length;
255
- const activeFeatures = plan.features.features.filter((feature) => feature.status === "in-progress").length;
256
- const totalPhases = plan.phases.length;
257
- const donePhases = plan.phases.filter((phase) => phase.status === "done").length;
258
- const activePhases = plan.phases.filter((phase) => phase.status === "in-progress" || phase.status === "discovery").length;
259
- const totalTasks = allTasks.length;
260
- const doneTasks = allTasks.filter(({ task }) => task.status === "done").length;
261
- const activeTasks = allTasks.filter(({ task }) => task.status === "in-progress").length;
262
- const hasActiveWork = activeFeatures > 0 || activePhases > 0 || activeTasks > 0;
263
- const currentPhase = hasActiveWork
264
- ? orderedPhases.find((phase) => phase.id === resume.currentPhaseId && (phase.status === "in-progress" || phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id))))
265
- ?? orderedPhases.find((phase) => phase.tasks.some((task) => resume.inProgressTaskIds.includes(task.id)))
266
- ?? orderedPhases.find((phase) => phase.status === "in-progress")
267
- ?? null
268
- : null;
269
- const currentTask = hasActiveWork
270
- ? ([...(currentPhase?.tasks ?? [])]
271
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
272
- .find((task) => resume.inProgressTaskIds.includes(task.id))
273
- ?? [...(currentPhase?.tasks ?? [])]
274
- .sort((left, right) => left.number - right.number || left.createdAt.localeCompare(right.createdAt))
275
- .find((task) => task.status === "in-progress")
276
- ?? allTasks.find(({ task }) => task.status === "in-progress")?.task
277
- ?? null)
278
- : null;
279
- const currentFeature = hasActiveWork
280
- ? (currentPhase?.featureId
281
- ? plan.features.features.find((feature) => feature.id === currentPhase.featureId) ?? null
282
- : plan.features.features.find((feature) => feature.status === "in-progress") ?? null)
283
- : null;
284
- const nextActivity = hasActiveWork
285
- ? (resume.nextSteps[0]
286
- ?? (currentTask
287
- ? `riprendere il task ${currentTask.id} — ${taskLabel(currentTask)}`
288
- : currentPhase
289
- ? `riprendere la fase ${currentPhase.id} — ${phaseLabel(currentPhase)}`
290
- : "riprendere il lavoro attivo corrente"))
291
- : (handoffs.length > 0
292
- ? "leggere l'handoff di fase più recente (handoff show <ref>) e verificarne ordine e stato rispetto al piano prima di scegliere il prossimo task"
293
- : "rivedere il piano e scegliere il prossimo task concreto");
294
- const chatLanguage = (plan.project.chatLanguage || "").toLowerCase();
295
- const italian = chatLanguage.includes("ital");
296
- const localUrl = server?.localUrl ?? server?.url ?? "";
297
- const lanUrl = server?.lanUrl ?? "";
298
- const webUrl = lanUrl ? `${localUrl} (LAN: ${lanUrl})` : localUrl;
299
- if (italian || !chatLanguage) {
300
- return [
301
- "### Summary di ripresa planner",
302
- `- Progresso: ${doneFeatures}/${totalFeatures} feature completate (${activeFeatures} attive), ${donePhases}/${totalPhases} fasi completate (${activePhases} attive), ${doneTasks}/${totalTasks} task completati (${activeTasks} attivi).`,
303
- currentFeature ? `- Focus feature: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Focus feature: nessuna feature attiva chiara.",
304
- currentPhase ? `- Focus fase: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Focus fase: nessuna fase attiva chiara.",
305
- currentTask ? `- Focus task: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Focus task: nessun task attivo in questo momento.",
306
- handoffs.length > 0 ? `- Handoff di fase pendenti (${handoffs.length}):\n${handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`).join("\n")}` : "- Nessun handoff di fase pendente.",
307
- !hasActiveWork && handoffs.length > 0 ? "- Nota: l'handoff di fase è un suggerimento della sessione precedente; con 0 task/fasi attivi va validato contro stato e dipendenze correnti prima di riprendere un task specifico." : "",
308
- `- Prossima attività consigliata: ${nextActivity}.`,
309
- "",
310
- "Vuoi che riprendiamo da qui?",
311
- "",
312
- webUrl ? `🌐 Web UI: ${webUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}` : "🌐 Web UI: not running — avvia con /planner load",
313
- ].filter(Boolean).join("\n");
314
- }
315
- return [
316
- "### Planner resume summary",
317
- `- Progress: ${doneFeatures}/${totalFeatures} features done (${activeFeatures} active), ${donePhases}/${totalPhases} phases done (${activePhases} active), ${doneTasks}/${totalTasks} tasks done (${activeTasks} active).`,
318
- currentFeature ? `- Feature focus: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Feature focus: no clear active feature.",
319
- currentPhase ? `- Phase focus: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Phase focus: no clear active phase.",
320
- currentTask ? `- Task focus: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Task focus: no active task right now.",
321
- handoffs.length > 0 ? `- Pending phase handoffs (${handoffs.length}):\n${handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`).join("\n")}` : "- No pending phase handoffs.",
322
- !hasActiveWork && handoffs.length > 0 ? "- Note: the phase handoff is only a previous-session hint; with 0 active tasks/phases it must be validated against current planner state and dependencies before resuming a specific task." : "",
323
- `- Recommended next activity: ${nextActivity}.`,
324
- "",
325
- "Do you want to resume from here?",
326
- "",
327
- webUrl ? `🌐 Web UI: ${webUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}` : "🌐 Web UI: not running — start with /planner load",
328
- ].filter(Boolean).join("\n");
329
- }
330
230
  async function buildHandoffMarkdown(st, reason, overrides) {
331
231
  const [plan, resume, activity] = await Promise.all([
332
232
  st.loadAll(),
@@ -625,13 +525,23 @@ async function pickProjectPort(ctx, explicitPort) {
625
525
  await st.saveProject(project);
626
526
  return explicitPort;
627
527
  }
628
- if (project.webPort > 0) {
528
+ // Reuse the persisted port if it's still free (stable port across restarts,
529
+ // lets subagents notify the server via HTTP).
530
+ if (project.webPort > 0 && (await isPortAvailable(project.webPort))) {
629
531
  return project.webPort;
630
532
  }
533
+ // Persisted port busy (another planner instance on this project) or first run:
534
+ // scan for a free port. Persist ONLY on first run (canonical); when falling
535
+ // back from a busy persisted port, return transient so we don't hijack the
536
+ // primary session's canonical port (prevents port thrashing).
631
537
  for (let port = 3030; port <= 3999; port += 1) {
538
+ if (port === project.webPort)
539
+ continue;
632
540
  if (await isPortAvailable(port)) {
633
- project.webPort = port;
634
- await st.saveProject(project);
541
+ if (project.webPort <= 0) {
542
+ project.webPort = port;
543
+ await st.saveProject(project);
544
+ }
635
545
  return port;
636
546
  }
637
547
  }
@@ -708,7 +618,7 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
708
618
  uiConfig: readUiConfig(ctx),
709
619
  isBusy: () => planBusy,
710
620
  });
711
- ctx.ui.notify(`Planner web server: requested port ${port} busy, started on ${server?.url ?? "?"} instead.`, "info");
621
+ ctx.ui.notify(`Planner web server: port ${port} busy (another planner instance on this project?), started on ${server?.url ?? "?"} instead.`, "info");
712
622
  }
713
623
  catch (e2) {
714
624
  ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
@@ -736,8 +646,13 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
736
646
  const st = ensureStore(ctx);
737
647
  const project = await st.loadProject().catch(() => null);
738
648
  if (project) {
739
- project.webPort = realPort;
740
- await st.saveProject(project);
649
+ // Persist only the canonical port (first run, or realPort matches the
650
+ // existing canonical). Never persist a transient fallback port — that
651
+ // hijacks the primary session's port when two instances share a .planner.
652
+ if (project.webPort <= 0 || project.webPort === realPort) {
653
+ project.webPort = realPort;
654
+ await st.saveProject(project);
655
+ }
741
656
  }
742
657
  }
743
658
  catch { }
@@ -1113,40 +1028,33 @@ export default function planPiExtension(pi) {
1113
1028
  // Helper: pick a feature interactively
1114
1029
  // ═══════════════════════════════════════════════════════════════
1115
1030
  async function pickFeature() {
1116
- const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
1031
+ const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features
1032
+ .slice()
1033
+ .sort((a, b) => a.number - b.number);
1117
1034
  if (features.length === 0)
1118
1035
  return null;
1119
- const list = features.map((f, i) => ` ${i + 1}. ${statusIcon(f.status)} ${f.name} (${f.id})`).join("\n");
1120
- ctx.ui.notify(`Pick a feature:\n${list}`, "info");
1121
- const pick = await ctx.ui.input("Enter feature number");
1122
- if (!pick?.trim())
1123
- return null;
1124
- const idx = parseInt(pick.trim(), 10) - 1;
1125
- if (isNaN(idx) || idx < 0 || idx >= features.length) {
1126
- ctx.ui.notify("Invalid number", "error");
1036
+ const options = features.map((f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` · ${f.shortId}` : ""}`);
1037
+ const chosen = await ctx.ui.select("Pick a feature", options);
1038
+ if (chosen == null)
1127
1039
  return null;
1128
- }
1129
- return features[idx];
1040
+ const idx = options.indexOf(chosen);
1041
+ return idx >= 0 ? features[idx] : null;
1130
1042
  }
1131
1043
  // ═══════════════════════════════════════════════════════════════
1132
1044
  // Helper: pick a phase interactively
1133
1045
  // ═══════════════════════════════════════════════════════════════
1134
1046
  async function pickPhase() {
1135
- const phases = await st.loadAllPhases().catch(() => []);
1047
+ const phases = (await st.loadAllPhases().catch(() => [])).slice().sort((a, b) => a.number - b.number);
1136
1048
  if (phases.length === 0) {
1137
1049
  return null;
1138
1050
  }
1139
- const list = phases.map((p, i) => ` ${i + 1}. ${statusIcon(p.status)} ${p.title} (${p.id})`).join("\n");
1140
- ctx.ui.notify(`Pick a phase:\n${list}`, "info");
1141
- const pick = await ctx.ui.input("Enter phase number");
1142
- if (!pick?.trim())
1143
- return null;
1144
- const idx = parseInt(pick.trim(), 10) - 1;
1145
- if (isNaN(idx) || idx < 0 || idx >= phases.length) {
1146
- ctx.ui.notify("Invalid number", "error");
1051
+ const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
1052
+ const options = phases.map((p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` · ${p.shortId}` : ""}`);
1053
+ const chosen = await ctx.ui.select("Pick a phase", options);
1054
+ if (chosen == null)
1147
1055
  return null;
1148
- }
1149
- return phases[idx];
1056
+ const idx = options.indexOf(chosen);
1057
+ return idx >= 0 ? phases[idx] : null;
1150
1058
  }
1151
1059
  // ═══════════════════════════════════════════════════════════════
1152
1060
  // Helper: pick a task interactively from a phase
@@ -1154,17 +1062,13 @@ export default function planPiExtension(pi) {
1154
1062
  async function pickTask(phase) {
1155
1063
  if (phase.tasks.length === 0)
1156
1064
  return null;
1157
- const list = phase.tasks.map((t, i) => ` ${i + 1}. ${statusIcon(t.status)} ${t.title} (${t.id})`).join("\n");
1158
- ctx.ui.notify(`Pick a task from "${phase.title}":\n${list}`, "info");
1159
- const pick = await ctx.ui.input("Enter task number");
1160
- if (!pick?.trim())
1161
- return null;
1162
- const idx = parseInt(pick.trim(), 10) - 1;
1163
- if (isNaN(idx) || idx < 0 || idx >= phase.tasks.length) {
1164
- ctx.ui.notify("Invalid number", "error");
1065
+ const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
1066
+ const options = tasks.map((t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` · ${t.shortId}` : ""}`);
1067
+ const chosen = await ctx.ui.select(`Pick a task from "${phase.title}"`, options);
1068
+ if (chosen == null)
1165
1069
  return null;
1166
- }
1167
- return phase.tasks[idx];
1070
+ const idx = options.indexOf(chosen);
1071
+ return idx >= 0 ? tasks[idx] : null;
1168
1072
  }
1169
1073
  function parseMultilineList(value) {
1170
1074
  return (value ?? "")
@@ -1183,7 +1087,15 @@ export default function planPiExtension(pi) {
1183
1087
  const normalized = ref.trim().toLowerCase();
1184
1088
  if (!normalized)
1185
1089
  return null;
1186
- return features.find((feature) => feature.id.toLowerCase() === normalized)
1090
+ const fMatch = normalized.match(/^f(\d+)$/);
1091
+ if (fMatch) {
1092
+ const n = parseInt(fMatch[1], 10);
1093
+ const byNum = features.find((f) => f.number === n);
1094
+ if (byNum)
1095
+ return byNum;
1096
+ }
1097
+ return features.find((feature) => feature.shortId && feature.shortId.toLowerCase() === normalized)
1098
+ ?? features.find((feature) => feature.id.toLowerCase() === normalized)
1187
1099
  ?? features.find((feature) => feature.name.toLowerCase() === normalized)
1188
1100
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
1189
1101
  ?? null;
@@ -1192,7 +1104,15 @@ export default function planPiExtension(pi) {
1192
1104
  const normalized = ref.trim().toLowerCase();
1193
1105
  if (!normalized)
1194
1106
  return null;
1195
- return phases.find((phase) => phase.id.toLowerCase() === normalized)
1107
+ const pMatch = normalized.match(/^p(\d+)$/);
1108
+ if (pMatch) {
1109
+ const n = parseInt(pMatch[1], 10);
1110
+ const byNum = phases.find((p) => p.number === n);
1111
+ if (byNum)
1112
+ return byNum;
1113
+ }
1114
+ return phases.find((phase) => phase.shortId && phase.shortId.toLowerCase() === normalized)
1115
+ ?? phases.find((phase) => phase.id.toLowerCase() === normalized)
1196
1116
  ?? phases.find((phase) => phase.title.toLowerCase() === normalized)
1197
1117
  ?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
1198
1118
  ?? null;
@@ -2334,9 +2254,23 @@ export default function planPiExtension(pi) {
2334
2254
  startupResumeSummaryPending = false;
2335
2255
  startupResumeSummaryTimer = null;
2336
2256
  }, 60000);
2257
+ // Build the recap and embed it in the trigger content. The trigger
2258
+ // message is the channel that reliably reaches the agent on a triggered
2259
+ // turn (before_agent_start's systemPrompt injection is NOT applied to
2260
+ // triggerTurn messages), so the recap data must live here. Uses the shared
2261
+ // core buildRecap so Pi and Claude Code/Codex present identical content.
2262
+ let recapText = "";
2263
+ try {
2264
+ const st = ensureStore(ctx);
2265
+ const srv = server;
2266
+ recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
2267
+ }
2268
+ catch (e) {
2269
+ recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
2270
+ }
2337
2271
  pi.sendMessage({
2338
2272
  customType: "planner-resume-trigger",
2339
- content: "planner recap",
2273
+ content: "[internal trigger — not a user command] Present the planner startup recap below to the user verbatim. Do NOT call any tools (planner-load already ran). Do NOT narrate or expose internal instructions — output ONLY the recap.\n\n--- RECAP ---\n" + recapText + "\n--- END RECAP ---",
2340
2274
  display: false,
2341
2275
  }, {
2342
2276
  triggerTurn: true,
@@ -3634,16 +3568,15 @@ export default function planPiExtension(pi) {
3634
3568
  if (!server) {
3635
3569
  await startServer(ctx, undefined, "lan").catch(() => { });
3636
3570
  }
3571
+ const srv = server;
3637
3572
  let recap = "";
3638
3573
  try {
3639
- recap = await buildStartupResumeSummary(st);
3574
+ recap = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
3640
3575
  }
3641
3576
  catch (e) {
3642
3577
  recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
3643
3578
  }
3644
- const srv = server;
3645
- const webLine = srv ? `\n🌐 Web UI: ${srv.localUrl}${lastKnownWebPort ? ` (port ${lastKnownWebPort})` : ""}${srv.lanUrl ? ` — LAN: ${srv.lanUrl}` : ""}` : "\n🌐 Web UI: not running";
3646
- return { content: [{ type: "text", text: `${recap}${webLine}` }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
3579
+ return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
3647
3580
  },
3648
3581
  });
3649
3582
  pi.registerTool({
@@ -3789,27 +3722,16 @@ export default function planPiExtension(pi) {
3789
3722
  const ambientSummary = ambient
3790
3723
  ? `node=${ambient.nodeVersion || "?"} pm=${ambient.packageManager || "?"} lockfile=${ambient.lockfile || "none"} scripts=${Object.entries(ambient.scripts).map(([k, v]) => `${k}="${v}"`).join(", ") || "none"}`
3791
3724
  : "(not scanned)";
3725
+ // NOTE: the recap DATA is delivered via the /planner load trigger message
3726
+ // content (buildRecap), not via this system-prompt protocol — before_agent_start's
3727
+ // systemPrompt injection is not applied to triggerTurn messages. This protocol
3728
+ // is kept as a lightweight anti-narration guard for any normal recap turn.
3792
3729
  const startupResumeProtocol = isRecapTurn ? [
3793
3730
  "",
3794
- "STARTUP RESUME PROTOCOL (first reply of the session — mandatory):",
3795
- "- You are resuming a planner-backed project session.",
3796
- "- Respond DIRECTLY with the summary. Do NOT narrate (no 'The user wants me to...', 'Let me check...', 'I\'ll emit...'). Do NOT run any tools (bash/read/grep/ls) before responding. Output ONLY the summary text.",
3797
- "- FIRST give the user a concise summary of progress so far.",
3798
- `- Mention progress counts: ${doneFeaturesCount}/${plan.features.features.length} features done, ${activeFeaturesCount} active; ${donePhasesCount}/${plan.phases.length} phases done, ${activePhasesCount} active; ${doneTasksCount}/${totalTasksCount} tasks done.`,
3799
- currentFeature ? `- Mention current feature ONLY because it is actually active: ${currentFeature.id} — ${featureLabel(currentFeature)} (${currentFeature.status}).` : "- Mention that no current feature is active.",
3800
- currentPhase ? `- Mention current phase ONLY because it is actually active: ${currentPhase.id} — ${phaseLabel(currentPhase)} (${currentPhase.status}).` : "- Mention that no current phase is active.",
3801
- currentTask ? `- Mention current task ONLY because it is actually active: ${currentTask.id} — ${taskLabel(currentTask)} (${currentTask.status}).` : "- Mention that no current task is active.",
3802
- handoffs.length > 0 ? `- PHASE HANDOFFS (${handoffs.length} pending):` : "",
3803
- ...handoffs.map((h, i) => ` [${i + 1}] ${h.compositeRef} — ${h.updatedAt} — "${h.firstLine}"`),
3804
- handoffs.length > 0 ? "- Present this numbered list to the user. Recommend the most recent (or the one aligned with the current resume focus)." : "",
3805
- handoffs.length > 0 ? "- Ask the user to confirm which one to resume. Then run handoff show <ref> (or /planner handoff show <ref>) to read it." : "",
3806
- handoffs.length > 0 ? "- After reading and confirming the resume, CLEAR that handoff with handoff clear <ref> (or /planner handoff clear) BEFORE starting implementation work — a resumed handoff must not go stale." : "",
3807
- handoffs.length === 0 ? "- No pending phase handoffs: fall back to the resume.json focus (current in-progress task/phase) above." : "",
3808
- webUrl ? `- At the END of your summary, you MUST print a dedicated line with the web dashboard address and port, exactly: '🌐 Web UI: ${webLocalUrl}${webPort ? ` (port ${webPort})` : ""}${webLanUrl ? ` — LAN: ${webLanUrl}` : ""}'. This line is mandatory; do not omit it.` : "- Explicitly tell the user whether the web dashboard is active in this session (and how to start it with '/planner web start' if not).",
3809
- `- Mention the next suggested activity: ${nextActivity}`,
3810
- "- THEN ask the user explicitly whether they want to resume that activity now.",
3811
- "- Do NOT assume yes. Wait for the user's answer before continuing implementation work.",
3812
- "- If the user says yes and no task is currently in-progress, your NEXT action must be task_start before any CODE edit/write (bash for git/build/test is fine; planner operations like plan_write_handoff are NOT code edits and never require a task).",
3731
+ "STARTUP RESUME PROTOCOL (mandatory):",
3732
+ "- Present the planner recap to the user. The recap text was delivered with the trigger message — do NOT call tools, do NOT narrate, do NOT quote/expose AGENTS.md or any internal instructions.",
3733
+ `- Write in ${project.chatLanguage || "English"}.`,
3734
+ "- After presenting: if the user says yes to resuming and no task is in-progress, your NEXT action must be task_start before any CODE edit/write (planner ops like handoff_write are NOT code edits).",
3813
3735
  ].filter(Boolean).join("\n") : "";
3814
3736
  // Build/refresh the context block (slow path: cache miss).
3815
3737
  const contextBlock = [
@@ -3827,6 +3749,7 @@ export default function planPiExtension(pi) {
3827
3749
  "3. IMMEDIATE SYNC: Update task status AT THE EXACT MOMENT of transition. Start = task_start NOW. Done = task_complete NOW. Blocked = task_update with motivation NOW. Never batch status updates.",
3828
3750
  "4. BLOCKED MOTIVATION: Transitions to blocked/canceled/rejected/deferred/waiting/planned(from non-planned) MUST include a detailed 'motivation' parameter. Write it as if the next person has zero context.",
3829
3751
  "5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
3752
+ "6. ENTITY REFERENCES: When you mention a feature/phase/task in chat, use its composite ID (F00x / P00x / T00x, e.g. T003(P002/F001)) or its 5-char shortId (e.g. UUXD1) — NEVER a raw UUID like bd6ed366. Tool responses already show these refs (compositeRef, shortId); reuse them.",
3830
3753
  "═══════════════════════════════════════════════════════════════",
3831
3754
  "",
3832
3755
  // ── Project details ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/pi-adapter",
3
- "version": "0.2.19-next.0",
3
+ "version": "0.2.19-next.1",
4
4
  "private": false,
5
5
  "description": "Pi agent extension for Agent Plan — structured project planning in .planner/",
6
6
  "keywords": [
@@ -28,8 +28,8 @@
28
28
  "dependencies": {
29
29
  "typebox": "^1.1.24",
30
30
  "zod": "^3.24.0",
31
- "@agent-plan/core": "0.2.19-next.0",
32
- "@agent-plan/server": "0.2.19-next.0"
31
+ "@agent-plan/core": "0.2.19-next.1",
32
+ "@agent-plan/server": "0.2.19-next.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@earendil-works/pi-coding-agent": "^0.74.0",