@agent-plan/pi-adapter 0.2.21 → 0.2.22-next.0

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/dist/index.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * - inject context into prompts
10
10
  */
11
11
  import { Type } from "typebox";
12
- import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask } from "@agent-plan/core";
12
+ import { paginatedSelect, paginatedNotify } from "./ui/paginate.js";
13
+ import { ExportService, PlanStore, setWriteBusyHook, setWriteNotifyHook, withFeatureLock, needsMotivation, findPhaseByRef, findTaskByRef, buildRecap, addChecklistItem, removeChecklistItem, toggleChecklistItem, buildPhaseContextBlock, checkExplicitTaskStart, recommendNextTask, buildResumeRequiredProposal, packageVersionFromModule, resolvedPackageVersion, markFeatureRead, markPhaseRead, markTaskRead, hasReadParents, parentReadAdvisory, hasReadRequirements, requirementReadAdvisory, markRequirementRead, invalidateReads } from "@agent-plan/core";
13
14
  import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug, formatPhaseRef, formatFeatureRef, featureNumberOfPhase, validateResolvedTarget } from "@agent-plan/core/naming";
14
15
  import { join, dirname } from "node:path";
15
16
  import { homedir } from "node:os";
@@ -21,6 +22,9 @@ import { serve } from "@agent-plan/server/serve";
21
22
  // ─── State (module-level) ───────────────────────────────────────────────
22
23
  const PI_CONFIG_DIR_NAME = ".pi";
23
24
  const PLAN_DIR_NAME = ".planner";
25
+ const PI_ADAPTER_PACKAGE = packageVersionFromModule(import.meta.url, "@agent-plan/pi-adapter");
26
+ const CORE_PACKAGE = resolvedPackageVersion("@agent-plan/core", import.meta.url);
27
+ const SERVER_PACKAGE = resolvedPackageVersion("@agent-plan/server", import.meta.url);
24
28
  let capturedPi = null;
25
29
  let store = null;
26
30
  let server = null;
