@agent-plan/mcp 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AA0vBA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AA21BA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAItD"}
package/dist/index.js CHANGED
@@ -6,7 +6,8 @@ import { join } from "node:path";
6
6
  import { existsSync } from "node:fs";
7
7
  import { pathToFileURL } from "node:url";
8
8
  import { PlanStore, ExportService, withFeatureLock, needsMotivation } from "@agent-plan/core";
9
- import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, normalizeSlug } from "@agent-plan/core/naming";
9
+ import { serve } from "@agent-plan/server";
10
+ import { createChecklistItemId, createFeatureId, createPhaseId, createTaskId, clampSlug, normalizeSlug } from "@agent-plan/core/naming";
10
11
  const STATUS_VALUES = ["planned", "in-progress", "done", "blocked", "canceled", "rejected", "deferred", "waiting"];
11
12
  const PHASE_STATUS_VALUES = ["draft", "discovery", ...STATUS_VALUES];
12
13
  function text(textValue, structuredContent) {
@@ -29,6 +30,67 @@ async function requireStore() {
29
30
  throw new Error(`No .planner/ found at ${st.root}. Use planner-init first.`);
30
31
  return st;
31
32
  }
33
+ // In-process web server handle, managed by the planner-web tool and planner-load.
34
+ // Lives as long as this MCP stdio process (i.e. the host session). Null when not running.
35
+ let webHandle = null;
36
+ /** Start the web dashboard on LAN (0.0.0.0:0, OS-assigned port) if not already
37
+ * running. Returns the local/LAN URLs. No-op (empty url) when no .planner/ exists. */
38
+ async function ensureWebStarted() {
39
+ if (webHandle)
40
+ return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
41
+ const root = planRoot();
42
+ const st = new PlanStore(root);
43
+ if (!(await st.exists()))
44
+ return { localUrl: "" };
45
+ try {
46
+ webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
47
+ return { localUrl: webHandle.localUrl, lanUrl: webHandle.lanUrl, mode: webHandle.mode };
48
+ }
49
+ catch {
50
+ return { localUrl: "" };
51
+ }
52
+ }
53
+ /** Build a consolidated planner recap (project state + active work + pending
54
+ * handoff + web URL). Harness-agnostic equivalent of Pi's buildStartupResumeSummary. */
55
+ async function buildRecapText(st, web) {
56
+ const [plan, resume, handoff] = await Promise.all([st.loadAll(), st.loadResume(), st.loadHandoff()]);
57
+ const allTasks = plan.phases.flatMap((p) => p.tasks.map((t) => ({ phase: p, task: t })));
58
+ const totalF = plan.features.features.length;
59
+ const doneF = plan.features.features.filter((f) => f.status === "done").length;
60
+ const activeF = plan.features.features.filter((f) => f.status === "in-progress").length;
61
+ const totalP = plan.phases.length;
62
+ const doneP = plan.phases.filter((p) => p.status === "done").length;
63
+ const activeP = plan.phases.filter((p) => p.status === "in-progress" || p.status === "discovery").length;
64
+ const totalT = allTasks.length;
65
+ const doneT = allTasks.filter(({ task }) => task.status === "done").length;
66
+ const activeT = allTasks.filter(({ task }) => task.status === "in-progress").length;
67
+ const inProgress = allTasks.filter(({ task }) => task.status === "in-progress");
68
+ const focus = inProgress[0];
69
+ const focusFeature = focus ? plan.features.features.find((f) => f.id === focus.phase.featureId) : undefined;
70
+ const lines = [];
71
+ lines.push("## Planner recap");
72
+ const name = plan.project.name || "(unnamed project)";
73
+ lines.push(`Project: ${name}${plan.project.goal ? " — " + plan.project.goal : ""}`);
74
+ lines.push(`Progress: Features ${doneF}/${totalF} done (${activeF} active) · Phases ${doneP}/${totalP} done (${activeP} active) · Tasks ${doneT}/${totalT} done (${activeT} active)`);
75
+ if (focus) {
76
+ lines.push(`Current focus: ${focusFeature?.name ?? "?"} / ${focus.phase.title} / ${focus.task.title} (in-progress)`);
77
+ }
78
+ else {
79
+ lines.push("Current focus: no active task — review the plan and pick the next concrete task");
80
+ }
81
+ if (resume?.nextSteps?.length)
82
+ lines.push(`Next step: ${resume.nextSteps[0]}`);
83
+ if (handoff) {
84
+ lines.push("", "## Pending handoff (read me first)", handoff.content || "(empty handoff)", "", "→ After processing the handoff above, call planner-handoff-clear to remove it.");
85
+ }
86
+ else if (activeT === 0) {
87
+ lines.push("", "No handoff pending and no task in-progress. Use planner-task-add / planner-task-start to begin work.");
88
+ }
89
+ if (web.localUrl) {
90
+ lines.push("", "## Web UI", `🌐 ${web.localUrl}${web.lanUrl ? " (LAN: " + web.lanUrl + ")" : ""}`);
91
+ }
92
+ return lines.join("\n");
93
+ }
32
94
  function findFeatureByRef(features, ref) {
33
95
  const normalized = ref.trim().toLowerCase();
34
96
  return features.find((feature) => feature.id.toLowerCase() === normalized)
@@ -504,7 +566,7 @@ server.registerTool("planner-task-add", {
504
566
  id: taskId,
505
567
  phaseId: found.id,
506
568
  number: found.tasks.length + 1,
507
- shortName: normalizeSlug(title).slice(0, 30),
569
+ shortName: clampSlug(title, 30, `task-${Date.now().toString(36)}`),
508
570
  title: title.trim(),
509
571
  status: "planned",
510
572
  description: description?.trim() ?? "",
@@ -640,12 +702,13 @@ server.registerTool("planner-task-start", {
640
702
  inputSchema: { task: z.string().min(1) },
641
703
  }, async ({ task: ref }) => {
642
704
  const st = await requireStore();
643
- // Hygiene Gate: block starting work if a pending handoff exists.
644
- if (existsSync(join(st.root, "HANDOFF.md"))) {
645
- return text(`🚨 HYGIENE VIOLATION: A pending handoff file exists at .planner/HANDOFF.md. ` +
646
- `You MUST read and delete it before you can officially start a task. ` +
647
- `This is a non-negotiable rule in AGENTS.md.`);
648
- }
705
+ // Hygiene notice (non-blocking): surface an existing handoff but never block
706
+ // task_start — the handoff is a captured-context artifact, not a lock (e.g. a
707
+ // small modification after writing a handoff should start without forcing
708
+ // deletion, which would lose context).
709
+ const handoffNotice = existsSync(join(st.root, "HANDOFF.md"))
710
+ ? "ℹ️ A handoff exists at .planner/HANDOFF.md — read it for context, then delete it with planner-handoff-delete when no longer needed. Proceeding with task start.\n"
711
+ : "";
649
712
  const found = findTaskByRef(await st.loadAllPhases(), ref);
650
713
  if (!found)
651
714
  return text(`Task not found: ${ref}`);
@@ -673,7 +736,7 @@ server.registerTool("planner-task-start", {
673
736
  return phase;
674
737
  });
675
738
  await st.syncTaskStatusRollup(found.phase.id);
676
- return writeAndSummarize(st, `✅ Task started: ${found.task.id}`, { task: updatedTask ?? found.task });
739
+ return writeAndSummarize(st, `${handoffNotice}✅ Task started: ${found.task.id}`, { task: updatedTask ?? found.task });
677
740
  });
678
741
  server.registerTool("planner-task-complete", {
679
742
  description: "Set a task to done. Fails if checklist is incomplete unless force=true.",
@@ -754,14 +817,55 @@ server.registerTool("planner-handoff-clear", {
754
817
  return text("Deleted .planner/HANDOFF.md");
755
818
  });
756
819
  server.registerTool("planner-web", {
757
- description: "Planner web command placeholder for MCP stdio. Use plan-server CLI directly for now.",
820
+ description: "Manage the planner web dashboard (start/status/stop) from MCP stdio. Runs an in-process HTTP+WS server bound to LAN; it lives as long as this MCP process. plan-root comes from AGENT_PLAN_ROOT or cwd()/.planner.",
758
821
  inputSchema: {
759
822
  action: z.enum(["start", "stop", "status"]).default("status"),
760
823
  },
761
- }, async ({ action }) => text(`planner-web ${action}: MCP stdio package does not manage the web server yet. Use \`pnpm --filter @agent-plan/server build\` and the plan-server CLI, or Pi /planner web ${action}.`));
824
+ }, async ({ action }) => {
825
+ if (action === "start") {
826
+ if (webHandle) {
827
+ return text(`planner-web already running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`);
828
+ }
829
+ const root = planRoot();
830
+ const storeCheck = new PlanStore(root);
831
+ if (!(await storeCheck.exists())) {
832
+ return text(`planner-web start: no .planner/ found at ${root}. Run planner-init first.`);
833
+ }
834
+ try {
835
+ // port: 0 → OS assigns a free port (no conflict with plan-server CLI / Pi).
836
+ // host: 0.0.0.0 → bind LAN (reachable from other devices), like Pi /planner load.
837
+ webHandle = await serve({ planRoot: root, host: "0.0.0.0", port: 0, quiet: true });
838
+ return text(`planner-web started: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode})`);
839
+ }
840
+ catch (err) {
841
+ webHandle = null;
842
+ return text(`planner-web start failed: ${err.message}`);
843
+ }
844
+ }
845
+ if (action === "stop") {
846
+ if (!webHandle)
847
+ return text("planner-web not running.");
848
+ const stoppedUrl = webHandle.localUrl;
849
+ await webHandle.close().catch(() => { });
850
+ webHandle = null;
851
+ return text(`planner-web stopped (was ${stoppedUrl}).`);
852
+ }
853
+ // status
854
+ if (webHandle) {
855
+ return text(`planner-web running: ${webHandle.localUrl}${webHandle.lanUrl ? ` — LAN: ${webHandle.lanUrl}` : ""} (mode: ${webHandle.mode}, bindHost: ${webHandle.bindHost})`);
856
+ }
857
+ return text("planner-web not running. Use planner-web with action=start to start the dashboard.");
858
+ });
762
859
  server.registerTool("planner-load", {
763
- description: "MCP no-op equivalent of Pi /planner load. MCP server is already loaded when tools are available.",
764
- }, async () => text("Planner MCP server is loaded."));
860
+ description: "Load/refresh the planner on explicit user request (NOT automatic): starts the web dashboard on LAN and returns a consolidated recap (project state, active task, pending handoff, web URL). This is the MCP equivalent of Pi /planner load. Call it ONLY when the user runs /planner load or /planner recap (or asks to load the planner). Present the recap to the user in that reply, including the web URL on a final prominent line. If a pending handoff is included, read it, summarize it to the user, then call planner-handoff-clear. Do NOT start the planner/web or show the web URL unless the user explicitly asks (load/recap/web status).",
861
+ }, async () => {
862
+ const st = store();
863
+ if (!(await st.exists()))
864
+ return text("No .planner/ found at " + planRoot() + ". Run planner-init first.");
865
+ const web = await ensureWebStarted();
866
+ const recap = await buildRecapText(st, web);
867
+ return text(recap);
868
+ });
765
869
  server.registerTool("planner-disable", {
766
870
  description: "MCP no-op equivalent of Pi /planner disable. Stop the MCP process from the host to disable it.",
767
871
  }, async () => text("To disable planner MCP, remove/disable this MCP server from the host config or stop the process."));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/mcp",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "private": false,
5
5
  "description": "MCP server for Agent Plan, exposing planner-* tools over stdio.",
6
6
  "license": "MIT",
@@ -41,7 +41,8 @@
41
41
  "dependencies": {
42
42
  "@modelcontextprotocol/sdk": "^1.17.0",
43
43
  "zod": "^3.24.0",
44
- "@agent-plan/core": "0.2.9"
44
+ "@agent-plan/core": "0.2.11",
45
+ "@agent-plan/server": "0.2.11"
45
46
  },
46
47
  "scripts": {
47
48
  "build": "tsc -p tsconfig.json",