@agent-plan/pi-adapter 0.2.22 โ†’ 0.2.23

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, packageVersionFromModule, resolvedPackageVersion } 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, contextReadEligibility, hasReadRequirements, 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";
@@ -243,10 +244,11 @@ async function getPlannerExecutionGuard(st) {
243
244
  ? allTasks.find(({ task }) => task.id === resume.inProgressTaskIds[0])
244
245
  : undefined;
245
246
  const focusFromCurrentPhase = resume?.currentPhaseId
246
- ? 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")
247
248
  : undefined;
248
- const fallbackFocus = allTasks.find(({ task }) => task.status === "planned" || task.status === "blocked")
249
- ?? 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");
250
252
  const focus = focusFromResume ?? focusFromCurrentPhase ?? fallbackFocus;
251
253
  return {
252
254
  totalTasks,
@@ -317,7 +319,8 @@ async function buildHandoffMarkdown(st, reason, overrides) {
317
319
  ?? null;
318
320
  const currentTask = currentPhase?.tasks.find((task) => resume.inProgressTaskIds.includes(task.id))
319
321
  ?? currentPhase?.tasks.find((task) => task.status === "in-progress")
320
- ?? [...(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]
321
324
  ?? latestTaskUpdate?.task
322
325
  ?? null;
323
326
  const currentFeature = currentPhase?.featureId
@@ -555,6 +558,7 @@ function statusIcon(status) {
555
558
  const icons = {
556
559
  draft: "๐Ÿ“„", discovery: "๐Ÿ”", planned: "๐Ÿ“‹",
557
560
  "in-progress": "๐Ÿšง", done: "โœ…", blocked: "๐Ÿšซ", canceled: "โŒ",
561
+ rejected: "โŒ", deferred: "โณ", waiting: "โฑ๏ธ",
558
562
  };
559
563
  return icons[status] ?? "โ“";
560
564
  }
@@ -626,20 +630,40 @@ function resolveStaticDir() {
626
630
  const adapterDir = dirname(adapterFile);
627
631
  // When installed as Pi package: node_modules/@agent-plan/pi-adapter/web-ui-dist/
628
632
  const pkgWebUi = join(adapterDir, "..", "web-ui-dist");
629
- if (existsSync(pkgWebUi))
630
- return pkgWebUi;
631
- // Fallback: monorepo dev โ€” packages/plan-web-ui/dist/
632
- 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];
633
659
  }
634
660
  catch {
635
661
  return undefined;
636
662
  }
637
663
  }
638
664
  async function maybeStartWeb(ctx) {
639
- if (server) {
640
- plannerSessionEnabled = true;
665
+ if (server)
641
666
  return;
642
- }
643
667
  ctx.ui.notify("Starting web server (LAN)โ€ฆ", "info");
644
668
  try {
645
669
  await startServer(ctx, undefined, "lan");
@@ -661,7 +685,6 @@ function normalizeVisibility(input) {
661
685
  return undefined;
662
686
  }
663
687
  async function startServer(ctx, requestedPort, visibility = "lan") {
664
- plannerSessionEnabled = true;
665
688
  if (server)
666
689
  return;
667
690
  const host = visibility === "lan" ? "0.0.0.0" : "127.0.0.1";
@@ -733,6 +756,10 @@ async function startServer(ctx, requestedPort, visibility = "lan") {
733
756
  catch { }
734
757
  }
735
758
  }
759
+ function enablePlannerSession() {
760
+ plannerSessionEnabled = true;
761
+ contextBlockDirty = true;
762
+ }
736
763
  function disablePlannerSession() {
737
764
  plannerSessionEnabled = false;
738
765
  startupResumePromptPending = false;
@@ -759,6 +786,7 @@ export default function planPiExtension(pi) {
759
786
  capturedPi = pi;
760
787
  // โ”€โ”€ Restore on session start/reload โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
761
788
  pi.on("session_start", async (_event, ctx) => {
789
+ invalidateReads();
762
790
  try {
763
791
  ctx.ui.addAutocompleteProvider((current) => ({
764
792
  triggerCharacters: ["/", " "],
@@ -1134,6 +1162,7 @@ export default function planPiExtension(pi) {
1134
1162
  await st.writeGenerated();
1135
1163
  await scanCodebase();
1136
1164
  ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discussโ€ฆ`, "info");
1165
+ enablePlannerSession();
1137
1166
  await maybeStartWeb(ctx);
1138
1167
  await handlePlanner("project discuss", ctx);
1139
1168
  return;
@@ -1151,6 +1180,7 @@ export default function planPiExtension(pi) {
1151
1180
  await st.writeGenerated();
1152
1181
  await scanCodebase();
1153
1182
  ctx.ui.notify(`.planner/ initialized for "${name.trim()}". Starting project discussโ€ฆ`, "info");
1183
+ enablePlannerSession();
1154
1184
  await maybeStartWeb(ctx);
1155
1185
  await handlePlanner("project discuss", ctx);
1156
1186
  return;
@@ -1182,12 +1212,12 @@ export default function planPiExtension(pi) {
1182
1212
  .sort((a, b) => a.number - b.number);
1183
1213
  if (features.length === 0)
1184
1214
  return null;
1185
- const options = features.map((f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` ยท ${f.shortId}` : ""}`);
1186
- const chosen = await ctx.ui.select("Pick a feature", options);
1187
- if (chosen == null)
1188
- return null;
1189
- const idx = options.indexOf(chosen);
1190
- return idx >= 0 ? features[idx] : null;
1215
+ return paginatedSelect(ctx, {
1216
+ title: "Pick a feature",
1217
+ items: features,
1218
+ render: (f) => ` ${formatFeatureRef(f.number)} ${statusIcon(f.status)} ${f.name}${f.shortId ? ` ยท ${f.shortId}` : ""}`,
1219
+ pageSize: 10,
1220
+ });
1191
1221
  }
1192
1222
  // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1193
1223
  // Helper: pick a phase interactively
@@ -1198,12 +1228,12 @@ export default function planPiExtension(pi) {
1198
1228
  return null;
1199
1229
  }
1200
1230
  const features = (await st.loadFeatures().catch(() => ({ features: [] }))).features;
1201
- const options = phases.map((p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` ยท ${p.shortId}` : ""}`);
1202
- const chosen = await ctx.ui.select("Pick a phase", options);
1203
- if (chosen == null)
1204
- return null;
1205
- const idx = options.indexOf(chosen);
1206
- return idx >= 0 ? phases[idx] : null;
1231
+ return paginatedSelect(ctx, {
1232
+ title: "Pick a phase",
1233
+ items: phases,
1234
+ render: (p) => ` ${formatPhaseRef(p.number, featureNumberOfPhase(p, features))} ${statusIcon(p.status)} ${p.title}${p.shortId ? ` ยท ${p.shortId}` : ""}`,
1235
+ pageSize: 10,
1236
+ });
1207
1237
  }
1208
1238
  // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
1209
1239
  // Helper: pick a task interactively from a phase
@@ -1212,12 +1242,12 @@ export default function planPiExtension(pi) {
1212
1242
  if (phase.tasks.length === 0)
1213
1243
  return null;
1214
1244
  const tasks = phase.tasks.slice().sort((a, b) => a.number - b.number);
1215
- const options = tasks.map((t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` ยท ${t.shortId}` : ""}`);
1216
- const chosen = await ctx.ui.select(`Pick a task from "${phase.title}"`, options);
1217
- if (chosen == null)
1218
- return null;
1219
- const idx = options.indexOf(chosen);
1220
- return idx >= 0 ? tasks[idx] : null;
1245
+ return paginatedSelect(ctx, {
1246
+ title: `Pick a task from "${phase.title}"`,
1247
+ items: tasks,
1248
+ render: (t) => ` T${String(t.number).padStart(3, "0")} ${statusIcon(t.status)} ${t.title}${t.shortId ? ` ยท ${t.shortId}` : ""}`,
1249
+ pageSize: 10,
1250
+ });
1221
1251
  }
1222
1252
  function parseMultilineList(value) {
1223
1253
  return (value ?? "")
@@ -1435,9 +1465,9 @@ export default function planPiExtension(pi) {
1435
1465
  if (b === "language") {
1436
1466
  const project = await st.loadProject();
1437
1467
  const contentLanguage = await ctx.ui.input(`Plan content language [${project.contentLanguage || "English"}]`);
1438
- const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "Italian"}]`);
1468
+ const chatLanguage = await ctx.ui.input(`Chat language [${project.chatLanguage || contentLanguage?.trim() || project.contentLanguage || "English"}]`);
1439
1469
  project.contentLanguage = contentLanguage?.trim() || project.contentLanguage || "English";
1440
- project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "Italian";
1470
+ project.chatLanguage = chatLanguage?.trim() || project.chatLanguage || project.contentLanguage || "English";
1441
1471
  await st.saveProject(project);
1442
1472
  await st.writeGenerated();
1443
1473
  ctx.ui.notify(`Saved language preferences: content=${project.contentLanguage}, chat=${project.chatLanguage}`, "info");
@@ -1461,11 +1491,12 @@ export default function planPiExtension(pi) {
1461
1491
  return;
1462
1492
  }
1463
1493
  const phases = await st.loadAllPhases();
1464
- ctx.ui.notify(features.map((feature) => {
1494
+ const lines = features.map((feature) => {
1465
1495
  const featurePhases = phases.filter((phase) => phase.featureId === feature.id);
1466
1496
  const taskCount = featurePhases.reduce((total, phase) => total + phase.tasks.length, 0);
1467
1497
  return `${statusIcon(feature.status)} ${feature.name} (${feature.id}) โ€” ${featurePhases.length} phases, ${taskCount} tasks`;
1468
- }).join("\n"), "info");
1498
+ });
1499
+ await paginatedNotify(ctx, { title: "features", lines, pageSize: 10 });
1469
1500
  return;
1470
1501
  }
1471
1502
  if (b === "add") {
@@ -2129,8 +2160,9 @@ export default function planPiExtension(pi) {
2129
2160
  ctx.ui.notify(`Invalid status. Use: ${valid.join(", ")}`, "error");
2130
2161
  return;
2131
2162
  }
2132
- if (normalizedStatus === "in-progress" && (await st.listHandoffs()).length > 0) {
2133
- ctx.ui.notify("โ„น๏ธ One or more phases have a pending handoff โ€” if relevant to this task, read it with /planner handoff show <ref>, then clear with /planner handoff clear. Proceeding with the status change.", "info");
2163
+ if (normalizedStatus === "in-progress" && normalizedStatus !== task.status) {
2164
+ ctx.ui.notify("Task start/resume transitions require /planner task start so ordered full context reads and lifecycle state remain consistent.", "warning");
2165
+ return;
2134
2166
  }
2135
2167
  applyTaskLifecycleDates(task, normalizedStatus, now);
2136
2168
  }
@@ -2168,6 +2200,21 @@ export default function planPiExtension(pi) {
2168
2200
  return;
2169
2201
  }
2170
2202
  }
2203
+ if (phase) {
2204
+ const contextEligibility = contextReadEligibility(task.id, phase.id, phase.featureId);
2205
+ if (!contextEligibility.eligible) {
2206
+ ctx.ui.notify(`Task start denied: context reads required. ${contextEligibility.reason}`, "warning");
2207
+ return;
2208
+ }
2209
+ const linkedRequirementIds = [
2210
+ ...(await st.linkedRequirementsForPhase(phase.id)).map((r) => r.id),
2211
+ ...(phase.featureId ? (await st.linkedRequirementsForFeature(phase.featureId)).map((r) => r.id) : []),
2212
+ ];
2213
+ if (!hasReadRequirements(linkedRequirementIds)) {
2214
+ ctx.ui.notify("Task start denied: read the requirements linked to this phase and feature first.", "warning");
2215
+ return;
2216
+ }
2217
+ }
2171
2218
  if (task.status === "in-progress") {
2172
2219
  ctx.ui.notify(`Task "${task.title}" is already in-progress.`, "info");
2173
2220
  return;
@@ -2341,7 +2388,11 @@ export default function planPiExtension(pi) {
2341
2388
  ctx.ui.notify("No phase handoffs set.", "info");
2342
2389
  return;
2343
2390
  }
2344
- ctx.ui.notify(list.map((e) => `โ€ข ${e.compositeRef} โ€” ${e.firstLine} (updated ${e.updatedAt})`).join("\n"), "info");
2391
+ await paginatedNotify(ctx, {
2392
+ title: "phase handoffs",
2393
+ lines: list.map((e) => `โ€ข ${e.compositeRef} โ€” ${e.firstLine} (updated ${e.updatedAt})`),
2394
+ pageSize: 10,
2395
+ });
2345
2396
  return;
2346
2397
  }
2347
2398
  if (action === "show") {
@@ -2514,7 +2565,7 @@ export default function planPiExtension(pi) {
2514
2565
  return;
2515
2566
  }
2516
2567
  if (a === "load") {
2517
- plannerSessionEnabled = true;
2568
+ enablePlannerSession();
2518
2569
  if (!server) {
2519
2570
  ctx.ui.notify("Starting web server (LAN) โ€ฆ", "info");
2520
2571
  await startServer(ctx, undefined, "lan").catch(() => { });
@@ -2747,6 +2798,7 @@ export default function planPiExtension(pi) {
2747
2798
  if (!st)
2748
2799
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2749
2800
  const requirements = await st.loadRequirements();
2801
+ requirements.requirements.forEach((req) => markRequirementRead(req.id));
2750
2802
  return {
2751
2803
  content: [{ type: "text", text: requirements.requirements.map((req) => `- ${req.id} โ€” ${req.title} (${req.status})`).join("\n") || "No requirements" }],
2752
2804
  details: requirements,
@@ -2767,15 +2819,21 @@ export default function planPiExtension(pi) {
2767
2819
  const st = await requirePlan(ctx);
2768
2820
  if (!st)
2769
2821
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
2822
+ const title = params.title?.trim();
2823
+ if (!title)
2824
+ return { content: [{ type: "text", text: "title required" }], details: {} };
2825
+ const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
2826
+ if (!links.ok)
2827
+ return { content: [{ type: "text", text: links.error }], details: {} };
2770
2828
  const requirements = await st.loadRequirements();
2771
2829
  const now = nowISO();
2772
2830
  const requirement = {
2773
2831
  id: createRequirementId(),
2774
- title: params.title.trim(),
2832
+ title,
2775
2833
  description: params.description?.trim() ?? "",
2776
2834
  status: params.status?.trim() ?? "planned",
2777
2835
  macroTasks: [],
2778
- linkedPhaseIds: (params.linkedPhaseIds ?? []).map((entry) => entry.trim()).filter(Boolean),
2836
+ linkedPhaseIds: links.linkedPhaseIds,
2779
2837
  createdAt: now,
2780
2838
  updatedAt: now,
2781
2839
  };
@@ -2804,14 +2862,22 @@ export default function planPiExtension(pi) {
2804
2862
  const requirement = requirements.requirements.find((entry) => entry.id === params.requirementId);
2805
2863
  if (!requirement)
2806
2864
  return { content: [{ type: "text", text: `Requirement not found: ${params.requirementId}` }], details: {} };
2807
- if (params.title !== undefined)
2808
- requirement.title = params.title.trim();
2865
+ if (params.title !== undefined) {
2866
+ const title = params.title.trim();
2867
+ if (!title)
2868
+ return { content: [{ type: "text", text: "title required" }], details: {} };
2869
+ requirement.title = title;
2870
+ }
2809
2871
  if (params.description !== undefined)
2810
2872
  requirement.description = params.description.trim();
2811
2873
  if (params.status !== undefined)
2812
2874
  requirement.status = params.status.trim();
2813
- if (params.linkedPhaseIds !== undefined)
2814
- requirement.linkedPhaseIds = params.linkedPhaseIds.map((entry) => entry.trim()).filter(Boolean);
2875
+ if (params.linkedPhaseIds !== undefined) {
2876
+ const links = await resolveRequirementPhaseRefs(st, params.linkedPhaseIds);
2877
+ if (!links.ok)
2878
+ return { content: [{ type: "text", text: links.error }], details: {} };
2879
+ requirement.linkedPhaseIds = links.linkedPhaseIds;
2880
+ }
2815
2881
  requirement.updatedAt = nowISO();
2816
2882
  await st.saveRequirements(requirements);
2817
2883
  await st.writeGenerated();
@@ -3021,6 +3087,23 @@ export default function planPiExtension(pi) {
3021
3087
  const feat = phase.featureId ? features.find((f) => f.id === phase.featureId) : undefined;
3022
3088
  return { ok: true, phase, compositeRef: formatPhaseRef(phase.number, feat?.number) };
3023
3089
  }
3090
+ async function resolveRequirementPhaseRefs(st, refs) {
3091
+ if (!Array.isArray(refs) || refs.length === 0) {
3092
+ return { ok: false, error: "linkedPhaseIds must contain at least one phase" };
3093
+ }
3094
+ const trimmed = refs.map((entry) => entry.trim()).filter(Boolean);
3095
+ if (trimmed.length === 0) {
3096
+ return { ok: false, error: "linkedPhaseIds must contain non-empty phase references" };
3097
+ }
3098
+ const phases = await st.loadAllPhases();
3099
+ const features = (await st.loadFeatures()).features;
3100
+ const resolved = trimmed.map((ref) => findPhaseByRef(phases, features, ref));
3101
+ const missingIndex = resolved.findIndex((phase) => !phase);
3102
+ if (missingIndex !== -1) {
3103
+ return { ok: false, error: `linked phase not found: ${trimmed[missingIndex]}` };
3104
+ }
3105
+ return { ok: true, linkedPhaseIds: [...new Set(resolved.map((phase) => phase.id))] };
3106
+ }
3024
3107
  pi.registerTool({
3025
3108
  name: "handoff_list",
3026
3109
  label: "Handoff List",
@@ -3077,11 +3160,13 @@ export default function planPiExtension(pi) {
3077
3160
  // Derive the current first non-empty line (H1) of the supplied body.
3078
3161
  const firstLine = body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
3079
3162
  const firstHeadingText = firstLine.replace(/^#+\s*/, "").trim();
3080
- const isGeneric = !firstHeadingText || /^(handoff|canonical handoff|session handoff)$/i.test(firstHeadingText);
3081
- if (!title && isGeneric) {
3163
+ const genericTitlePattern = /^(handoff|canonical handoff|session handoff)$/i;
3164
+ const effectiveHeadingText = (title && title.length > 0) ? title : firstHeadingText;
3165
+ const isGeneric = !effectiveHeadingText || genericTitlePattern.test(effectiveHeadingText);
3166
+ if (isGeneric) {
3082
3167
  return {
3083
3168
  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." }],
3084
- details: { error: "generic title", firstHeadingText },
3169
+ details: { error: "generic title", firstHeadingText: effectiveHeadingText || firstHeadingText },
3085
3170
  };
3086
3171
  }
3087
3172
  // Normalize: if a title is provided, replace or prepend the H1 so the first line is the title.
@@ -3191,6 +3276,8 @@ export default function planPiExtension(pi) {
3191
3276
  if (!resolvedFeature.ok)
3192
3277
  return { content: [{ type: "text", text: resolvedFeature.error }], details: {} };
3193
3278
  const feature = resolvedFeature.feature;
3279
+ if (params.full)
3280
+ markFeatureRead(feature.id);
3194
3281
  const phases = (await st.loadAllPhases()).filter((p) => p.featureId === feature.id);
3195
3282
  const linkedRequirements = await st.linkedRequirementsForFeature(feature.id);
3196
3283
  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"}` : ""})`;
@@ -3489,6 +3576,8 @@ export default function planPiExtension(pi) {
3489
3576
  const phase = findPhaseByRef(await st.loadAllPhases(), features, params.phaseId.trim());
3490
3577
  if (!phase)
3491
3578
  return { content: [{ type: "text", text: `Phase not found: ${params.phaseId}` }], details: {} };
3579
+ if (params.full)
3580
+ markPhaseRead(phase.id, phase.featureId);
3492
3581
  const linkedRequirements = await st.linkedRequirementsForPhase(phase.id);
3493
3582
  const reqCount = linkedRequirements.length;
3494
3583
  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"}` : ""})`;
@@ -3513,6 +3602,9 @@ export default function planPiExtension(pi) {
3513
3602
  const st = await requirePlan(ctx);
3514
3603
  if (!st)
3515
3604
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3605
+ const title = params.title?.trim();
3606
+ if (!title)
3607
+ return { content: [{ type: "text", text: "title required" }], details: {} };
3516
3608
  if (!params.featureId?.trim())
3517
3609
  return { content: [{ type: "text", text: "featureId is required: a phase must belong to a feature." }], details: {} };
3518
3610
  const createPhaseFeatures = (await st.loadFeatures()).features;
@@ -3533,7 +3625,7 @@ export default function planPiExtension(pi) {
3533
3625
  const priority = await st.nextPriority("phase", featureId);
3534
3626
  const now = nowISO();
3535
3627
  phase = {
3536
- id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(params.title), title: params.title,
3628
+ id, number: identity.number, shortId: identity.shortId, priority, slug: normalizeSlug(title), title,
3537
3629
  featureId,
3538
3630
  status: params.status ?? "draft",
3539
3631
  discussedAt: "",
@@ -3823,10 +3915,10 @@ export default function planPiExtension(pi) {
3823
3915
  pi.registerTool({
3824
3916
  name: "task_get",
3825
3917
  label: "Task Get",
3826
- description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description + statusLog.",
3918
+ description: "Show a task. Compact identity by default (saves tokens); pass full=true to include the description, resume checkpoint/advisory, and statusLog.",
3827
3919
  parameters: Type.Object({
3828
3920
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title" }),
3829
- full: Type.Optional(Type.Boolean({ description: "If true, include the task description + statusLog. Default: compact identity only." })),
3921
+ full: Type.Optional(Type.Boolean({ description: "If true, include the task description, resume checkpoint/advisory, and statusLog. Default: compact identity only." })),
3830
3922
  }),
3831
3923
  async execute(_id, params, _signal, _onUpdate, ctx) {
3832
3924
  const st = await requirePlan(ctx);
@@ -3836,11 +3928,37 @@ export default function planPiExtension(pi) {
3836
3928
  const found = findTaskByRef(await st.loadAllPhases(), features, params.taskId.trim());
3837
3929
  if (!found)
3838
3930
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
3931
+ if (params.full)
3932
+ markTaskRead(found.task.id, found.phase.id, found.phase.featureId);
3839
3933
  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})`;
3840
3934
  if (!params.full)
3841
3935
  return { content: [{ type: "text", text: summary }], details: {} };
3936
+ const project = await st.loadProject();
3937
+ const pendingDeviation = found.task.status === "done" || found.task.status === "canceled" || found.task.status === "rejected"
3938
+ ? undefined
3939
+ : project.workDeviations
3940
+ .filter((deviation) => deviation.resumeTaskId === found.task.id
3941
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))
3942
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0];
3943
+ const snapshot = found.task.pauseSnapshot ?? pendingDeviation?.snapshot ?? null;
3842
3944
  const log = (found.task.statusLog ?? []).map((e) => ` - ${e.date.slice(0, 10)} ${e.title}`).join("\n");
3843
- return { content: [{ type: "text", text: `${summary}\n\n${found.task.description || ""}${log ? `\n\nStatus log:\n${log}` : ""}` }], details: {} };
3945
+ const sections = [summary];
3946
+ if (found.task.description?.trim())
3947
+ sections.push(found.task.description.trim());
3948
+ if (snapshot || pendingDeviation) {
3949
+ sections.push([
3950
+ pendingDeviation
3951
+ ? "Resume advisory: this task has a saved checkpoint that should be evaluated before selecting other work."
3952
+ : "Resume checkpoint:",
3953
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "",
3954
+ snapshot ? `Work checkpoint: ${snapshot.whatWasBeingDone}` : "",
3955
+ snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
3956
+ snapshot ? `How to resume: ${snapshot.howToResume}` : "",
3957
+ ].filter(Boolean).join("\n"));
3958
+ }
3959
+ if (log)
3960
+ sections.push(`Status log:\n${log}`);
3961
+ return { content: [{ type: "text", text: sections.join("\n\n") }], details: {} };
3844
3962
  },
3845
3963
  });
3846
3964
  pi.registerTool({
@@ -3860,6 +3978,9 @@ export default function planPiExtension(pi) {
3860
3978
  const st = await requirePlan(ctx);
3861
3979
  if (!st)
3862
3980
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
3981
+ const title = params.title?.trim();
3982
+ if (!title)
3983
+ return { content: [{ type: "text", text: "title required" }], details: {} };
3863
3984
  if (!params.featureId?.trim())
3864
3985
  return { content: [{ type: "text", text: "featureId is required: a task must belong to a feature." }], details: {} };
3865
3986
  const features = (await st.loadFeatures()).features;
@@ -3886,18 +4007,18 @@ export default function planPiExtension(pi) {
3886
4007
  const existingPhase = await st.loadPhase(phaseId);
3887
4008
  if (!existingPhase)
3888
4009
  return { content: [{ type: "text", text: `Resolved phase ${phaseId} no longer exists. Refusing to create task.` }], details: {} };
3889
- const shortName = clampSlug(params.shortName ?? params.title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
4010
+ const initialStatus = params.status ?? "planned";
4011
+ if (initialStatus !== "planned") {
4012
+ return { content: [{ type: "text", text: "Tasks must be created planned. Use task_start for in-progress work and task_update for other justified status changes." }], details: {} };
4013
+ }
4014
+ const shortName = clampSlug(params.shortName ?? title, 30, `task-${Date.now().toString(36)}`); // clamp+strip trailing dash; never empty
3890
4015
  const taskId = createTaskId();
3891
4016
  const identity = await st.allocateEntityIdentity("task", taskId);
3892
4017
  const priority = await st.nextPriority("task", phaseId);
3893
4018
  const now = nowISO();
3894
- const initialStatus = params.status ?? "planned";
3895
- if (initialStatus === "paused") {
3896
- return { content: [{ type: "text", text: "A task cannot be created paused without an in-progress checkpoint. Create it planned, then use task_pause/task_switch." }], details: {} };
3897
- }
3898
4019
  const task = {
3899
4020
  id: taskId, phaseId, number: identity.number, shortId: identity.shortId, priority, shortName,
3900
- title: params.title,
4021
+ title,
3901
4022
  status: initialStatus,
3902
4023
  description: params.description ?? "",
3903
4024
  descriptionUpdatedAt: now,
@@ -3912,8 +4033,8 @@ export default function planPiExtension(pi) {
3912
4033
  dependsOn: [],
3913
4034
  pauseSnapshot: null,
3914
4035
  pauseHistory: [],
3915
- startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
3916
- completedAt: initialStatus === "done" ? now : "",
4036
+ startedAt: "",
4037
+ completedAt: "",
3917
4038
  createdAt: now, updatedAt: now,
3918
4039
  };
3919
4040
  // Atomic read-modify-write on the phase file: serializes concurrent
@@ -3967,6 +4088,12 @@ export default function planPiExtension(pi) {
3967
4088
  // Validate the motivation requirement for restrictive status transitions,
3968
4089
  // mirroring the MCP adapter and the /planner task update command
3969
4090
  // (AGENTS.md rule 8 โ€” rejected writes must leave the files unchanged).
4091
+ if (params.status === "in-progress" && params.status !== found.task.status) {
4092
+ return {
4093
+ content: [{ type: "text", text: "Task start/resume transitions require task_start so lifecycle state, checkpoints, and return stacks remain consistent." }],
4094
+ details: {},
4095
+ };
4096
+ }
3970
4097
  if (params.status !== undefined &&
3971
4098
  params.status !== found.task.status &&
3972
4099
  needsMotivation(found.task.status, params.status)) {
@@ -4061,14 +4188,14 @@ export default function planPiExtension(pi) {
4061
4188
  pi.registerTool({
4062
4189
  name: "task_recommend",
4063
4190
  label: "Task Recommend",
4064
- description: "Return the recommended next task without starting work: continue one active task, otherwise choose ready work by feature โ†’ phase โ†’ task priority. Reports approved deviation/resume context and never blocks an explicit user choice.",
4191
+ description: "Return the recommended next task without starting work: continue one active task or the current phase, otherwise choose ready work by feature โ†’ phase โ†’ task priority. Reports approved deviation/resume context and never blocks an explicit user choice.",
4065
4192
  parameters: Type.Object({}),
4066
4193
  async execute(_id, _params, _signal, _onUpdate, ctx) {
4067
4194
  const st = await requirePlan(ctx);
4068
4195
  if (!st)
4069
4196
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4070
- const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4071
- const result = recommendNextTask(features.features, phases, project.workDeviations);
4197
+ const [features, phases, project, resume] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
4198
+ const result = recommendNextTask(features.features, phases, project.workDeviations, resume?.currentPhaseId);
4072
4199
  if (!result.candidate)
4073
4200
  return { content: [{ type: "text", text: `No task recommendation: ${result.reason}` }], details: result };
4074
4201
  const { candidate } = result;
@@ -4092,11 +4219,11 @@ export default function planPiExtension(pi) {
4092
4219
  const st = await requirePlan(ctx);
4093
4220
  if (!st)
4094
4221
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4095
- const [features, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4222
+ const [features, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
4096
4223
  const temporary = findTaskByRef(phases, features.features, params.temporary_task.trim());
4097
4224
  if (!temporary)
4098
4225
  return { content: [{ type: "text", text: `Task not found: ${params.temporary_task}` }], details: {} };
4099
- const selected = recommendNextTask(features.features, phases, project.workDeviations);
4226
+ const selected = recommendNextTask(features.features, phases, project.workDeviations, focus?.currentPhaseId);
4100
4227
  const resume = params.resume_task ? findTaskByRef(phases, features.features, params.resume_task.trim()) : selected.candidate;
4101
4228
  if (!resume)
4102
4229
  return { content: [{ type: "text", text: `No resume task is available. Provide resume_task explicitly. ${selected.reason}` }], details: selected };
@@ -4145,7 +4272,8 @@ export default function planPiExtension(pi) {
4145
4272
  resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(), relatedTaskId: "",
4146
4273
  pausedAt: nowISO(), pausedBy: params.paused_by?.trim() ?? "",
4147
4274
  };
4148
- const paused = await st.pauseTask(found.phase.id, found.task.id, snapshot);
4275
+ invalidateReads();
4276
+ const checkpointed = await st.pauseTask(found.phase.id, found.task.id, snapshot);
4149
4277
  await st.syncTaskStatusRollup(found.phase.id);
4150
4278
  const activeDeviation = (await st.loadProject()).workDeviations
4151
4279
  .filter((deviation) => (deviation.state === "approved" || deviation.state === "active")
@@ -4157,20 +4285,20 @@ export default function planPiExtension(pi) {
4157
4285
  ? findTaskByRef(await st.loadAllPhases(), features, activeDeviation.resumeTaskId)
4158
4286
  : undefined;
4159
4287
  await st.writeGenerated();
4160
- const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(paused.number).padStart(3, "0")}`;
4288
+ const reference = `${formatPhaseRef(found.phase.number, featureNumberOfPhase(found.phase, features))}/T${String(checkpointed.number).padStart(3, "0")}`;
4161
4289
  const resumeRef = resume
4162
4290
  ? `${formatPhaseRef(resume.phase.number, featureNumberOfPhase(resume.phase, features))}/T${String(resume.task.number).padStart(3, "0")}`
4163
4291
  : "";
4164
4292
  return {
4165
4293
  content: [{ type: "text", text: [
4166
- `โธ๏ธ Task paused: ${reference} โ€” ${paused.title}`,
4294
+ `๐Ÿ’พ Resume checkpoint saved: ${reference} โ€” ${checkpointed.title}`,
4167
4295
  `Why: ${snapshot.reason}`,
4168
4296
  `Checkpoint: ${snapshot.whatWasBeingDone}`,
4169
4297
  `Resume from: ${snapshot.resumeLocation}`,
4170
4298
  `How to resume: ${snapshot.howToResume}`,
4171
4299
  resume ? `โ†ฉ๏ธ RESUME REQUIRED: ${resumeRef} โ€” ${resume.task.title}` : "",
4172
4300
  ].filter(Boolean).join("\n") }],
4173
- details: { task: paused, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) },
4301
+ details: { task: checkpointed, snapshot, ...(resume ? { resumeRequired: { taskId: resume.task.id } } : {}) },
4174
4302
  };
4175
4303
  },
4176
4304
  });
@@ -4191,7 +4319,7 @@ export default function planPiExtension(pi) {
4191
4319
  const st = await requirePlan(ctx);
4192
4320
  if (!st)
4193
4321
  return { content: [{ type: "text", text: "No .planner/ found." }], details: {} };
4194
- const [featuresDoc, phases, project] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject()]);
4322
+ const [featuresDoc, phases, project, focus] = await Promise.all([st.loadFeatures(), st.loadAllPhases(), st.loadProject(), st.loadResume()]);
4195
4323
  const features = featuresDoc.features;
4196
4324
  const source = findTaskByRef(phases, features, params.from_task.trim());
4197
4325
  const target = findTaskByRef(phases, features, params.to_task.trim());
@@ -4201,8 +4329,19 @@ export default function planPiExtension(pi) {
4201
4329
  return { content: [{ type: "text", text: `Task not found: ${params.to_task}` }], details: {} };
4202
4330
  if (source.task.id === target.task.id)
4203
4331
  return { content: [{ type: "text", text: "Source and temporary task must differ." }], details: {} };
4204
- if (source.task.status !== "in-progress" && source.task.status !== "paused") {
4205
- 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: {} };
4332
+ if (source.task.status !== "in-progress" && !source.task.pauseSnapshot) {
4333
+ return { content: [{ type: "text", text: `Task switch denied: source is ${source.task.status}; only in-progress or checkpointed work can be switched.` }], details: {} };
4334
+ }
4335
+ const contextEligibility = contextReadEligibility(target.task.id, target.phase.id, target.phase.featureId);
4336
+ if (!contextEligibility.eligible) {
4337
+ return { content: [{ type: "text", text: `Task switch denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
4338
+ }
4339
+ const linkedRequirementIds = [
4340
+ ...(await st.linkedRequirementsForPhase(target.phase.id)).map((requirement) => requirement.id),
4341
+ ...(target.phase.featureId ? (await st.linkedRequirementsForFeature(target.phase.featureId)).map((requirement) => requirement.id) : []),
4342
+ ];
4343
+ if (!hasReadRequirements(linkedRequirementIds)) {
4344
+ return { content: [{ type: "text", text: "Task switch denied: read the requirements linked to the target phase and feature before switching." }], details: { requirementIds: linkedRequirementIds } };
4206
4345
  }
4207
4346
  const eligibility = checkExplicitTaskStart(features, phases, target.task.id, project.workDeviations);
4208
4347
  if (!eligibility.eligible)
@@ -4213,7 +4352,7 @@ export default function planPiExtension(pi) {
4213
4352
  resumeLocation: params.resume_location.trim(), howToResume: params.how_to_resume.trim(),
4214
4353
  relatedTaskId: target.task.id, pausedAt: timestamp, pausedBy: params.switched_by?.trim() ?? "",
4215
4354
  };
4216
- const selection = recommendNextTask(features, phases, project.workDeviations);
4355
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
4217
4356
  const record = {
4218
4357
  id: crypto.randomUUID(), recommendedTaskId: selection.candidate?.task.id ?? source.task.id,
4219
4358
  temporaryTaskId: target.task.id, resumeTaskId: source.task.id, reason: snapshot.reason, snapshot,
@@ -4232,7 +4371,7 @@ export default function planPiExtension(pi) {
4232
4371
  await st.updatePhase(source.phase.id, (phase) => {
4233
4372
  const task = phase.tasks.find((entry) => entry.id === source.task.id);
4234
4373
  if (!task)
4235
- throw new Error(`Paused source task disappeared: ${params.from_task}`);
4374
+ throw new Error(`Checkpointed source task disappeared: ${params.from_task}`);
4236
4375
  task.pauseSnapshot = snapshot;
4237
4376
  task.pauseHistory = [...task.pauseHistory, snapshot];
4238
4377
  task.updatedAt = timestamp;
@@ -4240,7 +4379,7 @@ export default function planPiExtension(pi) {
4240
4379
  });
4241
4380
  }
4242
4381
  sourceCheckpointed = true;
4243
- if (target.task.status === "paused") {
4382
+ if (target.task.pauseSnapshot) {
4244
4383
  await st.resumeTask(target.phase.id, target.task.id, timestamp);
4245
4384
  }
4246
4385
  else {
@@ -4262,7 +4401,7 @@ export default function planPiExtension(pi) {
4262
4401
  }
4263
4402
  targetStarted = true;
4264
4403
  await st.addWorkDeviation(record);
4265
- if (target.task.status === "paused") {
4404
+ if (target.task.pauseSnapshot) {
4266
4405
  const resumedDeviation = project.workDeviations
4267
4406
  .filter((deviation) => deviation.resumeTaskId === target.task.id
4268
4407
  && (deviation.state === "resume-required" || deviation.state === "resolved"))
@@ -4293,12 +4432,13 @@ export default function planPiExtension(pi) {
4293
4432
  if (target.phase.id !== source.phase.id)
4294
4433
  await st.syncTaskStatusRollup(target.phase.id);
4295
4434
  await st.writeGenerated();
4435
+ invalidateReads();
4296
4436
  const sourceRef = `${formatPhaseRef(source.phase.number, featureNumberOfPhase(source.phase, features))}/T${String(source.task.number).padStart(3, "0")}`;
4297
4437
  const targetRef = `${formatPhaseRef(target.phase.number, featureNumberOfPhase(target.phase, features))}/T${String(target.task.number).padStart(3, "0")}`;
4298
4438
  return {
4299
4439
  content: [{ type: "text", text: [
4300
4440
  `๐Ÿ”€ Task switched: ${sourceRef} โ†’ ${targetRef}`,
4301
- `Paused snapshot: ${snapshot.whatWasBeingDone}`,
4441
+ `Resume checkpoint: ${snapshot.whatWasBeingDone}`,
4302
4442
  `Return target: ${sourceRef} from ${snapshot.resumeLocation}`,
4303
4443
  "Normal priority was deliberately overridden; completing the temporary task will emit RESUME REQUIRED.",
4304
4444
  ].join("\n") }],
@@ -4309,7 +4449,7 @@ export default function planPiExtension(pi) {
4309
4449
  pi.registerTool({
4310
4450
  name: "task_start",
4311
4451
  label: "Task Start",
4312
- 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.",
4452
+ description: "Set a task to in-progress or resume checkpointed work. BEFORE calling, read task_get(full=true), then its parent phase_get(full=true), then its parent feature_get(full=true), in that exact order. Read linked requirements with requirement_list when present. A different active task must first be suspended through task_switch.",
4313
4453
  parameters: Type.Object({
4314
4454
  taskId: Type.String({ description: "Task ref: F00x/P00x/T00x, bare T00x (global), 5-char shortId, UUID, or title to start" }),
4315
4455
  }),
@@ -4326,12 +4466,22 @@ export default function planPiExtension(pi) {
4326
4466
  return { content: [{ type: "text", text: `Task ${task.id} is already in-progress.` }], details: task };
4327
4467
  if (task.status === "done")
4328
4468
  return { content: [{ type: "text", text: `Task ${task.id} is done. Use task_update to reopen.` }], details: task };
4329
- const project = await st.loadProject();
4330
- const phases = await st.loadAllPhases();
4469
+ const contextEligibility = contextReadEligibility(task.id, found.phase.id, found.phase.featureId);
4470
+ if (!contextEligibility.eligible) {
4471
+ return { content: [{ type: "text", text: `Task start denied: context reads required. ${contextEligibility.reason}` }], details: contextEligibility };
4472
+ }
4473
+ const linkedRequirementIds = [
4474
+ ...(await st.linkedRequirementsForPhase(found.phase.id)).map((requirement) => requirement.id),
4475
+ ...(found.phase.featureId ? (await st.linkedRequirementsForFeature(found.phase.featureId)).map((requirement) => requirement.id) : []),
4476
+ ];
4477
+ if (!hasReadRequirements(linkedRequirementIds)) {
4478
+ return { content: [{ type: "text", text: "Task start denied: read the requirements linked to the task's phase and feature first." }], details: { requirementIds: linkedRequirementIds } };
4479
+ }
4480
+ const [project, phases, focus] = await Promise.all([st.loadProject(), st.loadAllPhases(), st.loadResume()]);
4331
4481
  const eligibility = checkExplicitTaskStart(features, phases, task.id, project.workDeviations);
4332
4482
  if (!eligibility.eligible)
4333
4483
  return { content: [{ type: "text", text: `Task start denied: ${eligibility.reason}` }], details: eligibility };
4334
- const selection = recommendNextTask(features, phases, project.workDeviations);
4484
+ const selection = recommendNextTask(features, phases, project.workDeviations, focus?.currentPhaseId);
4335
4485
  if (selection.kind === "conflict") {
4336
4486
  return { content: [{ type: "text", text: `${selection.reason} Pause/reconcile active tasks before starting another task.` }], details: selection };
4337
4487
  }
@@ -4339,9 +4489,27 @@ export default function planPiExtension(pi) {
4339
4489
  const activeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
4340
4490
  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 };
4341
4491
  }
4342
- if (selection.kind === "resume" && selection.candidate?.task.id !== task.id) {
4343
- const resumeRef = `${formatPhaseRef(selection.candidate.phase.number, featureNumberOfPhase(selection.candidate.phase, features))}/T${String(selection.candidate.task.number).padStart(3, "0")}`;
4344
- 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 };
4492
+ let resumeProposal;
4493
+ if (selection.kind === "resume" && selection.candidate && selection.candidate.task.id !== task.id) {
4494
+ // Advisory only: a pending resume must be surfaced loudly, but it must
4495
+ // not hard-block an explicit start of a different task. Capture a
4496
+ // structured proposal so the resume-first guidance is explicit and
4497
+ // machine-readable for the host/agent.
4498
+ const resumeTask = selection.candidate;
4499
+ const resumeRef = `${formatPhaseRef(resumeTask.phase.number, featureNumberOfPhase(resumeTask.phase, features))}/T${String(resumeTask.task.number).padStart(3, "0")}`;
4500
+ const resumeSnapshot = resumeTask.task.pauseSnapshot
4501
+ ?? project.workDeviations.find((deviation) => deviation.resumeTaskId === resumeTask.task.id
4502
+ && (deviation.state === "resume-required" || deviation.state === "resolved"))?.snapshot
4503
+ ?? null;
4504
+ resumeProposal = buildResumeRequiredProposal({
4505
+ ref: resumeRef,
4506
+ title: resumeTask.task.title,
4507
+ taskId: resumeTask.task.id,
4508
+ phaseId: resumeTask.phase.id,
4509
+ snapshot: resumeSnapshot
4510
+ ? { reason: resumeSnapshot.reason, resumeLocation: resumeSnapshot.resumeLocation, howToResume: resumeSnapshot.howToResume }
4511
+ : null,
4512
+ });
4345
4513
  }
4346
4514
  // Assemble the mandatory parent context before changing task lifecycle state.
4347
4515
  // The output order is feature + its requirements, then phase + its requirements.
@@ -4352,11 +4520,13 @@ export default function planPiExtension(pi) {
4352
4520
  ]);
4353
4521
  const phaseContext = buildPhaseContextBlock(phaseWithRequirements, parentFeature, phaseWithRequirements.linkedRequirements ?? [], featureRequirements);
4354
4522
  const advisory = selection.candidate && selection.candidate.task.id !== task.id
4355
- ? `\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.`
4523
+ ? selection.kind === "resume"
4524
+ ? `\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.`}`
4525
+ : `\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.`
4356
4526
  : "";
4357
4527
  const now = nowISO();
4358
4528
  let startedTask;
4359
- if (task.status === "paused") {
4529
+ if (task.pauseSnapshot) {
4360
4530
  startedTask = await st.resumeTask(found.phase.id, found.task.id, now);
4361
4531
  }
4362
4532
  else {
@@ -4390,7 +4560,11 @@ export default function planPiExtension(pi) {
4390
4560
  await st.writeGenerated();
4391
4561
  if (!startedTask)
4392
4562
  return { content: [{ type: "text", text: `Task not found: ${params.taskId}` }], details: {} };
4393
- 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 };
4563
+ return {
4564
+ 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}` }],
4565
+ details: startedTask,
4566
+ ...(resumeProposal ? { resumeRequired: resumeProposal.structured } : {}),
4567
+ };
4394
4568
  },
4395
4569
  });
4396
4570
  pi.registerTool({
@@ -4413,8 +4587,8 @@ export default function planPiExtension(pi) {
4413
4587
  const task = found.task;
4414
4588
  if (task.status === "done")
4415
4589
  return { content: [{ type: "text", text: `Task ${task.id} is already done.` }], details: task };
4416
- if (task.status === "paused")
4417
- 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 };
4590
+ if (task.pauseSnapshot)
4591
+ 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 };
4418
4592
  const unchecked = task.checklist.filter((item) => !item.checked);
4419
4593
  if (unchecked.length > 0 && !params.force) {
4420
4594
  return {
@@ -4463,7 +4637,7 @@ export default function planPiExtension(pi) {
4463
4637
  resumeNotice = [
4464
4638
  "",
4465
4639
  `โ†ฉ๏ธ RESUME REQUIRED: ${resumeRef} โ€” ${resume.task.title}`,
4466
- snapshot ? `Paused because: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
4640
+ snapshot ? `Checkpoint reason: ${snapshot.reason}` : "Return to the preserved task before selecting new priority work.",
4467
4641
  snapshot ? `Resume from: ${snapshot.resumeLocation}` : "",
4468
4642
  snapshot ? `How to resume: ${snapshot.howToResume}` : "",
4469
4643
  `Next action: task_start ${resumeRef}`,
@@ -4650,7 +4824,7 @@ export default function planPiExtension(pi) {
4650
4824
  const st = await requirePlan(ctx);
4651
4825
  if (!st)
4652
4826
  return { content: [{ type: "text", text: "No .planner/ found. Run plan_init first." }], details: { enabled: false, running: false } };
4653
- plannerSessionEnabled = true;
4827
+ enablePlannerSession();
4654
4828
  if (!server) {
4655
4829
  await startServer(ctx, undefined, "lan").catch(() => { });
4656
4830
  }
@@ -4722,6 +4896,7 @@ export default function planPiExtension(pi) {
4722
4896
  const activity = await st.loadActivityLog();
4723
4897
  const recentActivity = activity.entries.slice(-3).reverse();
4724
4898
  const handoffs = await st.listHandoffs();
4899
+ const extensionRules = await st.extensionRules();
4725
4900
  const featureById = new Map(plan.features.features.map((feature) => [feature.id, feature]));
4726
4901
  const phaseById = new Map(plan.phases.map((phase) => [phase.id, phase]));
4727
4902
  const orderedPhases = [
@@ -4836,12 +5011,19 @@ export default function planPiExtension(pi) {
4836
5011
  "",
4837
5012
  "Operational rules:",
4838
5013
  "- Handoffs are context, not locks. Read the relevant handoff with handoff show <ref> before resuming.",
4839
- "- BEFORE work: read feature_get(full=true) for the parent feature and its linked requirements, then phase_get(full=true) for the parent phase and its linked requirements, then task_get(full=true); only then call task_start before any code edit/write. AFTER finishing a task: task_complete.",
5014
+ "- BEFORE work: read task_get(full=true), then its parent phase_get(full=true), then its parent feature_get(full=true), in that exact order. Read linked requirements with requirement_list when present. task_start and task_switch reject work until these reads are complete; only then touch code. AFTER finishing a task: task_complete.",
4840
5015
  "- Record every new decision or user-agreed modification in both the relevant feature and phase before treating the discussion as complete.",
4841
5016
  "- Use task_update with motivation for blocked/canceled/rejected/deferred/waiting/planned(from non-planned).",
4842
5017
  "- Planner ops (status/handoff/planner metadata) are NOT code edits; they are always allowed.",
4843
5018
  "- Prioritize work: continue an in-progress task; otherwise choose ready work feature โ†’ phase โ†’ task by ascending priority, respecting dependencies and blocked/waiting states. Prefer shortId or F00x/P00x/T00x refs. Find via feature_list / phase_list / task_list; read one entity via *_get(full=true).",
4844
5019
  "- If edit/write guard blocks you, start the right task or use an explicit bypass.",
5020
+ ...(extensionRules.length > 0
5021
+ ? [
5022
+ "",
5023
+ "Planner rules (extension; agent-only):",
5024
+ ...extensionRules.map((rule, index) => `${index + 1}. ${rule}`),
5025
+ ]
5026
+ : []),
4845
5027
  "",
4846
5028
  `Goal: ${project.goal || "(not set)"}`,
4847
5029
  project.description ? `Description: ${project.description}` : "",