@@ -104,6 +108,7 @@ const healedStatusRoots = new Set();
104
108
  const PLANNER_COMMAND_COMPLETIONS = [
105
109
  { value: "init", label: "init", description: "Initialize planner in this project" },
106
110
  { value: "show", label: "show", description: "Show planner overview" },
111
+ { value: "version", label: "version", description: "Show loaded Agent Plan package versions" },
107
112
  { value: "repair", label: "repair", description: "Repair planner integrity" },
108
113
  { value: "cleanup-orphans", label: "cleanup-orphans", description: "List and remove orphan phase files" },
109
114
  { value: "project discuss", label: "project discuss", description: "Run project discovery" },
@@ -239,10 +244,11 @@ async function getPlannerExecutionGuard(st) {
239
244
  ? allTasks.find(({ task }) => task.id === resume.inProgressTaskIds[0])
240
245
  : undefined;
241
246
  const focusFromCurrentPhase = resume?.currentPhaseId
242
- ? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled")
247
+ ? allTasks.find(({ phase, task }) => phase.id === resume.currentPhaseId && task.status !== "done" && task.status !== "canceled" && task.status !== "rejected")
243
248
  : undefined;
244
- const fallbackFocus = allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked")
245
- ?? allTasks.find(({ task }) => task.status !== "done" && task.status !== "canceled");
249
+ const fallbackFocus = allTasks.find(({ task }) => task.pauseSnapshot)
250
+ ?? allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked" || task.status === "waiting")
251
+ ?? allTasks.find(({ task }) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected");
246
252
  const focus = focusFromResume ?? focusFromCurrentPhase ?? fallbackFocus;
247
253
  return {
248
254
  totalTasks,
@@ -313,7 +319,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
313
319
  ?? null;
314
320
  const currentTask = currentPhase?.tasks.find((task) => resume.inProgressTaskIds.includes(task.id))
315
321
  ?? currentPhase?.tasks.find((task) => task.status === "in-progress")
316
- ?? [...(currentPhase?.tasks ?? [])].filter((task) => task.status !== "done" && task.status !== "canceled").sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]
322
+ ?? currentPhase?.tasks.find((task) => task.pauseSnapshot)
323
+ ?? [...(currentPhase?.tasks ?? [])].filter((task) => task.status !== "done" && task.status !== "canceled" && task.status !== "rejected").sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]
317
324
  ?? latestTaskUpdate?.task
318
325
  ?? null;
319
326
  const currentFeature = currentPhase?.featureId
@@ -551,6 +558,7 @@ function statusIcon(status) {
551
558
  const icons = {
552
559
  draft: "📄", discovery: "🔍", planned: "📋",
553
560
  "in-progress": "🚧", done: "✅", blocked: "🚫", canceled: "❌",
561
+ rejected: "❌", deferred: "⏳", waiting: "⏱️",
554
562
  };
555
563
  return icons[status] ?? "❓";
556
564
  }
@@ -622,10 +630,32 @@ function resolveStaticDir() {
622
630
  const adapterDir = dirname(adapterFile);
623
631
  // When installed as Pi package: node_modules/@agent-plan/pi-adapter/web-ui-dist/
624
632
  const pkgWebUi = join(adapterDir, "..", "web-ui-dist");
625
- if (existsSync(pkgWebUi))
626
- return pkgWebUi;
627
- // Fallback: monorepo dev — packages/plan-web-ui/dist/
628
- return join(adapterDir, "..", "..", "plan-web-ui", "dist");
633
+ // Monorepo dev: packages/plan-web-ui/dist/ (rebuilt by `pnpm build:web-ui`)
634
+ const devWebUi = join(adapterDir, "..", "..", "plan-web-ui", "dist");
635
+ const candidates = [pkgWebUi, devWebUi].filter((d) => {
636
+ try {
637
+ return existsSync(join(d, "index.html"));
638
+ }
639
+ catch {
640
+ return false;
641
+ }
642
+ });
643
+ if (candidates.length === 0)
644
+ return undefined;
645
+ if (candidates.length === 1)
646
+ return candidates[0];
647
+ // Prefer the most recently built bundle so a fresh `pnpm build:web-ui`
648
+ // (or copy-web-ui.sh) is picked up in dev instead of a stale vendored
649
+ // snapshot. Deterministic: compare index.html mtimes, newest wins.
650
+ const mtime = (d) => {
651
+ try {
652
+ return statSync(join(d, "index.html")).mtimeMs;
653
+ }
654
+ catch {
655
+ return 0;
656
+ }
657
+ };
658
+ return [...candidates].sort((a, b) => mtime(b) - mtime(a))[0];
629
659
  }
630
660
  catch {
631
661
  return undefined;
@@ -1041,6 +1071,7 @@ export default function planPiExtension(pi) {
1041
1071
  const PLANNER_MENU_ACTIONS = [
1042
1072
  "init",
1043
1073
  "show",
1074
+ "version",
1044
1075
  "repair",
1045
1076
  "cleanup-orphans",
1046
1077
  "project discuss",
@@ -1076,7 +1107,7 @@ export default function planPiExtension(pi) {
1076
1107
  "load",
1077
1108
  "stop",
1078
1109
  ];
1079
- const SUB_HELP = "Available: init, show, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
1110
+ const SUB_HELP = "Available: init, show, version, repair, cleanup-orphans, project, feature, phase, task, discuss, handoff, web, export, export-full, bypass, clear-bypass, load, stop\n" +
1080
1111
  "Try: /planner <TAB> | /planner feature list | /planner task start | /planner cleanup-orphans\n" +
1081
1112
  "Handoff actions: /planner handoff list | show P00x | write P00x | clear P00x | prepare (prepare proposes a target and asks for confirmation)";
1082
1113
  if (!a) {
@@ -1088,6 +1119,15 @@ export default function planPiExtension(pi) {
1088
1119
  await runPlanner(action, ctx);
1089
1120
  return;
1090
1121
  }
1122
+ if (a === "version") {
1123
+ ctx.ui.notify([
1124
+ "Agent Plan runtime versions",
1125
+ `${PI_ADAPTER_PACKAGE.name}: ${PI_ADAPTER_PACKAGE.version}`,
1126
+ `${CORE_PACKAGE.name}: ${CORE_PACKAGE.version}`,
1127
+ `${SERVER_PACKAGE.name}: ${SERVER_PACKAGE.version}`,
1128
+ ].join("\n"), "info");
1129
+ return;
1130
+ }
1091
1131
  const st = ensureStore(ctx);
1092
1132
  // ── init ──
1093
1133
  if (a === "init") {
@@ -1168,12 +1208,12 @@ export default function planPiExtension(pi) {
1168
1208
  .sort((a, b) => a.number - b.number);
1169
1209
  if (features.length === 0)
1170
1210
  return null;
1171
- const options = features.map((f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` · ${f.shortId}` : ""}`);
1172
- const chosen = await ctx.ui.select("Pick a feature", options);
1173
- if (chosen == null)
1174
- return null;
1175
- const idx = options.indexOf(chosen);
1176
- return idx >= 0 ? features[idx] : null;
1211
+ return paginatedSelect(ctx, {
1212
+ title: "Pick a feature",
1213
+ items: features,
1214
+ render: (f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` · ${f.shortId}` : ""}`,
1215
+ pageSize: 10,
1216
+ });
1177
1217
  }
1178
1218
  // ═══════════════════════════════════════════════════════════════
1179
1219
  // Helper: pick a phase interactively
@@ -1184,12 +1224,12 @@ export default function planPiExtension(pi) {
1184
1224
  return null;
1185
1225
  }
1186
1226
  const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
1187
- const options = phases.map((p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` · ${p.shortId}` : ""}`);
1188
- const chosen = await ctx.ui.select("Pick a phase", options);
1189
- if (chosen == null)
1190
- return null;
1191
- const idx = options.indexOf(chosen);
1192
- return idx >= 0 ? phases[idx] : null;
1227
+ return paginatedSelect(ctx, {
1228
+ title: "Pick a phase",
1229
+ items: phases,
1230
+ render: (p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` · ${p.shortId}` : ""}`,
1231
+ pageSize: 10,
1232
+ });
1193
1233
  }
1194
1234
  // ═══════════════════════════════════════════════════════════════
1195
1235
  // Helper: pick a task interactively from a phase
@@ -1198,12 +1238,12 @@ export default function planPiExtension(pi) {
1198
1238
  if (phase.tasks.length === 0)
1199
1239
  return null;
1200
1240
  const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
1201
- const options = tasks.map((t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` · ${t.shortId}` : ""}`);
1202
- const chosen = await ctx.ui.select(`Pick a task from "${phase.title}"`, options);
1203
- if (chosen == null)
1204
- return null;
1205
- const idx = options.indexOf(chosen);
1206
- return idx >= 0 ? tasks[idx] : null;
1241
+ return paginatedSelect(ctx, {
1242
+ title: `Pick a task from "${phase.title}"`,
1243
+ items: tasks,
1244
+ render: (t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` · ${t.shortId}` : ""}`,
1245
+ pageSize: 10,
1246
+ });
1207
1247
  }
1208
1248
  function parseMultilineList(value) {
1209
1249
  return (value ?? "")
@@ -1447,11 +1487,12 @@ export default function planPiExtension(pi) {
1447
1487
  return;
1448
1488
  }
1449
1489
  const phases = await st.loadAllPhases();
1450
- ctx.ui.notify(features.map((feature) => {
1490
+ const lines = features.map((feature) => {
1451
1491
  const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
1452
1492
  const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
1453
1493
  return `${statusIcon(feature.status)} ${feature.name} (${feature.id}) — ${featurePhases.length} phases, ${taskCount} tasks`;
1454
- }).join("\n"), "info");
1494
+ });
1495
+ await paginatedNotify(ctx, { title: "features", lines, pageSize: 10 });
1455
1496
  return;
1456
1497
  }
1457
1498
  if (b === "add") {
@@ -2154,6 +2195,18 @@ export default function planPiExtension(pi) {
2154
2195
  return;
2155
2196
  }
2156
2197
  }
2198
+ if (phase && !hasReadParents(phase.featureId, phase.id)) {
2199
+ ctx.ui.notify("⚠️ READ REQUIRED before proceeding: read the parent feature and phase (full=true) for this task before starting it.", "warning");
2200
+ }
2201
+ if (phase) {
2202
+ const linkedRequirementIds = [
2203
+ ...(await st.linkedRequirementsForPhase(phase.id)).map((r) => r.id),
2204
+ ...(phase.featureId ? (await st.linkedRequirementsForFeature(phase.featureId)).map((r) => r.id) : []),
2205
+ ];
2206
+ if (linkedRequirementIds.length > 0 && !hasReadRequirements(linkedRequirementIds)) {
2207
+ ctx.ui.notify("⚠️ REQUIREMENTS READ REQUIRED: read the requirements linked to this phase (and feature) before starting the task.", "warning");
2208
+ }
2209
+ }
2157
2210
  if (task.status === "in-progress") {
2158
2211
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2159
2212
  return;
@@ -2327,7 +2380,11 @@ export default function planPiExtension(pi) {
2327
2380
  ctx.ui.notify("No phase handoffs set.", "info");
2328
2381
  return;
2329
2382
  }
2330
- ctx.ui.notify(list.map((e) => `• ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`).join("\n"), "info");
2383
+ await paginatedNotify(ctx, {
2384
+ title: "phase handoffs",
2385
+ lines: list.map((e) => `• ${e.compositeRef} — ${e.firstLine} (updated ${e.updatedAt})`),
2386
+ pageSize: 10,
2387
+ });
2331
2388
  return;
2332
2389
  }
2333
2390
  if (action === "show") {
@@ -2733,6 +2790,7 @@ export default function planPiExtension(pi) {
2733
2790
  if (!st)
2734
2791
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2735
2792
  const requirements = await st.loadRequirements();
2793
+ requirements.requirements.forEach((req) => markRequirementRead(req.id));
2736
2794
  return {
2737
2795
  content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} — ${req.title} (${req.status})`).join("\n") || "No requirements" }],
2738
2796
  details: requirements,
@@ -2753,15 +2811,21 @@ export default function planPiExtension(pi) {
2753
2811
  const st = await requirePlan(ctx);
2754
2812
  if (!st)
2755
2813
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2814
+ const title = params.title?.trim();
2815
+ if (!title)
2816
+ return { content: [{ type: "text", text: "title required" }], details: {} };
2817
+ const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
2818
+ if (!links.ok)
2819
+ return { content: [{ type: "text", text: links.error }], details: {} };
2756
2820
  const requirements = await st.loadRequirements();
2757
2821
  const now = nowISO();
2758
2822
  const requirement = {
2759
2823
  id: createRequirementId(),
2760
- title: params.title.trim(),
2824
+ title,
2761
2825
  description: params.description?.trim() ?? "",
2762
2826
  status: params.status?.trim() ?? "planned",
2763
2827
  macroTasks: [],
2764
- linkedPhaseIds: (params.linkedPhaseIds ?? []).map((entry) => entry.trim()).filter(Boolean),
2828
+ linkedPhaseIds: links.linkedPhaseIds,
2765
2829
  createdAt: now,
2766
2830
  updatedAt: now,
2767
2831
  };
@@ -2790,14 +2854,22 @@ export default function planPiExtension(pi) {
2790
2854
  const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
2791
2855
  if (!requirement)
2792
2856
  return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
2793
- if (params.title !== undefined)
2794
- requirement.title = params.title.trim();
2857
+ if (params.title !== undefined) {
2858
+ const title = params.title.trim();
2859
+ if (!title)
2860
+ return { content: [{ type: "text", text: "title required" }], details: {} };
2861
+ requirement.title = title;
2862
+ }
2795
2863
  if (params.description !== undefined)
2796
2864
  requirement.description = params.description.trim();
2797
2865
  if (params.status !== undefined)
2798
2866
  requirement.status = params.status.trim();
2799
- if (params.linkedPhaseIds !== undefined)
2800
- requirement.linkedPhaseIds = params.linkedPhaseIds.map((entry) => entry.trim()).filter(Boolean);
2867
+ if (params.linkedPhaseIds !== undefined) {
2868
+ const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
2869
+ if (!links.ok)
2870
+ return { content: [{ type: "text", text: links.error }], details: {} };
2871
+ requirement.linkedPhaseIds = links.linkedPhaseIds;
2872
+ }
2801
2873
  requirement.updatedAt = nowISO();
2802
2874
  await st.saveRequirements(requirements);
2803
2875
  await st.writeGenerated();
@@ -3007,6 +3079,23 @@ export default function planPiExtension(pi) {
3007
3079
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
3008
3080
  return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
3009
3081
  }
3082
+ async function resolveRequirementPhaseRefs(st, refs) {
3083
+ if (!Array.isArray(refs) || refs.length === 0) {
3084
+ return { ok: false, error: "linkedPhaseIds must contain at least one phase" };
3085
+ }
3086
+ const trimmed = refs.map((entry) => entry.trim()).filter(Boolean);
3087
+ if (trimmed.length === 0) {
3088
+ return { ok: false, error: "linkedPhaseIds must contain non-empty phase references" };
3089
+ }
3090
+ const phases = await st.loadAllPhases();
3091
+ const features = (await st.loadFeatures()).features;
3092
+ const resolved = trimmed.map((ref) => findPhaseByRef(phases, features, ref));
3093
+ const missingIndex = resolved.findIndex((phase) => !phase);
3094
+ if (missingIndex !== -1) {
3095
+ return { ok: false, error: `linked phase not found: ${trimmed[missingIndex]}` };
3096
+ }
3097
+ return { ok: true, linkedPhaseIds: [...new Set(resolved.map((phase) => phase.id))] };
3098
+ }
3010
3099
  pi.registerTool({
3011
3100
  name: "handoff_list",
3012
3101
  label: "Handoff List",
@@ -3063,11 +3152,13 @@ export default function planPiExtension(pi) {
3063
3152
  // Derive the current first non-empty line (H1) of the supplied body.
3064
3153
  const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
3065
3154
  const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
3066
- const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
3067
- if (!title && isGeneric) {
3155
+ const genericTitlePattern = /^(handoff|canonical handoff|session handoff)$/i;
3156
+ const effectiveHeadingText = (title && title.length > 0) ? title : firstHeadingText;
3157
+ const isGeneric = !effectiveHeadingText || genericTitlePattern.test(effectiveHeadingText);
3158
+ if (isGeneric) {
3068
3159
  return {
3069
3160
  content: [{ type: "text", text: "❌ Generic handoff title. Provide a meaningful `title` (or start your markdown with a descriptive H1) summarizing the work — e.g. 'P049 — featureId validation: tests + adapter wiring'. Generic titles like 'Handoff' or 'Canonical handoff' are not accepted." }],
3070
- details: { error: "generic title", firstHeadingText },
3161
+ details: { error: "generic title", firstHeadingText: effectiveHeadingText || firstHeadingText },
3071
3162
  };
3072
3163
  }
3073
3164
  // Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
@@ -3177,6 +3268,7 @@ export default function planPiExtension(pi) {
3177
3268
  if (!resolvedFeature.ok)
3178
3269
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3179
3270
  const feature = resolvedFeature.feature;
3271
+ markFeatureRead(feature.id);
3180
3272
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
3181
3273
  const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3182
3274
  const summary = `${feature.name} — ${formatFeatureRef(feature.number)}${feature.shortId ? ` · ${feature.shortId}` : ""} (${feature.status}; ${phases.length} phases${linkedRequirements.length ? `; ${linkedRequirements.length} linked requirement${linkedRequirements.length === 1 ? "" : "s"}` : ""})`;
@@ -3475,6 +3567,7 @@ export default function planPiExtension(pi) {
3475
3567
  const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3476
3568
  if (!phase)
3477
3569
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3570
+ markPhaseRead(phase.id, phase.featureId);
3478
3571
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3479
3572
  const reqCount = linkedRequirements.length;
3480
3573
  const summary = `${phase.title} — ${formatPhaseRef(phase.number, featureNumberOfPhase(phase, features))}${phase.shortId ? ` · ${phase.shortId}` : ""} (${phase.status}; ${phase.tasks.length} tasks${reqCount ? `; ${reqCount} linked requirement${reqCount === 1 ? "" : "s"}` : ""})`;
@@ -3499,6 +3592,9 @@ export default function planPiExtension(pi) {
3499
3592
  const st = await requirePlan(ctx);
3500
3593
  if (!st)
3501
3594
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3595
+ const title = params.title?.trim();
3596
+ if (!title)
3597
+ return { content: [{ type: "text", text: "title required" }], details: {} };
3502
3598
  if (!params.featureId?.trim())
3503
3599
  return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
3504
3600
  const createPhaseFeatures = (await st.loadFeatures()).features;
@@ -3519,7 +3615,7 @@ export default function planPiExtension(pi) {
3519
3615
  const priority = await st.nextPriority("phase", featureId);
3520
3616
  const now = nowISO();
3521
3617
  phase = {
3522
- id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3618
+ id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(title), title,
3523
3619
  featureId,
3524
3620
  status: params.status ?? "draft",
3525
3621
  discussedAt: "",
@@ -3809,10 +3905,10 @@ export default function planPiExtension(pi) {
3809
3905
  pi.registerTool({
3810
3906
  name: "task_get",
3811
3907
  label: "Task Get",
3812
- description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description + statusLog.",
3908
+ description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, resume checkpoint/advisory, and statusLog.",
3813
3909
  parameters: Type.Object({
3814
3910
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3815
- full: Type.Optional(Type.Boolean({ description: "If true, include the task description + statusLog. Default: compact identity only." })),
3911
+ full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
3816
3912
  }),
3817
3913
  async execute(_id, params, _signal, _onUpdate, ctx) {
3818
3914
  const st = await requirePlan(ctx);
@@ -3822,11 +3918,36 @@ export default function planPiExtension(pi) {
3822
3918
  const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3823
3919
  if (!found)
3824
3920
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3921
+ markTaskRead(found.task.id, found.phase.id, found.phase.featureId);
3825
3922
  const summary = `${found.task.title} — ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${pad(found.task.number)}${found.task.shortId ? ` · ${found.task.shortId}` : ""} (${found.task.status})`;
3826
3923
  if (!params.full)
3827
3924
  return { content: [{ type: "text", text: summary }], details: {} };
3925
+ const project = await st.loadProject();
3926
+ const pendingDeviation = found.task.status === "done" || found.task.status === "canceled" || found.task.status === "rejected"
3927
+ ? undefined
3928
+ : project.workDeviations
3929
+ .filter((deviation) => deviation.resumeTaskId === found.task.id
3930
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
3931
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
3932
+ const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
3828
3933
  const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
3829
- return { content: [{ type: "text", text: `${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}` }], details: {} };
3934
+ const sections = [summary];
3935
+ if (found.task.description?.trim())
3936
+ sections.push(found.task.description.trim());
3937
+ if (snapshot || pendingDeviation) {
3938
+ sections.push([
3939
+ pendingDeviation
3940
+ ? "Resume advisory: this task has a saved checkpoint that should be evaluated before selecting other work."
3941
+ : "Resume checkpoint:",
3942
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "",
3943
+ snapshot ? `Work checkpoint: ${snapshot.whatWasBeingDone}` : "",
3944
+ snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
3945
+ snapshot ? `How to resume: ${snapshot.howToResume}` : "",
3946
+ ].filter(Boolean).join("\n"));
3947
+ }
3948
+ if (log)
3949
+ sections.push(`Status log:\n${log}`);
3950
+ return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
3830
3951
  },
3831
3952
  });
3832
3953
  pi.registerTool({
@@ -3846,6 +3967,9 @@ export default function planPiExtension(pi) {
3846
3967
  const st = await requirePlan(ctx);
3847
3968
  if (!st)
3848
3969
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3970
+ const title = params.title?.trim();
3971
+ if (!title)
3972
+ return { content: [{ type: "text", text: "title required" }], details: {} };
3849
3973
  if (!params.featureId?.trim())
3850
3974
  return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
3851
3975
  const features = (await st.loadFeatures()).features;
@@ -3872,7 +3996,7 @@ export default function planPiExtension(pi) {
3872
3996
  const existingPhase = await st.loadPhase(phaseId);
3873
3997
  if (!existingPhase)
3874
3998
  return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
3875
- const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3999
+ const shortName = clampSlug(params.shortName ?? title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3876
4000
  const taskId = createTaskId();
3877
4001
  const identity = await st.allocateEntityIdentity("task", taskId);
3878
4002
  const priority = await st.nextPriority("task", phaseId);
@@ -3883,7 +4007,7 @@ export default function planPiExtension(pi) {
3883
4007
  }
3884
4008
  const task = {
3885
4009
  id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
3886
- title: params.title,
4010
+ title,
3887
4011
  status: initialStatus,
3888
4012
  description: params.description ?? "",
3889
4013
  descriptionUpdatedAt: now,
@@ -3953,6 +4077,12 @@ export default function planPiExtension(pi) {
3953
4077
  // Validate the motivation requirement for restrictive status transitions,
3954
4078
  // mirroring the MCP adapter and the /planner task update command
3955
4079
  // (AGENTS.md rule 8 — rejected writes must leave the files unchanged).
4080
+ if (found.task.pauseSnapshot && params.status === "in-progress" && params.status !== found.task.status) {
4081
+ return {
4082
+ content: [{ type: "text", text: "Resume checkpoint lifecycle transitions require task_start so the checkpoint and return stack remain consistent." }],
4083
+ details: {},
4084
+ };
4085
+ }
3956
4086
  if (params.status !== undefined &&
3957
4087
  params.status !== found.task.status &&
3958
4088
  needsMotivation(found.task.status, params.status)) {
@@ -4131,7 +4261,8 @@ export default function planPiExtension(pi) {
4131
4261
  resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(), relatedTaskId: "",
4132
4262
  pausedAt: nowISO(), pausedBy: params.paused_by?.trim() ?? "",
4133
4263
  };
4134
- const paused = await st.pauseTask(found.phase.id, found.task.id, snapshot);
4264
+ invalidateReads();
4265
+ const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
4135
4266
  await st.syncTaskStatusRollup(found.phase.id);
4136
4267
  const activeDeviation = (await st.loadProject()).workDeviations
4137
4268
  .filter((deviation) => (deviation.state === "approved" || deviation.state === "active")
@@ -4143,20 +4274,20 @@ export default function planPiExtension(pi) {
4143
4274
  ? findTaskByRef(await st.loadAllPhases(), features, activeDeviation.resumeTaskId)
4144
4275
  : undefined;
4145
4276
  await st.writeGenerated();
4146
- const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(paused.number).padStart(3, "0")}`;
4277
+ const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(checkpointed.number).padStart(3, "0")}`;
4147
4278
  const resumeRef = resume
4148
4279
  ? `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features))}/T${String(resume.task.number).padStart(3, "0")}`
4149
4280
  : "";
4150
4281
  return {
4151
4282
  content: [{ type: "text", text: [
4152
- `⏸️ Task paused: ${reference} — ${paused.title}`,
4283
+ `💾 Resume checkpoint saved: ${reference} — ${checkpointed.title}`,
4153
4284
  `Why: ${snapshot.reason}`,
4154
4285
  `Checkpoint: ${snapshot.whatWasBeingDone}`,
4155
4286
  `Resume from: ${snapshot.resumeLocation}`,
4156
4287
  `How to resume: ${snapshot.howToResume}`,
4157
4288
  resume ? `↩️ RESUME REQUIRED: ${resumeRef} — ${resume.task.title}` : "",
4158
4289
  ].filter(Boolean).join("\n") }],
4159
- details: { task: paused, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) },
4290
+ details: { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) },
4160
4291
  };
4161
4292
  },
4162
4293
  });
@@ -4187,8 +4318,8 @@ export default function planPiExtension(pi) {
4187
4318
  return { content: [{ type: "text", text: `Task not found: ${params.to_task}` }], details: {} };
4188
4319
  if (source.task.id === target.task.id)
4189
4320
  return { content: [{ type: "text", text: "Source and temporary task must differ." }], details: {} };
4190
- if (source.task.status !== "in-progress" && source.task.status !== "paused") {
4191
- return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or pending-resume paused work can be switched.` }], details: {} };
4321
+ if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
4322
+ return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
4192
4323
  }
