···11+#!/usr/bin/env tsx
22+/**
33+ * Smoke test for 9plan MCP server
44+ *
55+ * Spawns the server and sends actual MCP protocol messages to verify
66+ * the tools work correctly.
77+ */
88+99+import { spawn } from 'node:child_process';
1010+import { createInterface } from 'node:readline';
1111+import { join } from 'node:path';
1212+1313+let messageId = 1;
1414+1515+function createMessage(method: string, params?: Record<string, unknown>) {
1616+ return JSON.stringify({
1717+ jsonrpc: '2.0',
1818+ id: messageId++,
1919+ method,
2020+ params,
2121+ });
2222+}
2323+2424+async function runSmokeTest() {
2525+ console.log('🚀 Starting 9plan MCP server smoke test...\n');
2626+2727+ // Spawn the server
2828+ const serverPath = join(import.meta.dirname, '..', 'dist', 'index.js');
2929+ const server = spawn('node', [serverPath], {
3030+ stdio: ['pipe', 'pipe', 'pipe'],
3131+ });
3232+3333+ const responses: Record<number, unknown> = {};
3434+ let currentResolve: ((value: unknown) => void) | null = null;
3535+3636+ // Read responses from server
3737+ const rl = createInterface({ input: server.stdout });
3838+ rl.on('line', (line) => {
3939+ try {
4040+ const msg = JSON.parse(line);
4141+ if (msg.id) {
4242+ responses[msg.id] = msg;
4343+ if (currentResolve) {
4444+ currentResolve(msg);
4545+ currentResolve = null;
4646+ }
4747+ }
4848+ } catch {
4949+ // Ignore non-JSON lines
5050+ }
5151+ });
5252+5353+ // Helper to send message and wait for response
5454+ async function send(method: string, params?: Record<string, unknown>): Promise<unknown> {
5555+ const msg = createMessage(method, params);
5656+5757+ return new Promise((resolve, reject) => {
5858+ currentResolve = resolve;
5959+ server.stdin.write(msg + '\n');
6060+6161+ // Timeout after 10 seconds
6262+ setTimeout(() => {
6363+ if (currentResolve === resolve) {
6464+ currentResolve = null;
6565+ reject(new Error(`Timeout waiting for response to ${method}`));
6666+ }
6767+ }, 10000);
6868+ });
6969+ }
7070+7171+ try {
7272+ // Step 1: Initialize
7373+ console.log('📡 Initializing MCP connection...');
7474+ await send('initialize', {
7575+ protocolVersion: '2024-11-05',
7676+ capabilities: {},
7777+ clientInfo: { name: 'smoke-test', version: '1.0.0' },
7878+ });
7979+ console.log('✅ Initialize successful!\n');
8080+8181+ // Step 2: List tools
8282+ console.log('🔧 Listing available tools...');
8383+ const toolsResult = await send('tools/list', {}) as { result?: { tools?: Array<{ name: string }> } };
8484+ const tools = toolsResult.result?.tools || [];
8585+ console.log(`✅ Found ${tools.length} tools:`);
8686+ tools.forEach((t: { name: string }) => console.log(` - ${t.name}`));
8787+ console.log();
8888+8989+ // Step 3: Create a session
9090+ console.log('🎮 Creating a new session...');
9191+ const createResult = await send('tools/call', {
9292+ name: '9plan_session_create',
9393+ arguments: { task_description: 'Smoke test session' },
9494+ }) as { result?: { content?: Array<{ text?: string }> } };
9595+ const createText = createResult.result?.content?.[0]?.text || '';
9696+ console.log('✅ Session created!');
9797+ console.log(` ${createText.split('\n')[0]}`);
9898+ console.log();
9999+100100+ // Step 4: Add a plan to the queue
101101+ console.log('📝 Adding a plan to the queue...');
102102+ const addResult = await send('tools/call', {
103103+ name: '9plan_queue_add',
104104+ arguments: {
105105+ context: 'Testing the 9plan MCP server',
106106+ goal: 'Verify all tools work correctly',
107107+ approach: '1. Create session\n2. Add plans\n3. Complete plans',
108108+ success_criteria: 'All operations succeed without errors',
109109+ },
110110+ }) as { result?: { content?: Array<{ text?: string }> } };
111111+ const addText = addResult.result?.content?.[0]?.text || '';
112112+ console.log('✅ Plan added!');
113113+ console.log(` ${addText.split('\n').slice(0, 3).join('\n ')}`);
114114+ console.log();
115115+116116+ // Step 5: Pull the plan
117117+ console.log('📤 Pulling plan from queue...');
118118+ const pullResult = await send('tools/call', {
119119+ name: '9plan_queue_pull',
120120+ arguments: {},
121121+ }) as { result?: { content?: Array<{ text?: string }> } };
122122+ const pullText = pullResult.result?.content?.[0]?.text || '';
123123+ console.log('✅ Plan pulled!');
124124+ console.log(` ${pullText.split('\n').slice(0, 3).join('\n ')}`);
125125+ console.log();
126126+127127+ // Step 6: Complete the plan
128128+ console.log('🎯 Completing the plan...');
129129+ const completeResult = await send('tools/call', {
130130+ name: '9plan_plan_complete',
131131+ arguments: {
132132+ outcome: 'All smoke tests passed successfully!',
133133+ },
134134+ }) as { result?: { content?: Array<{ text?: string }> } };
135135+ const completeText = completeResult.result?.content?.[0]?.text || '';
136136+ console.log('✅ Plan completed!');
137137+ console.log(` ${completeText.split('\n').slice(0, 3).join('\n ')}`);
138138+ console.log();
139139+140140+ // Step 7: Search history
141141+ console.log('🔍 Searching history...');
142142+ const searchResult = await send('tools/call', {
143143+ name: '9plan_history_search',
144144+ arguments: { query: 'verify tools' },
145145+ }) as { result?: { content?: Array<{ text?: string }> } };
146146+ const searchText = searchResult.result?.content?.[0]?.text || '';
147147+ console.log('✅ History search worked!');
148148+ console.log(` ${searchText.split('\n').slice(0, 5).join('\n ')}`);
149149+ console.log();
150150+151151+ console.log('═'.repeat(50));
152152+ console.log('🎉 ALL SMOKE TESTS PASSED! The server is working! 🎉');
153153+ console.log('═'.repeat(50));
154154+155155+ } catch (error) {
156156+ console.error('❌ Smoke test failed:', error);
157157+ process.exit(1);
158158+ } finally {
159159+ server.kill();
160160+ }
161161+}
162162+163163+runSmokeTest().catch(console.error);
+127
src/container.ts
···11+/**
22+ * Dependency injection container
33+ *
44+ * Simple factory functions for wiring up production dependencies.
55+ * No DI framework needed - just straightforward function composition.
66+ */
77+88+import { join } from "node:path";
99+import { existsSync, mkdirSync, readdirSync } from "node:fs";
1010+import { logger } from "./logger.js";
1111+import { env } from "./env.js";
1212+import { SessionStore } from "./db/session-store.js";
1313+import { PlanFileHandlerImpl } from "./files/plan-files.js";
1414+import { generatePlanId } from "./generators/plan-id.js";
1515+import { generateSessionName } from "./generators/session-name.js";
1616+import { NinePlanError } from "./types.js";
1717+import type { PlanFileHandler } from "./types.js";
1818+1919+/**
2020+ * Create a PlanFileHandler for a session
2121+ */
2222+export function createPlanFileHandler(sessionPath: string): PlanFileHandler {
2323+ return new PlanFileHandlerImpl(sessionPath, { logger });
2424+}
2525+2626+/**
2727+ * Create a SessionStore for a session
2828+ */
2929+export function createSessionStore(
3030+ sessionName: string,
3131+ sessionPath: string,
3232+): SessionStore {
3333+ return new SessionStore(sessionName, sessionPath, {
3434+ logger,
3535+ generatePlanId,
3636+ planFiles: createPlanFileHandler(sessionPath),
3737+ });
3838+}
3939+4040+/**
4141+ * Get the path to a session directory
4242+ */
4343+export function getSessionPath(sessionName: string): string {
4444+ return join(env.NINEPLAN_SESSIONS_PATH, sessionName);
4545+}
4646+4747+/**
4848+ * Check if a session exists
4949+ */
5050+export function sessionExists(sessionName: string): boolean {
5151+ const path = getSessionPath(sessionName);
5252+ return existsSync(path);
5353+}
5454+5555+/**
5656+ * Create a new session with a unique name
5757+ */
5858+export function createNewSession(taskDescription?: string): {
5959+ sessionName: string;
6060+ store: SessionStore;
6161+} {
6262+ // Ensure sessions directory exists
6363+ if (!existsSync(env.NINEPLAN_SESSIONS_PATH)) {
6464+ mkdirSync(env.NINEPLAN_SESSIONS_PATH, { recursive: true });
6565+ }
6666+6767+ // Generate unique name with collision checking
6868+ let sessionName: string;
6969+ let attempts = 0;
7070+ const maxAttempts = 100;
7171+7272+ do {
7373+ sessionName = generateSessionName();
7474+ attempts++;
7575+ if (attempts >= maxAttempts) {
7676+ throw new NinePlanError(
7777+ "DATABASE_ERROR",
7878+ "Failed to generate unique session name after 100 attempts",
7979+ "This is extremely unlikely. Please report this issue.",
8080+ );
8181+ }
8282+ } while (sessionExists(sessionName));
8383+8484+ // Create session directory and store
8585+ const sessionPath = getSessionPath(sessionName);
8686+ mkdirSync(sessionPath, { recursive: true });
8787+8888+ const store = createSessionStore(sessionName, sessionPath);
8989+ store.createSession(taskDescription);
9090+9191+ logger.info({ sessionName, sessionPath }, "New session created");
9292+9393+ return { sessionName, store };
9494+}
9595+9696+/**
9797+ * Resume an existing session
9898+ */
9999+export function resumeSession(sessionName: string): SessionStore {
100100+ if (!sessionExists(sessionName)) {
101101+ throw new NinePlanError(
102102+ "SESSION_NOT_FOUND",
103103+ `Session not found: ${sessionName}`,
104104+ `Check the session name and try again. Session names are case-sensitive.\nAvailable sessions can be found in: ${env.NINEPLAN_SESSIONS_PATH}`,
105105+ );
106106+ }
107107+108108+ const sessionPath = getSessionPath(sessionName);
109109+ const store = createSessionStore(sessionName, sessionPath);
110110+111111+ logger.info({ sessionName }, "Session resumed");
112112+113113+ return store;
114114+}
115115+116116+/**
117117+ * List all available sessions
118118+ */
119119+export function listSessions(): string[] {
120120+ if (!existsSync(env.NINEPLAN_SESSIONS_PATH)) {
121121+ return [];
122122+ }
123123+124124+ return readdirSync(env.NINEPLAN_SESSIONS_PATH, { withFileTypes: true })
125125+ .filter((dirent) => dirent.isDirectory())
126126+ .map((dirent) => dirent.name);
127127+}
+68
src/db/schema.sql
···11+-- 9plan SQLite Schema
22+-- Requires SQLite with FTS5 support (built into Node.js 22.5.0+)
33+44+PRAGMA foreign_keys = ON;
55+66+-- Sessions table
77+CREATE TABLE IF NOT EXISTS sessions (
88+ name TEXT PRIMARY KEY,
99+ task_description TEXT,
1010+ created_at TEXT DEFAULT (datetime('now'))
1111+) STRICT;
1212+1313+-- Plans table with all lifecycle states
1414+CREATE TABLE IF NOT EXISTS plans (
1515+ id TEXT PRIMARY KEY,
1616+ session_name TEXT NOT NULL REFERENCES sessions(name) ON DELETE CASCADE,
1717+ status TEXT NOT NULL CHECK (status IN ('queued', 'active', 'completed', 'discarded')),
1818+ queue_position INTEGER,
1919+2020+ goal TEXT NOT NULL,
2121+ context TEXT,
2222+ inputs TEXT,
2323+ outputs TEXT,
2424+ approach TEXT,
2525+ success_criteria TEXT,
2626+ notes TEXT,
2727+ outcome TEXT,
2828+2929+ created_at TEXT DEFAULT (datetime('now')),
3030+ completed_at TEXT
3131+) STRICT;
3232+3333+-- Index for efficient queue queries (queued plans ordered by position)
3434+CREATE INDEX IF NOT EXISTS idx_plans_queue
3535+ ON plans(session_name, status, queue_position)
3636+ WHERE status = 'queued';
3737+3838+-- Index for finding the active plan quickly
3939+CREATE INDEX IF NOT EXISTS idx_plans_active
4040+ ON plans(session_name, status)
4141+ WHERE status = 'active';
4242+4343+-- FTS5 virtual table for history search
4444+-- Searches across goal, context, inputs, outputs, and outcome
4545+CREATE VIRTUAL TABLE IF NOT EXISTS plans_fts USING fts5(
4646+ id, goal, context, inputs, outputs, outcome,
4747+ content='plans', content_rowid='rowid'
4848+);
4949+5050+-- Trigger: Keep FTS in sync on INSERT
5151+CREATE TRIGGER IF NOT EXISTS plans_ai AFTER INSERT ON plans BEGIN
5252+ INSERT INTO plans_fts(rowid, id, goal, context, inputs, outputs, outcome)
5353+ VALUES (new.rowid, new.id, new.goal, new.context, new.inputs, new.outputs, new.outcome);
5454+END;
5555+5656+-- Trigger: Keep FTS in sync on DELETE
5757+CREATE TRIGGER IF NOT EXISTS plans_ad AFTER DELETE ON plans BEGIN
5858+ INSERT INTO plans_fts(plans_fts, rowid, id, goal, context, inputs, outputs, outcome)
5959+ VALUES ('delete', old.rowid, old.id, old.goal, old.context, old.inputs, old.outputs, old.outcome);
6060+END;
6161+6262+-- Trigger: Keep FTS in sync on UPDATE
6363+CREATE TRIGGER IF NOT EXISTS plans_au AFTER UPDATE ON plans BEGIN
6464+ INSERT INTO plans_fts(plans_fts, rowid, id, goal, context, inputs, outputs, outcome)
6565+ VALUES ('delete', old.rowid, old.id, old.goal, old.context, old.inputs, old.outputs, old.outcome);
6666+ INSERT INTO plans_fts(rowid, id, goal, context, inputs, outputs, outcome)
6767+ VALUES (new.rowid, new.id, new.goal, new.context, new.inputs, new.outputs, new.outcome);
6868+END;
+576
src/db/session-store.ts
···11+/**
22+ * Session store - SQLite-backed session and plan management
33+ *
44+ * Handles all database operations for sessions, plans, queue management,
55+ * and history search via FTS5.
66+ */
77+88+import Database from "better-sqlite3";
99+import { existsSync, mkdirSync } from "node:fs";
1010+import { join } from "node:path";
1111+import type { Logger } from "pino";
1212+import type {
1313+ Plan,
1414+ PlanInput,
1515+ PlanContent,
1616+ QueuedPlan,
1717+ SessionState,
1818+ HistoryMatch,
1919+ SessionStoreDeps,
2020+ PlanFileHandler,
2121+} from "../types.js";
2222+import { NinePlanError } from "../types.js";
2323+2424+// Inline schema to avoid file read issues after compilation
2525+const SCHEMA = `
2626+-- 9plan SQLite Schema
2727+PRAGMA foreign_keys = ON;
2828+2929+-- Sessions table
3030+CREATE TABLE IF NOT EXISTS sessions (
3131+ name TEXT PRIMARY KEY,
3232+ task_description TEXT,
3333+ created_at TEXT DEFAULT (datetime('now'))
3434+) STRICT;
3535+3636+-- Plans table with all lifecycle states
3737+CREATE TABLE IF NOT EXISTS plans (
3838+ id TEXT PRIMARY KEY,
3939+ session_name TEXT NOT NULL REFERENCES sessions(name) ON DELETE CASCADE,
4040+ status TEXT NOT NULL CHECK (status IN ('queued', 'active', 'completed', 'discarded')),
4141+ queue_position INTEGER,
4242+4343+ goal TEXT NOT NULL,
4444+ context TEXT,
4545+ inputs TEXT,
4646+ outputs TEXT,
4747+ approach TEXT,
4848+ success_criteria TEXT,
4949+ notes TEXT,
5050+ outcome TEXT,
5151+5252+ created_at TEXT DEFAULT (datetime('now')),
5353+ completed_at TEXT
5454+) STRICT;
5555+5656+-- Index for efficient queue queries
5757+CREATE INDEX IF NOT EXISTS idx_plans_queue
5858+ ON plans(session_name, status, queue_position)
5959+ WHERE status = 'queued';
6060+6161+-- Index for finding the active plan quickly
6262+CREATE INDEX IF NOT EXISTS idx_plans_active
6363+ ON plans(session_name, status)
6464+ WHERE status = 'active';
6565+6666+-- FTS5 virtual table for history search
6767+CREATE VIRTUAL TABLE IF NOT EXISTS plans_fts USING fts5(
6868+ id, goal, context, inputs, outputs, outcome,
6969+ content='plans', content_rowid='rowid'
7070+);
7171+7272+-- Trigger: Keep FTS in sync on INSERT
7373+CREATE TRIGGER IF NOT EXISTS plans_ai AFTER INSERT ON plans BEGIN
7474+ INSERT INTO plans_fts(rowid, id, goal, context, inputs, outputs, outcome)
7575+ VALUES (new.rowid, new.id, new.goal, new.context, new.inputs, new.outputs, new.outcome);
7676+END;
7777+7878+-- Trigger: Keep FTS in sync on DELETE
7979+CREATE TRIGGER IF NOT EXISTS plans_ad AFTER DELETE ON plans BEGIN
8080+ INSERT INTO plans_fts(plans_fts, rowid, id, goal, context, inputs, outputs, outcome)
8181+ VALUES ('delete', old.rowid, old.id, old.goal, old.context, old.inputs, old.outputs, old.outcome);
8282+END;
8383+8484+-- Trigger: Keep FTS in sync on UPDATE
8585+CREATE TRIGGER IF NOT EXISTS plans_au AFTER UPDATE ON plans BEGIN
8686+ INSERT INTO plans_fts(plans_fts, rowid, id, goal, context, inputs, outputs, outcome)
8787+ VALUES ('delete', old.rowid, old.id, old.goal, old.context, old.inputs, old.outputs, old.outcome);
8888+ INSERT INTO plans_fts(rowid, id, goal, context, inputs, outputs, outcome)
8989+ VALUES (new.rowid, new.id, new.goal, new.context, new.inputs, new.outputs, new.outcome);
9090+END;
9191+`;
9292+9393+/**
9494+ * Session store - manages a single session's data
9595+ */
9696+export class SessionStore {
9797+ private readonly db: Database.Database;
9898+ private readonly log: Logger;
9999+ private readonly generatePlanId: () => string;
100100+ private readonly planFiles: PlanFileHandler;
101101+102102+ constructor(
103103+ private readonly sessionName: string,
104104+ private readonly sessionPath: string,
105105+ deps: SessionStoreDeps,
106106+ ) {
107107+ this.log = deps.logger.child({ session: sessionName });
108108+ this.generatePlanId = deps.generatePlanId;
109109+ this.planFiles = deps.planFiles;
110110+111111+ // Ensure session directory exists
112112+ if (!existsSync(sessionPath)) {
113113+ mkdirSync(sessionPath, { recursive: true });
114114+ }
115115+116116+ // Initialize database
117117+ const dbPath = join(sessionPath, "session.db");
118118+ this.db = new Database(dbPath);
119119+ this.initializeSchema();
120120+ }
121121+122122+ /**
123123+ * Initialize the database schema
124124+ */
125125+ private initializeSchema(): void {
126126+ this.db.exec(SCHEMA);
127127+ this.log.debug("Database schema initialized");
128128+ }
129129+130130+ /**
131131+ * Create the session record (call once when creating a new session)
132132+ */
133133+ createSession(taskDescription?: string): void {
134134+ const stmt = this.db.prepare(
135135+ "INSERT INTO sessions (name, task_description) VALUES (?, ?)",
136136+ );
137137+ stmt.run(this.sessionName, taskDescription ?? null);
138138+ this.log.info({ taskDescription }, "Session created");
139139+ }
140140+141141+ /**
142142+ * Get the session's task description
143143+ */
144144+ getTaskDescription(): string | null {
145145+ const stmt = this.db.prepare(
146146+ "SELECT task_description FROM sessions WHERE name = ?",
147147+ );
148148+ const row = stmt.get(this.sessionName) as
149149+ | { task_description: string | null }
150150+ | undefined;
151151+ return row?.task_description ?? null;
152152+ }
153153+154154+ /**
155155+ * Get session path
156156+ */
157157+ getSessionPath(): string {
158158+ return this.sessionPath;
159159+ }
160160+161161+ // ===========================================================================
162162+ // Queue Operations
163163+ // ===========================================================================
164164+165165+ /**
166166+ * Add a plan to the queue
167167+ */
168168+ addPlan(input: PlanInput, position: "front" | "back" = "back"): Plan {
169169+ const id = this.generatePlanId();
170170+ const queuePosition = this.getNextQueuePosition(position);
171171+172172+ // Shift existing plans if inserting at front
173173+ if (position === "front") {
174174+ this.shiftQueuePositions();
175175+ }
176176+177177+ // Insert into database
178178+ const stmt = this.db.prepare(`
179179+ INSERT INTO plans (
180180+ id, session_name, status, queue_position,
181181+ goal, context, inputs, outputs, approach, success_criteria, notes
182182+ ) VALUES (?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?)
183183+ `);
184184+185185+ stmt.run(
186186+ id,
187187+ this.sessionName,
188188+ queuePosition,
189189+ input.goal,
190190+ input.context,
191191+ input.inputs ?? null,
192192+ input.outputs ?? null,
193193+ input.approach,
194194+ input.successCriteria,
195195+ null, // notes start empty
196196+ );
197197+198198+ // Write plan file
199199+ const content: PlanContent = {
200200+ context: input.context,
201201+ goal: input.goal,
202202+ inputs: input.inputs ?? "",
203203+ outputs: input.outputs ?? "",
204204+ approach: input.approach,
205205+ successCriteria: input.successCriteria,
206206+ notes: "",
207207+ };
208208+ this.planFiles.write(id, content);
209209+210210+ this.log.info({ planId: id, goal: input.goal, position }, "Plan added");
211211+212212+ const plan = this.getPlanById(id);
213213+ if (!plan) {
214214+ throw new NinePlanError(
215215+ "DATABASE_ERROR",
216216+ `Failed to retrieve plan ${id} after creation`,
217217+ "This should not happen. Please check the database.",
218218+ );
219219+ }
220220+ return plan;
221221+ }
222222+223223+ /**
224224+ * Pull the front plan from queue and make it active
225225+ */
226226+ pullPlan(): Plan {
227227+ // Check for existing active plan
228228+ const active = this.getActivePlan();
229229+ if (active) {
230230+ throw new NinePlanError(
231231+ "PLAN_ALREADY_ACTIVE",
232232+ `Plan ${active.id} is already active`,
233233+ "Complete, defer, or discard the active plan before pulling another.",
234234+ );
235235+ }
236236+237237+ // Get front of queue
238238+ const front = this.db
239239+ .prepare(
240240+ `SELECT id FROM plans
241241+ WHERE session_name = ? AND status = 'queued'
242242+ ORDER BY queue_position ASC
243243+ LIMIT 1`,
244244+ )
245245+ .get(this.sessionName) as { id: string } | undefined;
246246+247247+ if (!front) {
248248+ throw new NinePlanError(
249249+ "QUEUE_EMPTY",
250250+ "The queue is empty",
251251+ "Use 9plan_history_search to review completed work.",
252252+ );
253253+ }
254254+255255+ // Mark as active
256256+ this.db
257257+ .prepare(
258258+ `UPDATE plans SET status = 'active', queue_position = NULL WHERE id = ?`,
259259+ )
260260+ .run(front.id);
261261+262262+ // Renumber remaining queue
263263+ this.renumberQueue();
264264+265265+ const plan = this.getPlanById(front.id);
266266+ if (!plan) {
267267+ throw new NinePlanError(
268268+ "DATABASE_ERROR",
269269+ `Failed to retrieve plan ${front.id} after pulling`,
270270+ "This should not happen. Please check the database.",
271271+ );
272272+ }
273273+ this.log.info({ planId: plan.id, goal: plan.goal }, "Plan pulled");
274274+275275+ return plan;
276276+ }
277277+278278+ // ===========================================================================
279279+ // Plan Lifecycle
280280+ // ===========================================================================
281281+282282+ /**
283283+ * Complete the active plan
284284+ */
285285+ completePlan(outcome: string): void {
286286+ const active = this.requireActivePlan();
287287+288288+ // Update database
289289+ this.db
290290+ .prepare(
291291+ `UPDATE plans
292292+ SET status = 'completed', outcome = ?, completed_at = datetime('now')
293293+ WHERE id = ?`,
294294+ )
295295+ .run(outcome, active.id);
296296+297297+ // Delete plan file (content now lives in database/FTS)
298298+ this.planFiles.delete(active.id);
299299+300300+ this.log.info({ planId: active.id, goal: active.goal }, "Plan completed");
301301+ }
302302+303303+ /**
304304+ * Defer the active plan back to queue
305305+ */
306306+ deferPlan(reason: string, position: "front" | "back" = "back"): void {
307307+ const active = this.requireActivePlan();
308308+309309+ // Append reason to notes
310310+ this.planFiles.appendToNotes(active.id, `Deferred: ${reason}`);
311311+312312+ // Update notes in database too
313313+ const content = this.planFiles.read(active.id);
314314+ const queuePosition = this.getNextQueuePosition(position);
315315+316316+ // Shift if inserting at front
317317+ if (position === "front") {
318318+ this.shiftQueuePositions();
319319+ }
320320+321321+ // Move back to queued status
322322+ this.db
323323+ .prepare(
324324+ `UPDATE plans
325325+ SET status = 'queued', queue_position = ?, notes = ?
326326+ WHERE id = ?`,
327327+ )
328328+ .run(queuePosition, content.notes, active.id);
329329+330330+ this.log.info({ planId: active.id, reason, position }, "Plan deferred");
331331+ }
332332+333333+ /**
334334+ * Discard the active plan (no history record)
335335+ */
336336+ discardPlan(reason: string): void {
337337+ const active = this.requireActivePlan();
338338+339339+ // Mark as discarded (NOT completed, won't appear in history search)
340340+ this.db
341341+ .prepare(`UPDATE plans SET status = 'discarded' WHERE id = ?`)
342342+ .run(active.id);
343343+344344+ // Delete plan file
345345+ this.planFiles.delete(active.id);
346346+347347+ this.log.info({ planId: active.id, reason }, "Plan discarded");
348348+ }
349349+350350+ // ===========================================================================
351351+ // History Operations
352352+ // ===========================================================================
353353+354354+ /**
355355+ * Search completed plans using FTS5
356356+ */
357357+ searchHistory(query: string, maxResults = 10): HistoryMatch[] {
358358+ // Use FTS5 MATCH with BM25 ranking
359359+ const stmt = this.db.prepare(`
360360+ SELECT
361361+ plans.id,
362362+ plans.goal,
363363+ plans.outcome,
364364+ bm25(plans_fts) as score
365365+ FROM plans_fts
366366+ JOIN plans ON plans_fts.id = plans.id
367367+ WHERE plans.session_name = ?
368368+ AND plans.status = 'completed'
369369+ AND plans_fts MATCH ?
370370+ ORDER BY score
371371+ LIMIT ?
372372+ `);
373373+374374+ const rows = stmt.all(this.sessionName, query, maxResults) as {
375375+ id: string;
376376+ goal: string;
377377+ outcome: string | null;
378378+ score: number;
379379+ }[];
380380+381381+ return rows.map((row) => ({
382382+ id: row.id,
383383+ goal: row.goal,
384384+ outcome: row.outcome,
385385+ relevanceScore: Math.abs(row.score), // BM25 returns negative scores
386386+ }));
387387+ }
388388+389389+ /**
390390+ * Get a specific completed plan by ID
391391+ */
392392+ getHistoryPlan(planId: string): Plan | null {
393393+ const stmt = this.db.prepare(
394394+ `SELECT * FROM plans WHERE id = ? AND session_name = ? AND status = 'completed'`,
395395+ );
396396+ const row = stmt.get(planId, this.sessionName);
397397+ return row ? this.rowToPlan(row) : null;
398398+ }
399399+400400+ // ===========================================================================
401401+ // State Queries
402402+ // ===========================================================================
403403+404404+ /**
405405+ * Get the currently active plan
406406+ */
407407+ getActivePlan(): Plan | null {
408408+ const stmt = this.db.prepare(
409409+ `SELECT * FROM plans WHERE session_name = ? AND status = 'active'`,
410410+ );
411411+ const row = stmt.get(this.sessionName);
412412+ return row ? this.rowToPlan(row) : null;
413413+ }
414414+415415+ /**
416416+ * Get the queue contents
417417+ */
418418+ getQueue(): QueuedPlan[] {
419419+ const stmt = this.db.prepare(`
420420+ SELECT id, goal, queue_position
421421+ FROM plans
422422+ WHERE session_name = ? AND status = 'queued'
423423+ ORDER BY queue_position ASC
424424+ `);
425425+426426+ const rows = stmt.all(this.sessionName) as {
427427+ id: string;
428428+ goal: string;
429429+ queue_position: number;
430430+ }[];
431431+432432+ return rows.map((row) => ({
433433+ id: row.id,
434434+ goal: row.goal,
435435+ queuePosition: row.queue_position,
436436+ }));
437437+ }
438438+439439+ /**
440440+ * Get count of completed plans
441441+ */
442442+ getCompletedCount(): number {
443443+ const stmt = this.db.prepare(
444444+ `SELECT COUNT(*) as count FROM plans WHERE session_name = ? AND status = 'completed'`,
445445+ );
446446+ const row = stmt.get(this.sessionName) as { count: number };
447447+ return row.count;
448448+ }
449449+450450+ /**
451451+ * Get full session state for resume
452452+ */
453453+ getState(): SessionState {
454454+ const activePlan = this.getActivePlan();
455455+456456+ return {
457457+ sessionName: this.sessionName,
458458+ sessionPath: this.sessionPath,
459459+ taskDescription: this.getTaskDescription(),
460460+ queue: this.getQueue(),
461461+ activePlan: activePlan
462462+ ? {
463463+ id: activePlan.id,
464464+ goal: activePlan.goal,
465465+ filePath: this.planFiles.getFilePath(activePlan.id),
466466+ }
467467+ : null,
468468+ completedCount: this.getCompletedCount(),
469469+ };
470470+ }
471471+472472+ // ===========================================================================
473473+ // Helpers
474474+ // ===========================================================================
475475+476476+ /**
477477+ * Get a plan by ID
478478+ */
479479+ private getPlanById(id: string): Plan | null {
480480+ const stmt = this.db.prepare(
481481+ `SELECT * FROM plans WHERE id = ? AND session_name = ?`,
482482+ );
483483+ const row = stmt.get(id, this.sessionName);
484484+ return row ? this.rowToPlan(row) : null;
485485+ }
486486+487487+ /**
488488+ * Get the active plan, throwing if none exists
489489+ */
490490+ private requireActivePlan(): Plan {
491491+ const active = this.getActivePlan();
492492+ if (!active) {
493493+ throw new NinePlanError(
494494+ "NO_ACTIVE_PLAN",
495495+ "No plan is currently active",
496496+ "Use 9plan_queue_pull to get a plan first.",
497497+ );
498498+ }
499499+ return active;
500500+ }
501501+502502+ /**
503503+ * Get the next queue position
504504+ */
505505+ private getNextQueuePosition(position: "front" | "back"): number {
506506+ if (position === "front") {
507507+ return 1;
508508+ }
509509+510510+ const stmt = this.db.prepare(`
511511+ SELECT MAX(queue_position) as max_pos
512512+ FROM plans
513513+ WHERE session_name = ? AND status = 'queued'
514514+ `);
515515+ const row = stmt.get(this.sessionName) as { max_pos: number | null };
516516+ return (row.max_pos ?? 0) + 1;
517517+ }
518518+519519+ /**
520520+ * Shift all queue positions up by 1 (for front insertion)
521521+ */
522522+ private shiftQueuePositions(): void {
523523+ this.db
524524+ .prepare(
525525+ `UPDATE plans
526526+ SET queue_position = queue_position + 1
527527+ WHERE session_name = ? AND status = 'queued'`,
528528+ )
529529+ .run(this.sessionName);
530530+ }
531531+532532+ /**
533533+ * Renumber queue to be contiguous starting at 1
534534+ */
535535+ private renumberQueue(): void {
536536+ const queue = this.getQueue();
537537+ for (let i = 0; i < queue.length; i++) {
538538+ const plan = queue[i];
539539+ if (plan) {
540540+ this.db
541541+ .prepare(`UPDATE plans SET queue_position = ? WHERE id = ?`)
542542+ .run(i + 1, plan.id);
543543+ }
544544+ }
545545+ }
546546+547547+ /**
548548+ * Convert a database row to a Plan object
549549+ */
550550+ private rowToPlan(row: unknown): Plan {
551551+ const r = row as Record<string, unknown>;
552552+ return {
553553+ id: r.id as string,
554554+ sessionName: r.session_name as string,
555555+ status: r.status as Plan["status"],
556556+ queuePosition: r.queue_position as number | null,
557557+ goal: r.goal as string,
558558+ context: r.context as string | null,
559559+ inputs: r.inputs as string | null,
560560+ outputs: r.outputs as string | null,
561561+ approach: r.approach as string | null,
562562+ successCriteria: r.success_criteria as string | null,
563563+ notes: r.notes as string | null,
564564+ outcome: r.outcome as string | null,
565565+ createdAt: r.created_at as string,
566566+ completedAt: r.completed_at as string | null,
567567+ };
568568+ }
569569+570570+ /**
571571+ * Close the database connection
572572+ */
573573+ close(): void {
574574+ this.db.close();
575575+ }
576576+}
+37
src/env.ts
···11+/**
22+ * Environment configuration with Zod validation
33+ */
44+55+import { z } from "zod";
66+import envPaths from "env-paths";
77+import { join } from "node:path";
88+99+// Get cross-platform app directories
1010+const paths = envPaths("9plan", { suffix: "" });
1111+1212+// Environment schema
1313+const envSchema = z.object({
1414+ // Session storage location
1515+ NINEPLAN_SESSIONS_PATH: z.string().default(join(paths.data, "sessions")),
1616+1717+ // Logging level
1818+ NINEPLAN_LOG_LEVEL: z
1919+ .enum(["debug", "info", "warn", "error"])
2020+ .default("info"),
2121+2222+ // Transport mode
2323+ NINEPLAN_TRANSPORT: z.enum(["stdio", "http"]).default("stdio"),
2424+2525+ // HTTP settings (only used when transport is 'http')
2626+ NINEPLAN_HTTP_PORT: z.coerce.number().default(8080),
2727+ NINEPLAN_HTTP_HOST: z.string().default("127.0.0.1"),
2828+});
2929+3030+// Parse and export environment
3131+export const env = envSchema.parse(process.env);
3232+3333+// Export type for use in other modules
3434+export type Env = z.infer<typeof envSchema>;
3535+3636+// Export paths for convenience
3737+export { paths as appPaths };
+165
src/files/plan-files.ts
···11+/**
22+ * Plan file handler
33+ *
44+ * Manages plan files in the session's plans/ directory.
55+ * Plan files are human-readable text with markdown-like sections.
66+ */
77+88+import {
99+ readFileSync,
1010+ writeFileSync,
1111+ unlinkSync,
1212+ existsSync,
1313+ mkdirSync,
1414+} from "node:fs";
1515+import { join, dirname } from "node:path";
1616+import type { Logger } from "pino";
1717+import type { PlanContent, PlanFileHandlerDeps } from "../types.js";
1818+1919+/**
2020+ * Handles reading, writing, and managing plan files
2121+ */
2222+export class PlanFileHandlerImpl {
2323+ private readonly plansDir: string;
2424+ private readonly log: Logger;
2525+2626+ constructor(sessionPath: string, deps: PlanFileHandlerDeps) {
2727+ this.plansDir = join(sessionPath, "plans");
2828+ this.log = deps.logger.child({ component: "PlanFileHandler" });
2929+3030+ // Ensure plans directory exists
3131+ if (!existsSync(this.plansDir)) {
3232+ mkdirSync(this.plansDir, { recursive: true });
3333+ }
3434+ }
3535+3636+ /**
3737+ * Get the full file path for a plan ID
3838+ */
3939+ getFilePath(planId: string): string {
4040+ return join(this.plansDir, `${planId}.txt`);
4141+ }
4242+4343+ /**
4444+ * Write a plan to disk
4545+ */
4646+ write(planId: string, content: PlanContent): void {
4747+ const filePath = this.getFilePath(planId);
4848+ const fileContent = this.formatPlanContent(content);
4949+5050+ // Ensure parent directory exists
5151+ const dir = dirname(filePath);
5252+ if (!existsSync(dir)) {
5353+ mkdirSync(dir, { recursive: true });
5454+ }
5555+5656+ writeFileSync(filePath, fileContent, "utf-8");
5757+ this.log.debug({ planId, filePath }, "Plan file written");
5858+ }
5959+6060+ /**
6161+ * Read a plan from disk
6262+ */
6363+ read(planId: string): PlanContent {
6464+ const filePath = this.getFilePath(planId);
6565+ const fileContent = readFileSync(filePath, "utf-8");
6666+ return this.parsePlanContent(fileContent);
6767+ }
6868+6969+ /**
7070+ * Delete a plan file
7171+ */
7272+ delete(planId: string): void {
7373+ const filePath = this.getFilePath(planId);
7474+ if (existsSync(filePath)) {
7575+ unlinkSync(filePath);
7676+ this.log.debug({ planId, filePath }, "Plan file deleted");
7777+ }
7878+ }
7979+8080+ /**
8181+ * Check if a plan file exists
8282+ */
8383+ exists(planId: string): boolean {
8484+ return existsSync(this.getFilePath(planId));
8585+ }
8686+8787+ /**
8888+ * Append a note to the plan's Notes section with timestamp
8989+ */
9090+ appendToNotes(planId: string, note: string): void {
9191+ const content = this.read(planId);
9292+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
9393+ const newNote = `[${timestamp}] ${note}`;
9494+9595+ content.notes = content.notes ? `${content.notes}\n${newNote}` : newNote;
9696+9797+ this.write(planId, content);
9898+ this.log.debug({ planId, note: newNote }, "Note appended to plan");
9999+ }
100100+101101+ /**
102102+ * Format plan content as file text
103103+ */
104104+ private formatPlanContent(content: PlanContent): string {
105105+ return `# Context
106106+${content.context}
107107+108108+# Goal
109109+${content.goal}
110110+111111+# Inputs
112112+${content.inputs || "(none)"}
113113+114114+# Outputs
115115+${content.outputs || "(none)"}
116116+117117+# Approach
118118+${content.approach}
119119+120120+# Success Criteria
121121+${content.successCriteria}
122122+123123+# Notes
124124+${content.notes || "(none)"}
125125+`;
126126+ }
127127+128128+ /**
129129+ * Parse file text into plan content
130130+ */
131131+ private parsePlanContent(text: string): PlanContent {
132132+ const sections: Record<string, string> = {};
133133+134134+ // Split by section headers
135135+ const sectionPattern =
136136+ /^# (Context|Goal|Inputs|Outputs|Approach|Success Criteria|Notes)\s*$/gm;
137137+ const matches = [...text.matchAll(sectionPattern)];
138138+139139+ for (let i = 0; i < matches.length; i++) {
140140+ const match = matches[i];
141141+ if (!match) continue;
142142+143143+ const sectionName = match[1];
144144+ if (!sectionName) continue;
145145+146146+ const startIndex = (match.index || 0) + match[0].length;
147147+ const endIndex = matches[i + 1]?.index ?? text.length;
148148+ const content = text.slice(startIndex, endIndex).trim();
149149+150150+ // Normalize section names
151151+ const key = sectionName.toLowerCase().replace(" ", "");
152152+ sections[key] = content === "(none)" ? "" : content;
153153+ }
154154+155155+ return {
156156+ context: sections.context ?? "",
157157+ goal: sections.goal ?? "",
158158+ inputs: sections.inputs ?? "",
159159+ outputs: sections.outputs ?? "",
160160+ approach: sections.approach ?? "",
161161+ successCriteria: sections.successcriteria ?? "",
162162+ notes: sections.notes ?? "",
163163+ };
164164+ }
165165+}
+23
src/generators/plan-id.ts
···11+/**
22+ * Plan ID generator
33+ *
44+ * Generates short, alphanumeric IDs like "k7f3m" using nanoid
55+ * with a custom alphabet (lowercase letters and digits only).
66+ */
77+88+import { customAlphabet } from "nanoid";
99+1010+// Lowercase alphanumeric alphabet for human-friendly IDs
1111+const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
1212+1313+// Create ID generator with 5-character default length
1414+const nanoid = customAlphabet(alphabet, 5);
1515+1616+/**
1717+ * Generate a unique 5-character alphanumeric plan ID
1818+ *
1919+ * @returns A string like "k7f3m"
2020+ */
2121+export function generatePlanId(): string {
2222+ return nanoid();
2323+}
+27
src/generators/session-name.ts
···11+/**
22+ * Session name generator
33+ *
44+ * Generates human-memorable three-word identifiers like "amber-quiet-river"
55+ * using unique-names-generator with adjective-color-animal pattern.
66+ */
77+88+import {
99+ uniqueNamesGenerator,
1010+ adjectives,
1111+ colors,
1212+ animals,
1313+} from "unique-names-generator";
1414+1515+/**
1616+ * Generate a unique three-word session name
1717+ *
1818+ * @returns A hyphenated lowercase string like "amber-quiet-river"
1919+ */
2020+export function generateSessionName(): string {
2121+ return uniqueNamesGenerator({
2222+ dictionaries: [adjectives, colors, animals],
2323+ separator: "-",
2424+ length: 3,
2525+ style: "lowerCase",
2626+ });
2727+}
+47
src/index.ts
···11+#!/usr/bin/env node
22+/**
33+ * 9plan MCP Server - Entry Point
44+ *
55+ * Session-scoped work queue for AI agent task sequencing.
66+ */
77+88+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
99+import { createServer } from "./server.js";
1010+import { logger } from "./logger.js";
1111+1212+/**
1313+ * Main entry point
1414+ */
1515+async function main(): Promise<void> {
1616+ try {
1717+ // Create server
1818+ const server = createServer();
1919+2020+ // Create stdio transport
2121+ const transport = new StdioServerTransport();
2222+2323+ // Connect server to transport
2424+ await server.connect(transport);
2525+2626+ logger.info("9plan MCP server running on stdio");
2727+2828+ // Handle graceful shutdown
2929+ const shutdown = () => {
3030+ logger.info("Shutting down...");
3131+ void server.close().then(() => process.exit(0));
3232+ };
3333+3434+ process.on("SIGINT", shutdown);
3535+ process.on("SIGTERM", shutdown);
3636+ } catch (error) {
3737+ logger.error({ error }, "Fatal error starting server");
3838+ process.exit(1);
3939+ }
4040+}
4141+4242+// Run
4343+main().catch((error: unknown) => {
4444+ // Using console.error for fatal errors before logger is ready
4545+ console.error("Fatal error:", error);
4646+ process.exit(1);
4747+});
+32
src/logger.ts
···11+/**
22+ * Pino logger configuration
33+ *
44+ * In development mode, uses pino-pretty for readable output.
55+ * In production, outputs structured JSON.
66+ */
77+88+import pino from "pino";
99+import { env } from "./env.js";
1010+1111+// Create logger with appropriate transport based on environment
1212+export const logger =
1313+ process.env.NODE_ENV === "development"
1414+ ? pino({
1515+ level: env.NINEPLAN_LOG_LEVEL,
1616+ transport: {
1717+ target: "pino-pretty",
1818+ options: {
1919+ colorize: true,
2020+ translateTime: "SYS:standard",
2121+ ignore: "pid,hostname",
2222+ },
2323+ },
2424+ })
2525+ : pino({
2626+ level: env.NINEPLAN_LOG_LEVEL,
2727+ // In production, just use base JSON logger
2828+ base: { name: "9plan" },
2929+ });
3030+3131+// Re-export Logger type for convenience
3232+export type { Logger } from "pino";
+97
src/prompts/bootstrap.ts
···11+/**
22+ * Bootstrap prompt
33+ *
44+ * Guides initial session setup and planning decomposition.
55+ */
66+77+import { z } from "zod";
88+99+// Prompt argument schema
1010+export const bootstrapArgumentSchema = {
1111+ task: z
1212+ .string()
1313+ .optional()
1414+ .describe("Optional initial task description to start with"),
1515+};
1616+1717+// Prompt content
1818+export const BOOTSTRAP_PROMPT_CONTENT = `You are helping the user set up a new 9plan session for a complex task.
1919+2020+## WORKFLOW
2121+2222+1. **CLARIFY**: Ask questions to understand scope, components, and dependencies
2323+ - What is the overall goal?
2424+ - What are the major components or phases?
2525+ - What are the dependencies between them?
2626+ - What does success look like?
2727+2828+2. **CREATE SESSION**: Use 9plan_session_create with a clear task description
2929+3030+3. **DECOMPOSE**: Break the task into discrete, self-contained plans
3131+ - Each plan should be executable without additional context
3232+ - Identify cross-component dependencies NOW (this is your only chance!)
3333+ - Plans should have specific, measurable goals
3434+3535+4. **CAPTURE DEPENDENCIES**: For each plan, specify:
3636+ - **Inputs**: What this plan needs from other plans (by description, not ID)
3737+ - **Outputs**: What this plan produces that others may consume
3838+3939+5. **ENQUEUE**: Add plans with 9plan_queue_add
4040+ - Respect dependency order (dependencies must complete first)
4141+ - Use "back" position for normal work
4242+ - IMPORTANT: Adding A, B, C at "front" gives [C, B, A] - add in REVERSE order if using front
4343+4444+6. **START WORK**: Pull first plan with 9plan_queue_pull
4545+4646+## PLAN QUALITY CHECKLIST
4747+4848+Before adding a plan, verify:
4949+□ Goal is specific and measurable (not vague)
5050+□ Approach has concrete, actionable steps
5151+□ Success criteria are observable (you'll know when done)
5252+□ Inputs reference dependencies by description (e.g., "auth module from Authentication work")
5353+□ Outputs are specific enough to search for later
5454+5555+## DEPENDENCY RESOLUTION
5656+5757+Plans reference dependencies by description, not by ID:
5858+- At execution time, use 9plan_history_search to find matching completed outputs
5959+- Example: If plan needs "auth module", search history for "auth module authentication"
6060+6161+## DECOMPOSITION PATTERN
6262+6363+When a plan is too large:
6464+1. Add subplans at front in REVERSE order (so they execute in correct order)
6565+2. Defer parent to back with 9plan_plan_defer
6666+3. Record child plan IDs in parent's Notes
6767+4. When parent is re-pulled later, use 9plan_history_get to aggregate child outcomes
6868+6969+## EXAMPLE DECOMPOSITION
7070+7171+Starting task: "Build Ghost-powered blog app"
7272+7373+Plans to add (in order they should execute):
7474+1. Ghost API Client - Create authenticated client
7575+2. Cache System - Add response caching
7676+3. Display Layer - Build UI components
7777+7878+Since we want execution order [1, 2, 3], add plans at back:
7979+- 9plan_queue_add(Ghost API Client, position: back) → [1]
8080+- 9plan_queue_add(Cache System, position: back) → [1, 2]
8181+- 9plan_queue_add(Display Layer, position: back) → [1, 2, 3]
8282+8383+Now ready to pull!`;
8484+8585+// Prompt configuration
8686+export const bootstrapPromptConfig = {
8787+ name: "bootstrap",
8888+ description:
8989+ "Set up a new 9plan session for a complex task. Guides you through clarifying requirements, decomposing into plans, and capturing dependencies.",
9090+ arguments: [
9191+ {
9292+ name: "task",
9393+ description: "Optional initial task description to start with",
9494+ required: false,
9595+ },
9696+ ],
9797+};
+290
src/server.ts
···11+/**
22+ * MCP Server factory
33+ *
44+ * Creates and configures the 9plan MCP server with all tools and prompts.
55+ * Manages session state across tool calls.
66+ */
77+88+import { z } from "zod";
99+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1010+import type { SessionStore } from "./db/session-store.js";
1111+import {
1212+ createNewSession,
1313+ resumeSession,
1414+ getSessionPath,
1515+} from "./container.js";
1616+import { logger } from "./logger.js";
1717+import { NinePlanError, formatResponse } from "./types.js";
1818+1919+// Import all tools
2020+import {
2121+ sessionCreateToolConfig,
2222+ handleSessionResume,
2323+ sessionResumeToolConfig,
2424+ handleQueueAdd,
2525+ queueAddToolConfig,
2626+ handleQueuePull,
2727+ queuePullToolConfig,
2828+ handlePlanDefer,
2929+ planDeferToolConfig,
3030+ handlePlanComplete,
3131+ planCompleteToolConfig,
3232+ handlePlanDiscard,
3333+ planDiscardToolConfig,
3434+ handleHistorySearch,
3535+ historySearchToolConfig,
3636+ handleHistoryGet,
3737+ historyGetToolConfig,
3838+} from "./tools/index.js";
3939+4040+// Import prompts
4141+import { BOOTSTRAP_PROMPT_CONTENT } from "./prompts/bootstrap.js";
4242+4343+/* eslint-disable @typescript-eslint/no-unnecessary-condition */
4444+4545+/**
4646+ * Session state manager
4747+ * Tracks the currently active session across tool calls
4848+ */
4949+class SessionManager {
5050+ private currentStore: SessionStore | null = null;
5151+ private currentSessionName: string | null = null;
5252+5353+ /**
5454+ * Set the current session
5555+ */
5656+ setSession(sessionName: string, store: SessionStore): void {
5757+ // Close previous session if exists
5858+ this.close();
5959+ this.currentSessionName = sessionName;
6060+ this.currentStore = store;
6161+ logger.info({ sessionName }, "Session activated");
6262+ }
6363+6464+ /**
6565+ * Get the current session store, throwing if none active
6666+ */
6767+ getStore(): SessionStore {
6868+ if (!this.currentStore || !this.currentSessionName) {
6969+ throw new NinePlanError(
7070+ "SESSION_NOT_FOUND",
7171+ "No session is currently active",
7272+ "Use 9plan_session_create to create a new session, or 9plan_session_resume to load an existing one.",
7373+ );
7474+ }
7575+ return this.currentStore;
7676+ }
7777+7878+ /**
7979+ * Get the current session name
8080+ */
8181+ getSessionName(): string {
8282+ if (!this.currentSessionName) {
8383+ throw new NinePlanError(
8484+ "SESSION_NOT_FOUND",
8585+ "No session is currently active",
8686+ "Use 9plan_session_create to create a new session, or 9plan_session_resume to load an existing one.",
8787+ );
8888+ }
8989+ return this.currentSessionName;
9090+ }
9191+9292+ /**
9393+ * Check if a session is active
9494+ */
9595+ hasSession(): boolean {
9696+ return this.currentStore !== null && this.currentSessionName !== null;
9797+ }
9898+9999+ /**
100100+ * Close the current session
101101+ */
102102+ close(): void {
103103+ if (this.currentStore) {
104104+ this.currentStore.close();
105105+ logger.info({ sessionName: this.currentSessionName }, "Session closed");
106106+ }
107107+ this.currentStore = null;
108108+ this.currentSessionName = null;
109109+ }
110110+}
111111+112112+/**
113113+ * Create the MCP server with all tools registered
114114+ */
115115+export function createServer(): McpServer {
116116+ const server = new McpServer({
117117+ name: "9plan",
118118+ version: "1.0.0",
119119+ });
120120+121121+ const sessionManager = new SessionManager();
122122+123123+ // Register session create tool - handles session creation internally
124124+ server.registerTool(
125125+ "9plan_session_create",
126126+ sessionCreateToolConfig,
127127+ (args) => {
128128+ const taskDescription = args.task_description;
129129+ const { sessionName, store } = createNewSession(taskDescription);
130130+ const sessionPath = getSessionPath(sessionName);
131131+132132+ sessionManager.setSession(sessionName, store);
133133+134134+ const response = formatResponse(
135135+ sessionName,
136136+ `Session created: ${sessionName}
137137+Directory: ${sessionPath}
138138+139139+The session is ready. Use 9plan_queue_add to add plans, then 9plan_queue_pull to begin work.`,
140140+ );
141141+142142+ return {
143143+ content: [{ type: "text" as const, text: response }],
144144+ };
145145+ },
146146+ );
147147+148148+ // Register session resume tool
149149+ server.registerTool(
150150+ "9plan_session_resume",
151151+ sessionResumeToolConfig,
152152+ (args) => {
153153+ const sessionName = args.session_name;
154154+ const store = resumeSession(sessionName);
155155+ sessionManager.setSession(sessionName, store);
156156+ return handleSessionResume({ session_name: sessionName });
157157+ },
158158+ );
159159+160160+ // Register queue tools
161161+ server.registerTool("9plan_queue_add", queueAddToolConfig, (args) => {
162162+ const store = sessionManager.getStore();
163163+ const sessionName = sessionManager.getSessionName();
164164+ const queueAddArgs = {
165165+ context: args.context,
166166+ goal: args.goal,
167167+ approach: args.approach,
168168+ success_criteria: args.success_criteria,
169169+ ...(args.inputs !== undefined && { inputs: args.inputs }),
170170+ ...(args.outputs !== undefined && { outputs: args.outputs }),
171171+ ...(args.position !== undefined && {
172172+ position: args.position,
173173+ }),
174174+ };
175175+ return handleQueueAdd(store, sessionName, queueAddArgs);
176176+ });
177177+178178+ server.registerTool(
179179+ "9plan_queue_pull",
180180+ queuePullToolConfig,
181181+ (_args) => {
182182+ const store = sessionManager.getStore();
183183+ const sessionName = sessionManager.getSessionName();
184184+ return handleQueuePull(store, sessionName, {});
185185+ },
186186+ );
187187+188188+ // Register plan lifecycle tools
189189+ server.registerTool("9plan_plan_defer", planDeferToolConfig, (args) => {
190190+ const store = sessionManager.getStore();
191191+ const sessionName = sessionManager.getSessionName();
192192+ const deferArgs = {
193193+ reason: args.reason,
194194+ ...(args.position !== undefined && {
195195+ position: args.position,
196196+ }),
197197+ };
198198+ return handlePlanDefer(store, sessionName, deferArgs);
199199+ });
200200+201201+ server.registerTool(
202202+ "9plan_plan_complete",
203203+ planCompleteToolConfig,
204204+ (args) => {
205205+ const store = sessionManager.getStore();
206206+ const sessionName = sessionManager.getSessionName();
207207+ return handlePlanComplete(store, sessionName, {
208208+ outcome: args.outcome,
209209+ });
210210+ },
211211+ );
212212+213213+ server.registerTool(
214214+ "9plan_plan_discard",
215215+ planDiscardToolConfig,
216216+ (args) => {
217217+ const store = sessionManager.getStore();
218218+ const sessionName = sessionManager.getSessionName();
219219+ return handlePlanDiscard(store, sessionName, {
220220+ reason: args.reason,
221221+ });
222222+ },
223223+ );
224224+225225+ // Register history tools
226226+ server.registerTool(
227227+ "9plan_history_search",
228228+ historySearchToolConfig,
229229+ (args) => {
230230+ const store = sessionManager.getStore();
231231+ const sessionName = sessionManager.getSessionName();
232232+ const searchArgs = {
233233+ query: args.query,
234234+ ...(args.max_results !== undefined && {
235235+ max_results: args.max_results,
236236+ }),
237237+ };
238238+ return handleHistorySearch(store, sessionName, searchArgs);
239239+ },
240240+ );
241241+242242+ server.registerTool(
243243+ "9plan_history_get",
244244+ historyGetToolConfig,
245245+ (args) => {
246246+ const store = sessionManager.getStore();
247247+ const sessionName = sessionManager.getSessionName();
248248+ return handleHistoryGet(store, sessionName, {
249249+ plan_id: args.plan_id,
250250+ });
251251+ },
252252+ );
253253+254254+ // Register bootstrap prompt
255255+ server.registerPrompt(
256256+ "bootstrap",
257257+ {
258258+ description:
259259+ "Set up a new 9plan session for a complex task. Guides you through clarifying requirements, decomposing into plans, and capturing dependencies.",
260260+ argsSchema: {
261261+ task: z
262262+ .string()
263263+ .optional()
264264+ .describe("Optional initial task description to start with"),
265265+ },
266266+ },
267267+ (args) => {
268268+ const taskArg = args.task;
269269+ const taskText = taskArg
270270+ ? `\n\nThe user has provided an initial task: "${taskArg}"\n\nStart by clarifying the task and then proceed with the workflow.`
271271+ : "";
272272+273273+ return {
274274+ messages: [
275275+ {
276276+ role: "user" as const,
277277+ content: {
278278+ type: "text" as const,
279279+ text: BOOTSTRAP_PROMPT_CONTENT + taskText,
280280+ },
281281+ },
282282+ ],
283283+ };
284284+ },
285285+ );
286286+287287+ logger.info("9plan MCP server initialized with 9 tools and 1 prompt");
288288+289289+ return server;
290290+}
+70
src/tools/history-get.ts
···11+/**
22+ * 9plan_history_get tool
33+ *
44+ * Retrieves a specific completed plan by ID.
55+ */
66+77+import { z } from "zod";
88+import type { SessionStoreInterface } from "../types.js";
99+import { formatResponse, NinePlanError } from "../types.js";
1010+1111+// Input schema
1212+export const historyGetInputSchema = {
1313+ plan_id: z
1414+ .string()
1515+ .min(1)
1616+ .describe("The ID of the completed plan to retrieve (e.g., k7f3m)"),
1717+};
1818+1919+// Input type
2020+export interface HistoryGetInput {
2121+ plan_id: string;
2222+}
2323+2424+/**
2525+ * Handle history get tool call
2626+ */
2727+export function handleHistoryGet(
2828+ store: SessionStoreInterface,
2929+ sessionName: string,
3030+ args: HistoryGetInput,
3131+): { content: { type: "text"; text: string }[] } {
3232+ const plan = store.getHistoryPlan(args.plan_id);
3333+3434+ if (!plan) {
3535+ const error = new NinePlanError(
3636+ "PLAN_NOT_FOUND",
3737+ `Plan not found: ${args.plan_id}`,
3838+ "The plan may not have been completed yet (check queue), or may have been discarded.\nUse 9plan_session_resume to see current queue, or 9plan_history_search to find related plans.",
3939+ );
4040+ return {
4141+ content: [{ type: "text", text: error.format(sessionName) }],
4242+ };
4343+ }
4444+4545+ // Format the plan content
4646+ let content = `Plan ${plan.id} (completed)\n\n`;
4747+4848+ content += `# Context\n${plan.context ?? "(none)"}\n\n`;
4949+ content += `# Goal\n${plan.goal}\n\n`;
5050+ content += `# Inputs\n${plan.inputs ?? "(none)"}\n\n`;
5151+ content += `# Outputs\n${plan.outputs ?? "(none)"}\n\n`;
5252+ content += `# Approach\n${plan.approach ?? "(none)"}\n\n`;
5353+ content += `# Success Criteria\n${plan.successCriteria ?? "(none)"}\n\n`;
5454+ content += `# Notes\n${plan.notes ?? "(none)"}\n\n`;
5555+ content += `# Outcome\n${plan.outcome ?? "(none)"}`;
5656+5757+ const response = formatResponse(sessionName, content);
5858+5959+ return {
6060+ content: [{ type: "text", text: response }],
6161+ };
6262+}
6363+6464+// Tool configuration for registration
6565+export const historyGetToolConfig = {
6666+ title: "Get History Plan",
6767+ description:
6868+ "Retrieves a specific completed plan by ID. Returns the full plan content including outcome. Used for dependency resolution and aggregating child outcomes after decomposition.",
6969+ inputSchema: historyGetInputSchema,
7070+};
+93
src/tools/history-search.ts
···11+/**
22+ * 9plan_history_search tool
33+ *
44+ * Searches completed plans for outputs matching a query.
55+ * Uses FTS5 with BM25 ranking.
66+ */
77+88+import { z } from "zod";
99+import type { SessionStoreInterface } from "../types.js";
1010+import { formatResponse } from "../types.js";
1111+1212+// Input schema
1313+export const historySearchInputSchema = {
1414+ query: z
1515+ .string()
1616+ .min(1, "Query is required")
1717+ .describe(
1818+ "Search terms to match against completed plan goals, outputs, and outcomes",
1919+ ),
2020+ max_results: z
2121+ .number()
2222+ .int()
2323+ .min(1)
2424+ .max(50)
2525+ .optional()
2626+ .default(10)
2727+ .describe("Maximum number of results to return (default: 10, max: 50)"),
2828+};
2929+3030+// Input type
3131+export interface HistorySearchInput {
3232+ query: string;
3333+ max_results?: number;
3434+}
3535+3636+/**
3737+ * Handle history search tool call
3838+ */
3939+export function handleHistorySearch(
4040+ store: SessionStoreInterface,
4141+ sessionName: string,
4242+ args: HistorySearchInput,
4343+): { content: { type: "text"; text: string }[] } {
4444+ const matches = store.searchHistory(args.query, args.max_results ?? 10);
4545+4646+ if (matches.length === 0) {
4747+ const response = formatResponse(
4848+ sessionName,
4949+ `No matching plans found for: "${args.query}"
5050+5151+Try different search terms, or check if the relevant work has been completed yet.`,
5252+ );
5353+ return {
5454+ content: [{ type: "text", text: response }],
5555+ };
5656+ }
5757+5858+ // Format results
5959+ let resultText = `Found ${String(matches.length)} matching plan${matches.length === 1 ? "" : "s"}:\n\n`;
6060+6161+ for (let i = 0; i < matches.length; i++) {
6262+ const match = matches[i];
6363+ if (!match) continue;
6464+6565+ resultText += `${String(i + 1)}. Plan ${match.id}\n`;
6666+ resultText += ` Goal: ${match.goal}\n`;
6767+ if (match.outcome) {
6868+ // Truncate long outcomes for the list view
6969+ const outcomePreview =
7070+ match.outcome.length > 200
7171+ ? match.outcome.slice(0, 200) + "..."
7272+ : match.outcome;
7373+ resultText += ` Outcome: ${outcomePreview}\n`;
7474+ }
7575+ resultText += "\n";
7676+ }
7777+7878+ resultText += "Use 9plan_history_get with the plan ID for full details.";
7979+8080+ const response = formatResponse(sessionName, resultText);
8181+8282+ return {
8383+ content: [{ type: "text", text: response }],
8484+ };
8585+}
8686+8787+// Tool configuration for registration
8888+export const historySearchToolConfig = {
8989+ title: "Search History",
9090+ description:
9191+ "Searches completed plans for outputs matching a query. Uses full-text search across goals, outputs, and outcomes. Primary mechanism for resolving plan dependencies.",
9292+ inputSchema: historySearchInputSchema,
9393+};
+67
src/tools/index.ts
···11+/**
22+ * Tool registration aggregator
33+ *
44+ * Exports all tool handlers and configurations for server registration.
55+ */
66+77+// Session tools
88+export { sessionCreateToolConfig } from "./session-create.js";
99+1010+export {
1111+ handleSessionResume,
1212+ sessionResumeToolConfig,
1313+ sessionResumeInputSchema,
1414+ type SessionResumeInput,
1515+} from "./session-resume.js";
1616+1717+// Queue tools
1818+export {
1919+ handleQueueAdd,
2020+ queueAddToolConfig,
2121+ queueAddInputSchema,
2222+ type QueueAddInput,
2323+} from "./queue-add.js";
2424+2525+export {
2626+ handleQueuePull,
2727+ queuePullToolConfig,
2828+ queuePullInputSchema,
2929+ type QueuePullInput,
3030+} from "./queue-pull.js";
3131+3232+// Plan lifecycle tools
3333+export {
3434+ handlePlanDefer,
3535+ planDeferToolConfig,
3636+ planDeferInputSchema,
3737+ type PlanDeferInput,
3838+} from "./plan-defer.js";
3939+4040+export {
4141+ handlePlanComplete,
4242+ planCompleteToolConfig,
4343+ planCompleteInputSchema,
4444+ type PlanCompleteInput,
4545+} from "./plan-complete.js";
4646+4747+export {
4848+ handlePlanDiscard,
4949+ planDiscardToolConfig,
5050+ planDiscardInputSchema,
5151+ type PlanDiscardInput,
5252+} from "./plan-discard.js";
5353+5454+// History tools
5555+export {
5656+ handleHistorySearch,
5757+ historySearchToolConfig,
5858+ historySearchInputSchema,
5959+ type HistorySearchInput,
6060+} from "./history-search.js";
6161+6262+export {
6363+ handleHistoryGet,
6464+ historyGetToolConfig,
6565+ historyGetInputSchema,
6666+ type HistoryGetInput,
6767+} from "./history-get.js";
+89
src/tools/plan-complete.ts
···11+/**
22+ * 9plan_plan_complete tool
33+ *
44+ * Marks the active plan as done, storing the outcome in history.
55+ * The plan becomes searchable via 9plan_history_search.
66+ */
77+88+import { z } from "zod";
99+import type { SessionStoreInterface } from "../types.js";
1010+import { formatResponse, NinePlanError } from "../types.js";
1111+1212+// Input schema
1313+export const planCompleteInputSchema = {
1414+ outcome: z
1515+ .string()
1616+ .min(1, "Outcome is required")
1717+ .describe(
1818+ "Summary of what was accomplished, including outputs produced. This will be indexed for history search.",
1919+ ),
2020+};
2121+2222+// Input type
2323+export interface PlanCompleteInput {
2424+ outcome: string;
2525+}
2626+2727+/**
2828+ * Handle plan complete tool call
2929+ */
3030+export function handlePlanComplete(
3131+ store: SessionStoreInterface,
3232+ sessionName: string,
3333+ args: PlanCompleteInput,
3434+): { content: { type: "text"; text: string }[] } {
3535+ try {
3636+ const activePlan = store.getActivePlan();
3737+ if (!activePlan) {
3838+ throw new NinePlanError(
3939+ "NO_ACTIVE_PLAN",
4040+ "No plan is currently active",
4141+ "Use 9plan_queue_pull to get a plan first.",
4242+ );
4343+ }
4444+4545+ const planId = activePlan.id;
4646+ store.completePlan(args.outcome);
4747+4848+ // Get updated queue info
4949+ const queue = store.getQueue();
5050+ const queueStatus =
5151+ queue.length > 0
5252+ ? `${String(queue.length)} plans remaining`
5353+ : "Queue is now empty!";
5454+5555+ const nextAction =
5656+ queue.length > 0
5757+ ? "Next: Use 9plan_queue_pull to continue work."
5858+ : "Task complete! Use 9plan_history_search to review what was accomplished.";
5959+6060+ const response = formatResponse(
6161+ sessionName,
6262+ `Plan ${planId} completed and indexed.
6363+6464+The plan's content and outcome are now searchable via 9plan_history_search.
6565+6666+Queue status: ${queueStatus}
6767+${nextAction}`,
6868+ );
6969+7070+ return {
7171+ content: [{ type: "text", text: response }],
7272+ };
7373+ } catch (error) {
7474+ if (error instanceof NinePlanError) {
7575+ return {
7676+ content: [{ type: "text", text: error.format(sessionName) }],
7777+ };
7878+ }
7979+ throw error;
8080+ }
8181+}
8282+8383+// Tool configuration for registration
8484+export const planCompleteToolConfig = {
8585+ title: "Complete Plan",
8686+ description:
8787+ "Marks the active plan as done. The outcome is stored and indexed for future searches via 9plan_history_search. The plan file is deleted (content lives in database).",
8888+ inputSchema: planCompleteInputSchema,
8989+};
+94
src/tools/plan-defer.ts
···11+/**
22+ * 9plan_plan_defer tool
33+ *
44+ * Returns the active plan to the queue without completing it.
55+ * Appends the reason to the plan's Notes section.
66+ */
77+88+import { z } from "zod";
99+import type { SessionStoreInterface } from "../types.js";
1010+import { formatResponse, NinePlanError } from "../types.js";
1111+1212+// Input schema
1313+export const planDeferInputSchema = {
1414+ reason: z
1515+ .string()
1616+ .min(1, "Reason is required")
1717+ .describe("Why the plan is being deferred (will be recorded in Notes)"),
1818+ position: z
1919+ .enum(["front", "back"])
2020+ .optional()
2121+ .default("back")
2222+ .describe(
2323+ 'Where to place in queue: "front" to retry soon, "back" for decomposition/later',
2424+ ),
2525+};
2626+2727+// Input type
2828+export interface PlanDeferInput {
2929+ reason: string;
3030+ position?: "front" | "back";
3131+}
3232+3333+/**
3434+ * Handle plan defer tool call
3535+ */
3636+export function handlePlanDefer(
3737+ store: SessionStoreInterface,
3838+ sessionName: string,
3939+ args: PlanDeferInput,
4040+): { content: { type: "text"; text: string }[] } {
4141+ try {
4242+ const activePlan = store.getActivePlan();
4343+ if (!activePlan) {
4444+ throw new NinePlanError(
4545+ "NO_ACTIVE_PLAN",
4646+ "No plan is currently active",
4747+ "Use 9plan_queue_pull to get a plan first.",
4848+ );
4949+ }
5050+5151+ const planId = activePlan.id;
5252+ store.deferPlan(args.reason, args.position ?? "back");
5353+5454+ // Get updated queue info
5555+ const queue = store.getQueue();
5656+ const deferredPlan = queue.find((p) => p.id === planId);
5757+ const queuePosition = deferredPlan?.queuePosition ?? queue.length;
5858+5959+ const positionText =
6060+ args.position === "front"
6161+ ? `${String(queuePosition)} (will be pulled next)`
6262+ : `${String(queuePosition)} (will execute after current queue)`;
6363+6464+ const response = formatResponse(
6565+ sessionName,
6666+ `Plan ${planId} deferred to ${args.position ?? "back"} of queue.
6767+Reason recorded in Notes.
6868+6969+Queue status: ${String(queue.length)} plans
7070+Position: ${positionText}
7171+7272+${args.position === "front" ? "Resolve the blocking issue, then use 9plan_queue_pull to retry." : "Use 9plan_queue_pull to continue with queued work."}`,
7373+ );
7474+7575+ return {
7676+ content: [{ type: "text", text: response }],
7777+ };
7878+ } catch (error) {
7979+ if (error instanceof NinePlanError) {
8080+ return {
8181+ content: [{ type: "text", text: error.format(sessionName) }],
8282+ };
8383+ }
8484+ throw error;
8585+ }
8686+}
8787+8888+// Tool configuration for registration
8989+export const planDeferToolConfig = {
9090+ title: "Defer Plan",
9191+ description:
9292+ 'Returns the active plan to the queue without completing it. Use "front" position for blocked work (retry soon), "back" for decomposition (aggregate later). The reason is appended to Notes.',
9393+ inputSchema: planDeferInputSchema,
9494+};
+88
src/tools/plan-discard.ts
···11+/**
22+ * 9plan_plan_discard tool
33+ *
44+ * Abandons the active plan without completing it.
55+ * The plan is NOT recorded in history and cannot be searched.
66+ */
77+88+import { z } from "zod";
99+import type { SessionStoreInterface } from "../types.js";
1010+import { formatResponse, NinePlanError } from "../types.js";
1111+1212+// Input schema
1313+export const planDiscardInputSchema = {
1414+ reason: z
1515+ .string()
1616+ .min(1, "Reason is required")
1717+ .describe("Why the plan is being discarded (for logging only)"),
1818+};
1919+2020+// Input type
2121+export interface PlanDiscardInput {
2222+ reason: string;
2323+}
2424+2525+/**
2626+ * Handle plan discard tool call
2727+ */
2828+export function handlePlanDiscard(
2929+ store: SessionStoreInterface,
3030+ sessionName: string,
3131+ args: PlanDiscardInput,
3232+): { content: { type: "text"; text: string }[] } {
3333+ try {
3434+ const activePlan = store.getActivePlan();
3535+ if (!activePlan) {
3636+ throw new NinePlanError(
3737+ "NO_ACTIVE_PLAN",
3838+ "No plan is currently active",
3939+ "Use 9plan_queue_pull to get a plan first.",
4040+ );
4141+ }
4242+4343+ const planId = activePlan.id;
4444+ store.discardPlan(args.reason);
4545+4646+ // Get updated queue info
4747+ const queue = store.getQueue();
4848+ const queueStatus =
4949+ queue.length > 0
5050+ ? `${String(queue.length)} plans remaining`
5151+ : "Queue is now empty!";
5252+5353+ const nextAction =
5454+ queue.length > 0
5555+ ? "Next: Use 9plan_queue_pull to continue work."
5656+ : "Task complete! Use 9plan_history_search to review what was accomplished.";
5757+5858+ const response = formatResponse(
5959+ sessionName,
6060+ `Plan ${planId} discarded.
6161+Reason: ${args.reason}
6262+6363+The plan has been removed and will not appear in history.
6464+6565+Queue status: ${queueStatus}
6666+${nextAction}`,
6767+ );
6868+6969+ return {
7070+ content: [{ type: "text", text: response }],
7171+ };
7272+ } catch (error) {
7373+ if (error instanceof NinePlanError) {
7474+ return {
7575+ content: [{ type: "text", text: error.format(sessionName) }],
7676+ };
7777+ }
7878+ throw error;
7979+ }
8080+}
8181+8282+// Tool configuration for registration
8383+export const planDiscardToolConfig = {
8484+ title: "Discard Plan",
8585+ description:
8686+ "Abandons the active plan without completing. Use when a plan becomes obsolete or was misconceived. The plan will NOT appear in history search.",
8787+ inputSchema: planDiscardInputSchema,
8888+};
+112
src/tools/queue-add.ts
···11+/**
22+ * 9plan_queue_add tool
33+ *
44+ * Adds a new plan to the queue with front/back positioning.
55+ */
66+77+import { z } from "zod";
88+import type { SessionStoreInterface } from "../types.js";
99+import { formatResponse, NinePlanError } from "../types.js";
1010+1111+// Input schema
1212+export const queueAddInputSchema = {
1313+ context: z
1414+ .string()
1515+ .min(1, "Context is required")
1616+ .describe("Where this plan fits in the overall task"),
1717+ goal: z
1818+ .string()
1919+ .min(1, "Goal is required")
2020+ .describe("What this plan accomplishes"),
2121+ approach: z
2222+ .string()
2323+ .min(1, "Approach is required")
2424+ .describe("How to accomplish the goal (detailed, actionable steps)"),
2525+ success_criteria: z
2626+ .string()
2727+ .min(1, "Success criteria is required")
2828+ .describe("Observable conditions that indicate completion"),
2929+ inputs: z
3030+ .string()
3131+ .optional()
3232+ .describe(
3333+ 'Dependencies from other plans (format: "- description: source")',
3434+ ),
3535+ outputs: z
3636+ .string()
3737+ .optional()
3838+ .describe(
3939+ 'What this plan produces that others may consume (format: "- description: details")',
4040+ ),
4141+ position: z
4242+ .enum(["front", "back"])
4343+ .optional()
4444+ .default("back")
4545+ .describe(
4646+ 'Queue position: "front" for blocking work, "back" for eventual work',
4747+ ),
4848+};
4949+5050+// Input type
5151+export interface QueueAddInput {
5252+ context: string;
5353+ goal: string;
5454+ approach: string;
5555+ success_criteria: string;
5656+ inputs?: string;
5757+ outputs?: string;
5858+ position?: "front" | "back";
5959+}
6060+6161+/**
6262+ * Handle queue add tool call
6363+ */
6464+export function handleQueueAdd(
6565+ store: SessionStoreInterface,
6666+ sessionName: string,
6767+ args: QueueAddInput,
6868+): { content: { type: "text"; text: string }[] } {
6969+ try {
7070+ const planInput = {
7171+ context: args.context,
7272+ goal: args.goal,
7373+ approach: args.approach,
7474+ successCriteria: args.success_criteria,
7575+ ...(args.inputs !== undefined && { inputs: args.inputs }),
7676+ ...(args.outputs !== undefined && { outputs: args.outputs }),
7777+ };
7878+7979+ const plan = store.addPlan(planInput, args.position ?? "back");
8080+8181+ const planPath = store.getSessionPath() + `/plans/${plan.id}.txt`;
8282+ const positionNote = args.position === "front" ? " (front)" : "";
8383+8484+ const response = formatResponse(
8585+ sessionName,
8686+ `Plan added: ${plan.id}
8787+Path: ${planPath}
8888+Queue position: ${String(plan.queuePosition)}${positionNote}
8989+9090+The plan file has been created. Use 9plan_queue_pull when ready to execute.`,
9191+ );
9292+9393+ return {
9494+ content: [{ type: "text", text: response }],
9595+ };
9696+ } catch (error) {
9797+ if (error instanceof NinePlanError) {
9898+ return {
9999+ content: [{ type: "text", text: error.format(sessionName) }],
100100+ };
101101+ }
102102+ throw error;
103103+ }
104104+}
105105+106106+// Tool configuration for registration
107107+export const queueAddToolConfig = {
108108+ title: "Add Plan to Queue",
109109+ description:
110110+ "Adds a new plan to the queue. Required fields: context, goal, approach, success_criteria. Optional: inputs, outputs, position (front/back, default: back). Front = blocking work, back = eventual work.",
111111+ inputSchema: queueAddInputSchema,
112112+};
+94
src/tools/queue-pull.ts
···11+/**
22+ * 9plan_queue_pull tool
33+ *
44+ * Removes the front plan from queue and marks it active.
55+ */
66+77+import type { SessionStoreInterface } from "../types.js";
88+import { formatResponse, NinePlanError } from "../types.js";
99+1010+// Input schema (no required inputs)
1111+export const queuePullInputSchema = {};
1212+1313+// Input type
1414+export type QueuePullInput = Record<string, never>;
1515+1616+/**
1717+ * Handle queue pull tool call
1818+ */
1919+export function handleQueuePull(
2020+ store: SessionStoreInterface,
2121+ sessionName: string,
2222+ _args: QueuePullInput,
2323+): { content: { type: "text"; text: string }[] } {
2424+ try {
2525+ const plan = store.pullPlan();
2626+ const planPath = store.getSessionPath() + `/plans/${plan.id}.txt`;
2727+2828+ const response = formatResponse(
2929+ sessionName,
3030+ `Active plan: ${plan.id}
3131+Path: ${planPath}
3232+3333+Read the plan file for full context. Review for any ambiguities before starting execution.
3434+3535+If the plan has inputs from other plans, use 9plan_history_search to find their outputs.
3636+If the plan's Notes indicate it was previously decomposed, use 9plan_history_get to retrieve child outcomes.`,
3737+ );
3838+3939+ return {
4040+ content: [{ type: "text", text: response }],
4141+ };
4242+ } catch (error) {
4343+ if (error instanceof NinePlanError) {
4444+ // Special handling for queue empty (not really an error)
4545+ if (error.category === "QUEUE_EMPTY") {
4646+ const completedCount = store.getCompletedCount();
4747+ const response = formatResponse(
4848+ sessionName,
4949+ `Queue is empty. Task complete!
5050+5151+Completed plans: ${String(completedCount)}
5252+5353+Use 9plan_history_search to review what was accomplished.`,
5454+ );
5555+ return {
5656+ content: [{ type: "text", text: response }],
5757+ };
5858+ }
5959+6060+ // Special handling for plan already active
6161+ if (error.category === "PLAN_ALREADY_ACTIVE") {
6262+ const active = store.getActivePlan();
6363+ if (active) {
6464+ const activePath = store.getSessionPath() + `/plans/${active.id}.txt`;
6565+ const response = formatResponse(
6666+ sessionName,
6767+ `Error: Cannot pull - a plan is already active
6868+6969+Active plan: ${active.id}
7070+Path: ${activePath}
7171+7272+Complete, defer, or discard the active plan before pulling another.`,
7373+ );
7474+ return {
7575+ content: [{ type: "text", text: response }],
7676+ };
7777+ }
7878+ }
7979+8080+ return {
8181+ content: [{ type: "text", text: error.format(sessionName) }],
8282+ };
8383+ }
8484+ throw error;
8585+ }
8686+}
8787+8888+// Tool configuration for registration
8989+export const queuePullToolConfig = {
9090+ title: "Pull Plan from Queue",
9191+ description:
9292+ "Removes the front plan from queue and marks it as active. Returns the plan ID and file path. Only one plan can be active at a time.",
9393+ inputSchema: queuePullInputSchema,
9494+};
+55
src/tools/session-create.ts
···11+/**
22+ * 9plan_session_create tool
33+ *
44+ * Creates a new session with a randomly-generated three-word identifier.
55+ */
66+77+import { z } from "zod";
88+import { createNewSession, getSessionPath } from "../container.js";
99+import { formatResponse } from "../types.js";
1010+1111+// Input schema
1212+export const sessionCreateInputSchema = {
1313+ task_description: z
1414+ .string()
1515+ .optional()
1616+ .describe("Optional description of the overall task this session is for"),
1717+};
1818+1919+// Input type derived from schema
2020+export interface SessionCreateInput {
2121+ task_description?: string;
2222+}
2323+2424+/**
2525+ * Handle session create tool call
2626+ */
2727+export function handleSessionCreate(
2828+ args: SessionCreateInput,
2929+): { content: { type: "text"; text: string }[] } {
3030+ const { sessionName, store } = createNewSession(args.task_description);
3131+ const sessionPath = getSessionPath(sessionName);
3232+3333+ // Close the store since we're just creating the session
3434+ store.close();
3535+3636+ const response = formatResponse(
3737+ sessionName,
3838+ `Session created: ${sessionName}
3939+Directory: ${sessionPath}
4040+4141+The session is ready. Use 9plan_queue_add to add plans, then 9plan_queue_pull to begin work.`,
4242+ );
4343+4444+ return {
4545+ content: [{ type: "text", text: response }],
4646+ };
4747+}
4848+4949+// Tool configuration for registration
5050+export const sessionCreateToolConfig = {
5151+ title: "Create Session",
5252+ description:
5353+ "Creates a new session with a randomly-generated three-word identifier (e.g., amber-quiet-river). Optionally provide a task description to document the overall goal.",
5454+ inputSchema: sessionCreateInputSchema,
5555+};
+109
src/tools/session-resume.ts
···11+/**
22+ * 9plan_session_resume tool
33+ *
44+ * Loads an existing session by name and returns its current state.
55+ */
66+77+import { z } from "zod";
88+import { resumeSession } from "../container.js";
99+import { formatResponse, NinePlanError } from "../types.js";
1010+1111+// Input schema
1212+export const sessionResumeInputSchema = {
1313+ session_name: z
1414+ .string()
1515+ .regex(
1616+ /^[a-z]+-[a-z]+-[a-z]+$/,
1717+ "Session name must be in format: word-word-word (e.g., amber-quiet-river)",
1818+ )
1919+ .describe("The three-word session name to resume"),
2020+};
2121+2222+// Input type
2323+export interface SessionResumeInput {
2424+ session_name: string;
2525+}
2626+2727+/**
2828+ * Handle session resume tool call
2929+ */
3030+export function handleSessionResume(
3131+ args: SessionResumeInput,
3232+): { content: { type: "text"; text: string }[] } {
3333+ try {
3434+ const store = resumeSession(args.session_name);
3535+ const state = store.getState();
3636+3737+ // Build response
3838+ let response = `Session resumed: ${state.sessionName}
3939+Directory: ${state.sessionPath}
4040+4141+Task: ${state.taskDescription ?? "(no description)"}
4242+4343+`;
4444+4545+ // Active plan info
4646+ if (state.activePlan) {
4747+ response += `Active plan: ${state.activePlan.id}
4848+ Path: ${state.activePlan.filePath}
4949+ Goal: ${state.activePlan.goal}
5050+5151+`;
5252+ } else {
5353+ response += `Active plan: (none)
5454+5555+`;
5656+ }
5757+5858+ // Queue info
5959+ if (state.queue.length > 0) {
6060+ response += `Queue (${String(state.queue.length)} plans):\n`;
6161+ for (const plan of state.queue) {
6262+ response += ` ${String(plan.queuePosition)}. ${plan.id} - ${plan.goal}\n`;
6363+ }
6464+ response += "\n";
6565+ } else {
6666+ response += `Queue: (empty)\n\n`;
6767+ }
6868+6969+ // Completed count
7070+ response += `Completed: ${String(state.completedCount)} plans\n\n`;
7171+7272+ // Suggestion
7373+ if (state.activePlan) {
7474+ response +=
7575+ "An active plan exists. Read the plan file to continue, or use 9plan_plan_complete/defer/discard to close it out.";
7676+ } else if (state.queue.length > 0) {
7777+ response += "Use 9plan_queue_pull to get the next plan.";
7878+ } else if (state.completedCount > 0) {
7979+ response +=
8080+ "Task complete! Use 9plan_history_search to review completed work.";
8181+ } else {
8282+ response += "Session is empty. Use 9plan_queue_add to add plans.";
8383+ }
8484+8585+ // Close store
8686+ store.close();
8787+8888+ return {
8989+ content: [
9090+ { type: "text", text: formatResponse(state.sessionName, response) },
9191+ ],
9292+ };
9393+ } catch (error) {
9494+ if (error instanceof NinePlanError) {
9595+ return {
9696+ content: [{ type: "text", text: error.format() }],
9797+ };
9898+ }
9999+ throw error;
100100+ }
101101+}
102102+103103+// Tool configuration for registration
104104+export const sessionResumeToolConfig = {
105105+ title: "Resume Session",
106106+ description:
107107+ "Loads an existing session by its three-word name and returns its current state including queue contents, active plan, and completed count.",
108108+ inputSchema: sessionResumeInputSchema,
109109+};