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

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;AA6uBtF,MAAM,CAAC,OAAO,UAAU,eAAe,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAg7F9D"}
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";
@@ -68,6 +68,29 @@ setWriteNotifyHook(() => {
68
68
  let previousContextPercent = 0;
69
69
  let autoHandoffTriggered = false;
70
70
  let plannerSessionEnabled = false;
71
+ // In-process guard bypass (per session, NOT persisted/shared). The edit/write
72
+ // guard is advisory in Pi (warns "NO ACTIVE TASK" but does not block). When a
73
+ // bypass is active the warning is silenced for the window. Lives in memory only:
74
+ // not on disk, not in git, not shared across agents/sessions on the same folder.
75
+ let guardBypassUntil = "";
76
+ function authorizeGuardBypass(durationMinutes = 15) {
77
+ const until = new Date(Date.now() + durationMinutes * 60_000).toISOString();
78
+ guardBypassUntil = until;
79
+ return until;
80
+ }
81
+ function clearGuardBypass() { guardBypassUntil = ""; }
82
+ function isGuardBypassed() {
83
+ if (!guardBypassUntil)
84
+ return false;
85
+ const until = Date.parse(guardBypassUntil);
86
+ if (!Number.isFinite(until))
87
+ return false;
88
+ if (until <= Date.now()) {
89
+ guardBypassUntil = "";
90
+ return false;
91
+ }
92
+ return true;
93
+ }
71
94
  let startupResumePromptPending = false;
72
95
  let startupResumeSummaryPending = false;
73
96
  let plannerHeavyInitDone = false; // runs migrate/heal/refreshResume once per session, not every turn
@@ -227,106 +250,6 @@ function phaseLabel(phase) {
227
250
  function taskLabel(task) {
228
251
  return `T${formatSequence(task.number)} — ${task.title}`;
229
252
  }
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
253
  async function buildHandoffMarkdown(st, reason, overrides) {
331
254
  const [plan, resume, activity] = await Promise.all([
332
255
  st.loadAll(),
@@ -625,13 +548,23 @@ async function pickProjectPort(ctx, explicitPort) {
625
548
  await st.saveProject(project);
626
549
  return explicitPort;
627
550
  }
628
- if (project.webPort > 0) {
551
+ // Reuse the persisted port if it's still free (stable port across restarts,
552
+ // lets subagents notify the server via HTTP).
553
+ if (project.webPort > 0 && (await isPortAvailable(project.webPort))) {
629
554
  return project.webPort;
630
555
  }
556
+ // Persisted port busy (another planner instance on this project) or first run:
557
+ // scan for a free port. Persist ONLY on first run (canonical); when falling
558
+ // back from a busy persisted port, return transient so we don't hijack the
559
+ // primary session's canonical port (prevents port thrashing).
631
560
  for (let port = 3030; port <= 3999; port += 1) {
561
+ if (port === project.webPort)
562
+ continue;
632
563
  if (await isPortAvailable(port)) {
633
- project.webPort = port;
634
- await st.saveProject(project);
564
+ if (project.webPort <= 0) {
565
+ project.webPort = port;
566
+ await st.saveProject(project);
567
+ }
635
568
  return port;
636
569
  }
637
570
  }
@@ -708,7 +641,7 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
708
641
  uiConfig: readUiConfig(ctx),
709
642
  isBusy: () => planBusy,
710
643
  });
711
- ctx.ui.notify(`Planner web server: requested port ${port} busy, started on ${server?.url ?? "?"} instead.`, "info");
644
+ ctx.ui.notify(`Planner web server: port ${port} busy (another planner instance on this project?), started on ${server?.url ?? "?"} instead.`, "info");
712
645
  }
713
646
  catch (e2) {
714
647
  ctx.ui.notify(`Failed to start web server (also retry failed): ${e2 instanceof Error ? e2.message : String(e2)}`, "error");
@@ -736,8 +669,13 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
736
669
  const st = ensureStore(ctx);
737
670
  const project = await st.loadProject().catch(() => null);
738
671
  if (project) {
739
- project.webPort = realPort;
740
- await st.saveProject(project);
672
+ // Persist only the canonical port (first run, or realPort matches the
673
+ // existing canonical). Never persist a transient fallback port — that
674
+ // hijacks the primary session's port when two instances share a .planner.
675
+ if (project.webPort <= 0 || project.webPort === realPort) {
676
+ project.webPort = realPort;
677
+ await st.saveProject(project);
678
+ }
741
679
  }
742
680
  }
743
681
  catch { }
@@ -947,6 +885,8 @@ export default function planPiExtension(pi) {
947
885
  return; // nothing to enforce yet
948
886
  if (guard.inProgressTaskIds.length > 0)
949
887
  return; // a task is open → we're good
888
+ if (isGuardBypassed())
889
+ return; // user authorized proceeding without a task
950
890
  const focusHint = guard.focusTaskId
951
891
  ? `Il task più probabile è ${guard.focusTaskId} — ${guard.focusTaskTitle}. Avvialo con: \`/planner task start ${guard.focusTaskId}\``
952
892
  : `Scegli un task dal piano e avvialo con \`/planner task start <taskId>\`.`;
@@ -1113,40 +1053,33 @@ export default function planPiExtension(pi) {
1113
1053
  // Helper: pick a feature interactively
1114
1054
  // ═══════════════════════════════════════════════════════════════
1115
1055
  async function pickFeature() {
1116
- const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
1056
+ const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features
1057
+ .slice()
1058
+ .sort((a, b) => a.number - b.number);
1117
1059
  if (features.length === 0)
1118
1060
  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");
1061
+ const options = features.map((f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` · ${f.shortId}` : ""}`);
1062
+ const chosen = await ctx.ui.select("Pick a feature", options);
1063
+ if (chosen == null)
1127
1064
  return null;
1128
- }
1129
- return features[idx];
1065
+ const idx = options.indexOf(chosen);
1066
+ return idx >= 0 ? features[idx] : null;
1130
1067
  }
1131
1068
  // ═══════════════════════════════════════════════════════════════
1132
1069
  // Helper: pick a phase interactively
1133
1070
  // ═══════════════════════════════════════════════════════════════
1134
1071
  async function pickPhase() {
1135
- const phases = await st.loadAllPhases().catch(() => []);
1072
+ const phases = (await st.loadAllPhases().catch(() => [])).slice().sort((a, b) => a.number - b.number);
1136
1073
  if (phases.length === 0) {
1137
1074
  return null;
1138
1075
  }
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");
1076
+ const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
1077
+ const options = phases.map((p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` · ${p.shortId}` : ""}`);
1078
+ const chosen = await ctx.ui.select("Pick a phase", options);
1079
+ if (chosen == null)
1147
1080
  return null;
1148
- }
1149
- return phases[idx];
1081
+ const idx = options.indexOf(chosen);
1082
+ return idx >= 0 ? phases[idx] : null;
1150
1083
  }
1151
1084
  // ═══════════════════════════════════════════════════════════════
1152
1085
  // Helper: pick a task interactively from a phase
@@ -1154,17 +1087,13 @@ export default function planPiExtension(pi) {
1154
1087
  async function pickTask(phase) {
1155
1088
  if (phase.tasks.length === 0)
1156
1089
  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())
1090
+ const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
1091
+ const options = tasks.map((t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` · ${t.shortId}` : ""}`);
1092
+ const chosen = await ctx.ui.select(`Pick a task from "${phase.title}"`, options);
1093
+ if (chosen == null)
1161
1094
  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");
1165
- return null;
1166
- }
1167
- return phase.tasks[idx];
1095
+ const idx = options.indexOf(chosen);
1096
+ return idx >= 0 ? tasks[idx] : null;
1168
1097
  }
1169
1098
  function parseMultilineList(value) {
1170
1099
  return (value ?? "")
@@ -1183,7 +1112,15 @@ export default function planPiExtension(pi) {
1183
1112
  const normalized = ref.trim().toLowerCase();
1184
1113
  if (!normalized)
1185
1114
  return null;
1186
- return features.find((feature) => feature.id.toLowerCase() === normalized)
1115
+ const fMatch = normalized.match(/^f(\d+)$/);
1116
+ if (fMatch) {
1117
+ const n = parseInt(fMatch[1], 10);
1118
+ const byNum = features.find((f) => f.number === n);
1119
+ if (byNum)
1120
+ return byNum;
1121
+ }
1122
+ return features.find((feature) => feature.shortId && feature.shortId.toLowerCase() === normalized)
1123
+ ?? features.find((feature) => feature.id.toLowerCase() === normalized)
1187
1124
  ?? features.find((feature) => feature.name.toLowerCase() === normalized)
1188
1125
  ?? features.find((feature) => feature.name.toLowerCase().includes(normalized))
1189
1126
  ?? null;
@@ -1192,7 +1129,15 @@ export default function planPiExtension(pi) {
1192
1129
  const normalized = ref.trim().toLowerCase();
1193
1130
  if (!normalized)
1194
1131
  return null;
1195
- return phases.find((phase) => phase.id.toLowerCase() === normalized)
1132
+ const pMatch = normalized.match(/^p(\d+)$/);
1133
+ if (pMatch) {
1134
+ const n = parseInt(pMatch[1], 10);
1135
+ const byNum = phases.find((p) => p.number === n);
1136
+ if (byNum)
1137
+ return byNum;
1138
+ }
1139
+ return phases.find((phase) => phase.shortId && phase.shortId.toLowerCase() === normalized)
1140
+ ?? phases.find((phase) => phase.id.toLowerCase() === normalized)
1196
1141
  ?? phases.find((phase) => phase.title.toLowerCase() === normalized)
1197
1142
  ?? phases.find((phase) => phase.title.toLowerCase().includes(normalized))
1198
1143
  ?? null;
@@ -2301,12 +2246,12 @@ export default function planPiExtension(pi) {
2301
2246
  try {
2302
2247
  if (a === "bypass") {
2303
2248
  const mins = parseInt(subArgs.trim(), 10);
2304
- const until = await st.authorizeGuardBypass(Number.isFinite(mins) && mins > 0 ? mins : 15);
2305
- ctx.ui.notify(`Guard bypass authorized until ${until}. edit/write will work without a task in-progress for that window.`, "info");
2249
+ const until = authorizeGuardBypass(Number.isFinite(mins) && mins > 0 ? mins : 15);
2250
+ ctx.ui.notify(`Guard bypass authorized until ${until}. edit/write advisory warnings silenced for that window (per-session, not shared).`, "info");
2306
2251
  }
2307
2252
  else {
2308
- await st.clearGuardBypass();
2309
- ctx.ui.notify("Guard bypass revoked. edit/write now requires a task in-progress again.", "info");
2253
+ clearGuardBypass();
2254
+ ctx.ui.notify("Guard bypass revoked. edit/write advisory warnings re-enabled.", "info");
2310
2255
  }
2311
2256
  }
2312
2257
  catch (e) {
@@ -2334,9 +2279,23 @@ export default function planPiExtension(pi) {
2334
2279
  startupResumeSummaryPending = false;
2335
2280
  startupResumeSummaryTimer = null;
2336
2281
  }, 60000);
2282
+ // Build the recap and embed it in the trigger content. The trigger
2283
+ // message is the channel that reliably reaches the agent on a triggered
2284
+ // turn (before_agent_start's systemPrompt injection is NOT applied to
2285
+ // triggerTurn messages), so the recap data must live here. Uses the shared
2286
+ // core buildRecap so Pi and Claude Code/Codex present identical content.
2287
+ let recapText = "";
2288
+ try {
2289
+ const st = ensureStore(ctx);
2290
+ const srv = server;
2291
+ recapText = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
2292
+ }
2293
+ catch (e) {
2294
+ recapText = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
2295
+ }
2337
2296
  pi.sendMessage({
2338
2297
  customType: "planner-resume-trigger",
2339
- content: "planner recap",
2298
+ 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
2299
  display: false,
2341
2300
  }, {
2342
2301
  triggerTurn: true,
@@ -2837,29 +2796,29 @@ export default function planPiExtension(pi) {
2837
2796
  pi.registerTool({
2838
2797
  name: "plan_authorize_bypass",
2839
2798
  label: "Plan Authorize Guard Bypass",
2840
- description: "Authorize a temporary guard bypass (default 15 minutes) so edit/write tools can proceed even when no task is in-progress. Use this ONLY after the user explicitly authorizes proceeding without a task. Harness-agnostic: stored in resume.json so all adapters respect it.",
2799
+ description: "Authorize a temporary guard bypass (default 15 minutes) so the edit/write advisory guard stops warning when no task is in-progress. Per-session in-memory: not persisted, not shared across agents. Use ONLY after the user explicitly authorizes proceeding without a task.",
2841
2800
  parameters: Type.Object({
2842
2801
  durationMinutes: Type.Optional(Type.Number({ description: "Bypass window in minutes. Default 15." })),
2843
2802
  }),
2844
2803
  async execute(_id, params, _signal, _onUpdate, ctx) {
2845
- const st = await requirePlan(ctx);
2846
- if (!st)
2804
+ const ok = await requirePlan(ctx);
2805
+ if (!ok)
2847
2806
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2848
2807
  const mins = params.durationMinutes ?? 15;
2849
- const until = await st.authorizeGuardBypass(mins);
2850
- return { content: [{ type: "text", text: `Guard bypass authorized until ${until}. edit/write is allowed without a task in-progress for ${mins} minutes.` }], details: { until } };
2808
+ const until = authorizeGuardBypass(mins);
2809
+ return { content: [{ type: "text", text: `Guard bypass authorized until ${until}. Advisory edit/write warnings silenced for ${mins} minutes (per-session, not shared).` }], details: { until } };
2851
2810
  },
2852
2811
  });
2853
2812
  pi.registerTool({
2854
2813
  name: "plan_clear_bypass",
2855
2814
  label: "Plan Clear Guard Bypass",
2856
- description: "Revoke any active guard bypass so edit/write again requires a task in-progress.",
2815
+ description: "Revoke the per-session guard bypass so the edit/write advisory warnings resume.",
2857
2816
  parameters: Type.Object({}),
2858
2817
  async execute(_id, _params, _signal, _onUpdate, ctx) {
2859
- const st = await requirePlan(ctx);
2860
- if (!st)
2818
+ const ok = await requirePlan(ctx);
2819
+ if (!ok)
2861
2820
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2862
- await st.clearGuardBypass();
2821
+ clearGuardBypass();
2863
2822
  return { content: [{ type: "text", text: "Guard bypass revoked." }], details: { cleared: true } };
2864
2823
  },
2865
2824
  });
@@ -3634,16 +3593,15 @@ export default function planPiExtension(pi) {
3634
3593
  if (!server) {
3635
3594
  await startServer(ctx, undefined, "lan").catch(() => { });
3636
3595
  }
3596
+ const srv = server;
3637
3597
  let recap = "";
3638
3598
  try {
3639
- recap = await buildStartupResumeSummary(st);
3599
+ recap = await buildRecap(st, { localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort ?? undefined }, { harness: "pi" });
3640
3600
  }
3641
3601
  catch (e) {
3642
3602
  recap = `(recap unavailable: ${e instanceof Error ? e.message : String(e)})`;
3643
3603
  }
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 } };
3604
+ return { content: [{ type: "text", text: recap }], details: { enabled: true, running: Boolean(srv), localUrl: srv?.localUrl, lanUrl: srv?.lanUrl, port: lastKnownWebPort } };
3647
3605
  },
3648
3606
  });
3649
3607
  pi.registerTool({
@@ -3789,27 +3747,16 @@ export default function planPiExtension(pi) {
3789
3747
  const ambientSummary = ambient
3790
3748
  ? `node=${ambient.nodeVersion || "?"} pm=${ambient.packageManager || "?"} lockfile=${ambient.lockfile || "none"} scripts=${Object.entries(ambient.scripts).map(([k, v]) => `${k}="${v}"`).join(", ") || "none"}`
3791
3749
  : "(not scanned)";
3750
+ // NOTE: the recap DATA is delivered via the /planner load trigger message
3751
+ // content (buildRecap), not via this system-prompt protocol — before_agent_start's
3752
+ // systemPrompt injection is not applied to triggerTurn messages. This protocol
3753
+ // is kept as a lightweight anti-narration guard for any normal recap turn.
3792
3754
  const startupResumeProtocol = isRecapTurn ? [
3793
3755
  "",
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).",
3756
+ "STARTUP RESUME PROTOCOL (mandatory):",
3757
+ "- 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.",
3758
+ `- Write in ${project.chatLanguage || "English"}.`,
3759
+ "- 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
3760
  ].filter(Boolean).join("\n") : "";
3814
3761
  // Build/refresh the context block (slow path: cache miss).
3815
3762
  const contextBlock = [
@@ -3827,6 +3774,7 @@ export default function planPiExtension(pi) {
3827
3774
  "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
3775
  "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
3776
  "5. NO SHORTCUTS: If a tool blocks you, follow the protocol. Bypasses are for emergencies only, not for convenience.",
3777
+ "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
3778
  "═══════════════════════════════════════════════════════════════",
3831
3779
  "",
3832
3780
  // ── 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.2",
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.2",
32
+ "@agent-plan/server": "0.2.19-next.2"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@earendil-works/pi-coding-agent": "^0.74.0",