4193
4324
  const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
4194
4325
  if (!eligibility.eligible)
@@ -4218,7 +4349,7 @@ export default function planPiExtension(pi) {
4218
4349
  await st.updatePhase(source.phase.id, (phase) => {
4219
4350
  const task = phase.tasks.find((entry) => entry.id === source.task.id);
4220
4351
  if (!task)
4221
- throw new Error(`Paused source task disappeared: ${params.from_task}`);
4352
+ throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
4222
4353
  task.pauseSnapshot = snapshot;
4223
4354
  task.pauseHistory = [...task.pauseHistory, snapshot];
4224
4355
  task.updatedAt = timestamp;
@@ -4226,7 +4357,7 @@ export default function planPiExtension(pi) {
4226
4357
  });
4227
4358
  }
4228
4359
  sourceCheckpointed = true;
4229
- if (target.task.status === "paused") {
4360
+ if (target.task.pauseSnapshot) {
4230
4361
  await st.resumeTask(target.phase.id, target.task.id, timestamp);
4231
4362
  }
4232
4363
  else {
@@ -4248,7 +4379,7 @@ export default function planPiExtension(pi) {
4248
4379
  }
4249
4380
  targetStarted = true;
4250
4381
  await st.addWorkDeviation(record);
4251
- if (target.task.status === "paused") {
4382
+ if (target.task.pauseSnapshot) {
4252
4383
  const resumedDeviation = project.workDeviations
4253
4384
  .filter((deviation) => deviation.resumeTaskId === target.task.id
4254
4385
  && (deviation.state === "resume-required" || deviation.state === "resolved"))
@@ -4281,21 +4412,28 @@ export default function planPiExtension(pi) {
4281
4412
  await st.writeGenerated();
4282
4413
  const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
4283
4414
  const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
4415
+ const targetReadAdvisory = parentReadAdvisory(target.phase.featureId, target.phase.id);
4416
+ const targetRequirementAdvisory = requirementReadAdvisory([
4417
+ ...(await st.linkedRequirementsForPhase(target.phase.id)).map((r) => r.id),
4418
+ ...(target.phase.featureId ? (await st.linkedRequirementsForFeature(target.phase.featureId)).map((r) => r.id) : []),
4419
+ ]);
4284
4420
  return {
4285
4421
  content: [{ type: "text", text: [
4286
4422
  `🔀 Task switched: ${sourceRef} → ${targetRef}`,
4287
- `Paused snapshot: ${snapshot.whatWasBeingDone}`,
4423
+ `Resume checkpoint: ${snapshot.whatWasBeingDone}`,
4288
4424
  `Return target: ${sourceRef} from ${snapshot.resumeLocation}`,
4289
4425
  "Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.",
4426
+ ...(targetReadAdvisory ? [targetReadAdvisory.trimStart()] : []),
4427
+ ...(targetRequirementAdvisory ? [targetRequirementAdvisory.trimStart()] : []),
4290
4428
  ].join("\n") }],
4291
- details: { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id },
4429
+ details: { deviation: record, snapshot, resumeTaskId: source.task.id, temporaryTaskId: target.task.id, ...(targetReadAdvisory || targetRequirementAdvisory ? { readRequired: true } : {}) },
4292
4430
  };
4293
4431
  },
4294
4432
  });
4295
4433
  pi.registerTool({
4296
4434
  name: "task_start",
4297
4435
  label: "Task Start",
4298
- description: "Set a task to in-progress or resume paused work. BEFORE calling, read feature_get(full=true), phase_get(full=true), then task_get(full=true). A different active task must first be suspended through task_switch. Otherwise follow active/pending-resume/feature → phase → task priority guidance.",
4436
+ description: "Set a task to in-progress or resume checkpointed work. BEFORE calling, read feature_get(full=true), phase_get(full=true), then task_get(full=true). A different active task must first be suspended through task_switch. Otherwise follow active/pending-resume/feature → phase → task priority guidance.",
4299
4437
  parameters: Type.Object({
4300
4438
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
4301
4439
  }),
@@ -4325,9 +4463,27 @@ export default function planPiExtension(pi) {
4325
4463
  const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
4326
4464
  return { content: [{ type: "text", text: `Task start denied: ${activeRef} is active. Use task_switch to snapshot and pause it before starting another task.` }], details: selection };
4327
4465
  }
4328
- if (selection.kind === "resume" && selection.candidate?.task.id !== task.id) {
4329
- const resumeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
4330
- return { content: [{ type: "text", text: `Task start denied: ${resumeRef} is pending resume. Resume it now, or use task_switch from that paused task with a new checkpoint and reason.` }], details: selection };
4466
+ let resumeProposal;
4467
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
4468
+ // Advisory only: a pending resume must be surfaced loudly, but it must
4469
+ // not hard-block an explicit start of a different task. Capture a
4470
+ // structured proposal so the resume-first guidance is explicit and
4471
+ // machine-readable for the host/agent.
4472
+ const resumeTask = selection.candidate;
4473
+ const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
4474
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
4475
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
4476
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
4477
+ ?? null;
4478
+ resumeProposal = buildResumeRequiredProposal({
4479
+ ref: resumeRef,
4480
+ title: resumeTask.task.title,
4481
+ taskId: resumeTask.task.id,
4482
+ phaseId: resumeTask.phase.id,
4483
+ snapshot: resumeSnapshot
4484
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
4485
+ : null,
4486
+ });
4331
4487
  }
4332
4488
  // Assemble the mandatory parent context before changing task lifecycle state.
4333
4489
  // The output order is feature + its requirements, then phase + its requirements.
@@ -4338,11 +4494,13 @@ export default function planPiExtension(pi) {
4338
4494
  ]);
4339
4495
  const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
4340
4496
  const advisory = selection.candidate && selection.candidate.task.id !== task.id
4341
- ? `\n\n⚠️ ${selection.kind === "resume" ? "Resume" : "Priority"} advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is the automatic recommendation. Explicit task request honored.`
4497
+ ? selection.kind === "resume"
4498
+ ? `\n\n${resumeProposal ? resumeProposal.text : `⚠️ Resume advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} has a saved checkpoint. Evaluate its resume context before continuing with this explicit task request. Explicit task request honored.`}`
4499
+ : `\n\n⚠️ Priority advisory: ${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")} is the automatic recommendation. Explicit task request honored.`
4342
4500
  : "";
4343
4501
  const now = nowISO();
4344
4502
  let startedTask;
4345
- if (task.status === "paused") {
4503
+ if (task.pauseSnapshot) {
4346
4504
  startedTask = await st.resumeTask(found.phase.id, found.task.id, now);
4347
4505
  }
4348
4506
  else {
@@ -4376,7 +4534,17 @@ export default function planPiExtension(pi) {
4376
4534
  await st.writeGenerated();
4377
4535
  if (!startedTask)
4378
4536
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4379
- return { content: [{ type: "text", text: `✅ Task started: ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(startedTask.number).padStart(3, "0")} — ${startedTask.title} (in-progress)${startedTask.shortId ? ` · ${startedTask.shortId}` : ""}${phaseContext}${advisory}` }], details: startedTask };
4537
+ const readAdvisory = parentReadAdvisory(parentFeature?.id, found.phase.id);
4538
+ const requirementAdvisory = requirementReadAdvisory([
4539
+ ...(phaseWithRequirements.linkedRequirements ?? []).map((r) => r.id),
4540
+ ...featureRequirements.map((r) => r.id),
4541
+ ]);
4542
+ return {
4543
+ content: [{ type: "text", text: `✅ Task started: ${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(startedTask.number).padStart(3, "0")} — ${startedTask.title} (in-progress)${startedTask.shortId ? ` · ${startedTask.shortId}` : ""}${phaseContext}${advisory}${readAdvisory}${requirementAdvisory}` }],
4544
+ details: startedTask,
4545
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
4546
+ ...(readAdvisory || requirementAdvisory ? { readRequired: true } : {}),
4547
+ };
4380
4548
  },
4381
4549
  });
4382
4550
  pi.registerTool({
@@ -4399,8 +4567,8 @@ export default function planPiExtension(pi) {
4399
4567
  const task = found.task;
4400
4568
  if (task.status === "done")
4401
4569
  return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
4402
- if (task.status === "paused")
4403
- return { content: [{ type: "text", text: "Task completion denied: resume paused work with task_start before completing it, or cancel it explicitly with motivation." }], details: task };
4570
+ if (task.pauseSnapshot)
4571
+ return { content: [{ type: "text", text: "Task completion denied: resume checkpointed work with task_start before completing it, or cancel it explicitly with motivation." }], details: task };
4404
4572
  const unchecked = task.checklist.filter((item) => !item.checked);
4405
4573
  if (unchecked.length > 0 && !params.force) {
4406
4574
  return {
@@ -4449,7 +4617,7 @@ export default function planPiExtension(pi) {
4449
4617
  resumeNotice = [
4450
4618
  "",
4451
4619
  `↩️ RESUME REQUIRED: ${resumeRef} — ${resume.task.title}`,
4452
- snapshot ? `Paused because: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
4620
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
4453
4621
  snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
4454
4622
  snapshot ? `How to resume: ${snapshot.howToResume}` : "",
4455
4623
  `Next action: task_start ${resumeRef}`,