@koan-labs/koan 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/dist/cli/main.d.ts +2 -0
  4. package/dist/cli/main.js +399 -0
  5. package/dist/cli/prompt.d.ts +5 -0
  6. package/dist/cli/prompt.js +48 -0
  7. package/dist/core/answers.d.ts +27 -0
  8. package/dist/core/answers.js +86 -0
  9. package/dist/core/commandLog.d.ts +8 -0
  10. package/dist/core/commandLog.js +51 -0
  11. package/dist/core/commands.d.ts +71 -0
  12. package/dist/core/commands.js +252 -0
  13. package/dist/core/constants.d.ts +32 -0
  14. package/dist/core/constants.js +36 -0
  15. package/dist/core/crystallize.d.ts +15 -0
  16. package/dist/core/crystallize.js +124 -0
  17. package/dist/core/documents.d.ts +11 -0
  18. package/dist/core/documents.js +72 -0
  19. package/dist/core/gitPolicy.d.ts +2 -0
  20. package/dist/core/gitPolicy.js +25 -0
  21. package/dist/core/handoff.d.ts +5 -0
  22. package/dist/core/handoff.js +20 -0
  23. package/dist/core/hostAdapter.d.ts +8 -0
  24. package/dist/core/hostAdapter.js +34 -0
  25. package/dist/core/lock.d.ts +5 -0
  26. package/dist/core/lock.js +142 -0
  27. package/dist/core/mcpCache.d.ts +4 -0
  28. package/dist/core/mcpCache.js +37 -0
  29. package/dist/core/prd.d.ts +33 -0
  30. package/dist/core/prd.js +151 -0
  31. package/dist/core/profile.d.ts +8 -0
  32. package/dist/core/profile.js +47 -0
  33. package/dist/core/profileRef.d.ts +3 -0
  34. package/dist/core/profileRef.js +41 -0
  35. package/dist/core/project.d.ts +17 -0
  36. package/dist/core/project.js +126 -0
  37. package/dist/core/qa.d.ts +6 -0
  38. package/dist/core/qa.js +26 -0
  39. package/dist/core/questions.d.ts +10 -0
  40. package/dist/core/questions.js +272 -0
  41. package/dist/core/reconstruct.d.ts +7 -0
  42. package/dist/core/reconstruct.js +62 -0
  43. package/dist/core/schemas.d.ts +331 -0
  44. package/dist/core/schemas.js +132 -0
  45. package/dist/core/scoring.d.ts +9 -0
  46. package/dist/core/scoring.js +72 -0
  47. package/dist/core/session.d.ts +6 -0
  48. package/dist/core/session.js +88 -0
  49. package/dist/index.d.ts +18 -0
  50. package/dist/index.js +18 -0
  51. package/dist/mcp/server.d.ts +5 -0
  52. package/dist/mcp/server.js +539 -0
  53. package/package.json +55 -0
@@ -0,0 +1,539 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
+ import { z } from "zod";
10
+ import { recordAnswer } from "../core/answers.js";
11
+ import { brightIdea, handoff, hello, qa, recordInsight, status, updateStatus } from "../core/commands.js";
12
+ import { CORE_DOCUMENTS, KOAN_VERSION, LAZY_DOCUMENTS, STATE_FILES } from "../core/constants.js";
13
+ import { crystallize } from "../core/crystallize.js";
14
+ import { defaultKoanGitignore } from "../core/gitPolicy.js";
15
+ import { adapterFor, detectHost } from "../core/hostAdapter.js";
16
+ import { buildPrd } from "../core/prd.js";
17
+ import { loadMcpCache, updateMcpCache } from "../core/mcpCache.js";
18
+ import { defaultProfile, loadProfile, updateProfile } from "../core/profile.js";
19
+ import { loadProfileRef } from "../core/profileRef.js";
20
+ import { inspectProject, loadProjectConfig } from "../core/project.js";
21
+ import { getQuestion } from "../core/questions.js";
22
+ import { AmbiguityAxisSchema, DEFAULT_CONVERGENCE_THRESHOLD, DevelopmentUnderstandingSchema, ExplanationStyleSchema, LanguageSchema, LearningModeSchema, OutputUseSchema, UserProfileSchema } from "../core/schemas.js";
23
+ import { createInitialLedger, isConverged, loadLedger, selectMostUnclearAxis } from "../core/scoring.js";
24
+ import { loadSessionState } from "../core/session.js";
25
+ export const toolNames = [
26
+ "koan_get_profile",
27
+ "koan_update_profile",
28
+ "koan_inspect_project",
29
+ "koan_start_session",
30
+ "koan_get_next_question",
31
+ "koan_record_answer",
32
+ "koan_crystallize_documents",
33
+ "koan_get_status",
34
+ "koan_update_status",
35
+ "koan_record_bright_idea",
36
+ "koan_record_insight",
37
+ "koan_synthesize_prd",
38
+ "koan_prepare_qa",
39
+ "koan_prepare_handoff"
40
+ ];
41
+ const BrightIdeaClassificationSchema = z.enum(["clarify", "change-goal", "later-follow-up", "reject"]);
42
+ const profileFieldProperties = {
43
+ developmentUnderstanding: { type: "string", enum: [...DevelopmentUnderstandingSchema.options] },
44
+ explanationStyle: { type: "string", enum: [...ExplanationStyleSchema.options] },
45
+ language: { type: "string", enum: [...LanguageSchema.options] },
46
+ outputUse: { type: "string", enum: [...OutputUseSchema.options] },
47
+ domainBackground: { type: "string" },
48
+ learningMode: { type: "string", enum: [...LearningModeSchema.options] }
49
+ };
50
+ // The host adapter only varies instruction phrasing; reapplying it to a
51
+ // question computed by host-agnostic core code keeps the core API unchanged.
52
+ function withHostInstruction(question, host) {
53
+ if (!question)
54
+ return question;
55
+ return { ...question, hostAgentInstruction: adapterFor(host).questionInstruction };
56
+ }
57
+ function textContent(value) {
58
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
59
+ }
60
+ const tools = {
61
+ koan_get_profile: {
62
+ description: "Load the global Koan user profile (defaults when unset), its learning mode, and project overrides.",
63
+ inputSchema: {
64
+ type: "object",
65
+ properties: { homeDir: { type: "string" }, projectRoot: { type: "string" } },
66
+ required: ["homeDir"]
67
+ },
68
+ handler: async (args) => {
69
+ const parsed = z.object({ homeDir: z.string(), projectRoot: z.string().optional() }).parse(args);
70
+ const profile = (await loadProfile(parsed.homeDir)) ?? defaultProfile();
71
+ const overrides = parsed.projectRoot
72
+ ? ((await loadProfileRef(parsed.projectRoot))?.overrides ?? null)
73
+ : null;
74
+ return { profile, learningMode: profile.learningMode, overrides };
75
+ }
76
+ },
77
+ koan_update_profile: {
78
+ description: "Apply approved partial changes to the global user profile and report which fields changed.",
79
+ inputSchema: {
80
+ type: "object",
81
+ properties: {
82
+ homeDir: { type: "string" },
83
+ profile: { type: "object", properties: profileFieldProperties }
84
+ },
85
+ required: ["homeDir", "profile"]
86
+ },
87
+ handler: async (args) => {
88
+ const parsed = z.object({ homeDir: z.string(), profile: UserProfileSchema.partial() }).parse(args);
89
+ const before = (await loadProfile(parsed.homeDir)) ?? defaultProfile();
90
+ const updated = await updateProfile(parsed.homeDir, parsed.profile);
91
+ const changedFields = Object.keys(updated).filter((field) => before[field] !== updated[field]);
92
+ return { ...updated, changedFields };
93
+ }
94
+ },
95
+ koan_inspect_project: {
96
+ description: "Inspect a directory for Koan project state, bootstrap markers, document paths, and git policy.",
97
+ inputSchema: {
98
+ type: "object",
99
+ properties: { projectRoot: { type: "string" } },
100
+ required: ["projectRoot"]
101
+ },
102
+ handler: async (args) => {
103
+ const parsed = z.object({ projectRoot: z.string() }).parse(args);
104
+ const inspection = await inspectProject(parsed.projectRoot);
105
+ const gitignore = await readFile(join(inspection.projectRoot, STATE_FILES.gitignore), "utf8").catch(() => null);
106
+ return {
107
+ ...inspection,
108
+ documents: CORE_DOCUMENTS,
109
+ gitPolicy: { path: STATE_FILES.gitignore, matchesDefault: gitignore === defaultKoanGitignore() }
110
+ };
111
+ }
112
+ },
113
+ koan_start_session: {
114
+ description: "Initialize or resume a Koan session and return its session id, ledger, next action, and next question. The resume input is informational: hello always resumes an existing session.",
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ projectRoot: { type: "string" },
119
+ homeDir: { type: "string" },
120
+ rawIntent: { type: "string" },
121
+ resume: { type: "boolean" }
122
+ },
123
+ required: ["projectRoot", "homeDir"]
124
+ },
125
+ handler: async (args, context) => {
126
+ const parsed = z
127
+ .object({
128
+ projectRoot: z.string(),
129
+ homeDir: z.string(),
130
+ rawIntent: z.string().optional(),
131
+ resume: z.boolean().optional()
132
+ })
133
+ .parse(args);
134
+ const result = await hello({ cwd: parsed.projectRoot, homeDir: parsed.homeDir });
135
+ const state = await loadSessionState(result.projectRoot);
136
+ const { nextAction } = await status({ cwd: result.projectRoot });
137
+ const ledger = await loadLedger(result.projectRoot);
138
+ const sessionId = state?.sessionId ?? null;
139
+ const rawIntent = parsed.rawIntent ?? "";
140
+ const rawIntentCaptured = rawIntent.length > 0;
141
+ // Drop any cached question that belongs to a different session so a
142
+ // later no-axis record_answer cannot target a stale goal's axis.
143
+ const cache = await updateMcpCache(result.projectRoot, (current) => ({
144
+ ...current,
145
+ rawIntent: rawIntentCaptured ? rawIntent : current.rawIntent,
146
+ lastQuestion: current.lastQuestion?.sessionId === sessionId ? current.lastQuestion : null
147
+ }));
148
+ return {
149
+ sessionId,
150
+ activeGoalId: result.activeGoalId,
151
+ nextAction,
152
+ ledger,
153
+ resumed: result.resumed,
154
+ reconstructed: result.reconstructed,
155
+ converged: result.converged,
156
+ nextQuestion: withHostInstruction(result.nextQuestion, context.host),
157
+ resumeRequested: parsed.resume ?? false,
158
+ rawIntent: cache.rawIntent,
159
+ rawIntentCaptured
160
+ };
161
+ }
162
+ },
163
+ koan_get_next_question: {
164
+ description: "Select the most unclear ambiguity axis and return its profile-adapted question for the host agent.",
165
+ inputSchema: {
166
+ type: "object",
167
+ properties: { projectRoot: { type: "string" }, homeDir: { type: "string" } },
168
+ required: ["projectRoot", "homeDir"]
169
+ },
170
+ handler: async (args, context) => {
171
+ const parsed = z.object({ projectRoot: z.string(), homeDir: z.string() }).parse(args);
172
+ const state = await loadSessionState(parsed.projectRoot);
173
+ if (!state)
174
+ throw new Error("No active Koan session. Run koan hello first.");
175
+ if (!state.activeGoalId || state.phase === "archived") {
176
+ throw new Error("No active goal. Run koan hello first.");
177
+ }
178
+ const profile = (await loadProfile(parsed.homeDir)) ?? defaultProfile();
179
+ const stored = await loadLedger(parsed.projectRoot);
180
+ const ledger = stored && stored.goalId === state.activeGoalId ? stored : createInitialLedger(state.activeGoalId);
181
+ const threshold = (await loadProjectConfig(parsed.projectRoot))?.settings.convergenceThreshold ??
182
+ DEFAULT_CONVERGENCE_THRESHOLD;
183
+ if (state.phase === "ready" || isConverged(ledger, threshold)) {
184
+ return { converged: true, question: null };
185
+ }
186
+ const axis = selectMostUnclearAxis(ledger);
187
+ const question = getQuestion(axis, profile, context.host);
188
+ await updateMcpCache(parsed.projectRoot, (current) => ({
189
+ ...current,
190
+ lastQuestion: { sessionId: state.sessionId, axis, questionId: axis, askedAt: new Date().toISOString() }
191
+ }));
192
+ return {
193
+ converged: false,
194
+ questionId: axis,
195
+ axis,
196
+ intent: question.intent,
197
+ userFacingQuestion: question.userFacingQuestion,
198
+ answerSchema: question.answerSchema,
199
+ hostAgentInstruction: question.hostAgentInstruction
200
+ };
201
+ }
202
+ },
203
+ koan_record_answer: {
204
+ description: "Record an answer for an ambiguity axis, update the ledger, and preview the crystallize write plan.",
205
+ inputSchema: {
206
+ type: "object",
207
+ properties: {
208
+ projectRoot: { type: "string" },
209
+ homeDir: { type: "string" },
210
+ answerText: { type: "string" },
211
+ axis: { type: "string", enum: [...AmbiguityAxisSchema.options] },
212
+ questionId: { type: "string" },
213
+ source: { type: "string" },
214
+ interpretation: {
215
+ type: "object",
216
+ properties: { clarity: { type: "number", minimum: 0, maximum: 1 } }
217
+ }
218
+ },
219
+ required: ["projectRoot", "homeDir", "answerText"]
220
+ },
221
+ handler: async (args, context) => {
222
+ const parsed = z
223
+ .object({
224
+ projectRoot: z.string(),
225
+ homeDir: z.string(),
226
+ answerText: z.string(),
227
+ axis: AmbiguityAxisSchema.optional(),
228
+ questionId: z.string().optional(),
229
+ source: z.string().optional(),
230
+ interpretation: z.object({ clarity: z.number().min(0).max(1).optional() }).optional()
231
+ })
232
+ .parse(args);
233
+ const state = await loadSessionState(parsed.projectRoot);
234
+ let candidate = parsed.axis ?? parsed.questionId;
235
+ if (candidate === undefined && state) {
236
+ // Only trust the cached question when it was asked in this session;
237
+ // a cache left over from an earlier goal must not absorb answers.
238
+ const cached = (await loadMcpCache(parsed.projectRoot)).lastQuestion;
239
+ if (cached && cached.sessionId === state.sessionId)
240
+ candidate = cached.axis;
241
+ }
242
+ if (candidate === undefined)
243
+ throw new Error("No axis given and no cached question context.");
244
+ const resolvedAxis = AmbiguityAxisSchema.safeParse(candidate);
245
+ if (!resolvedAxis.success)
246
+ throw new Error(`Unknown axis: ${candidate}`);
247
+ const result = await recordAnswer({
248
+ cwd: parsed.projectRoot,
249
+ homeDir: parsed.homeDir,
250
+ axis: resolvedAxis.data,
251
+ answer: parsed.answerText,
252
+ clarity: parsed.interpretation?.clarity,
253
+ source: parsed.source
254
+ });
255
+ if (state) {
256
+ // Advance the cached question so consecutive no-axis answers walk the
257
+ // axes instead of re-answering the axis from get_next_question.
258
+ const sessionId = state.sessionId;
259
+ await updateMcpCache(parsed.projectRoot, (current) => ({
260
+ ...current,
261
+ lastQuestion: result.nextQuestion
262
+ ? {
263
+ sessionId,
264
+ axis: result.nextQuestion.axis,
265
+ questionId: result.nextQuestion.axis,
266
+ askedAt: new Date().toISOString()
267
+ }
268
+ : null
269
+ }));
270
+ }
271
+ const preview = await crystallize({ cwd: parsed.projectRoot, homeDir: parsed.homeDir, dryRun: true });
272
+ return {
273
+ ledger: result.ledger,
274
+ answer: result.answer,
275
+ converged: result.converged,
276
+ unresolved: result.unresolved,
277
+ nextQuestion: withHostInstruction(result.nextQuestion, context.host),
278
+ preview: {
279
+ description: preview.plan.description,
280
+ files: preview.files,
281
+ operations: preview.plan.operations.length
282
+ }
283
+ };
284
+ }
285
+ },
286
+ koan_crystallize_documents: {
287
+ description: "Crystallize recorded answers into koan/*.md managed regions; dryRun returns the write plan only.",
288
+ inputSchema: {
289
+ type: "object",
290
+ properties: {
291
+ projectRoot: { type: "string" },
292
+ homeDir: { type: "string" },
293
+ dryRun: { type: "boolean" }
294
+ },
295
+ required: ["projectRoot", "homeDir"]
296
+ },
297
+ handler: async (args) => {
298
+ const parsed = z
299
+ .object({ projectRoot: z.string(), homeDir: z.string(), dryRun: z.boolean().optional() })
300
+ .parse(args);
301
+ const result = await crystallize({ cwd: parsed.projectRoot, homeDir: parsed.homeDir, dryRun: parsed.dryRun });
302
+ return {
303
+ plan: result.plan,
304
+ executed: result.executed,
305
+ files: result.files,
306
+ crystallizedAxes: result.crystallizedAxes
307
+ };
308
+ }
309
+ },
310
+ koan_get_status: {
311
+ description: "Read the status summary with stale-state warnings, the next recommended action, and the captured raw intent.",
312
+ inputSchema: {
313
+ type: "object",
314
+ properties: { projectRoot: { type: "string" } },
315
+ required: ["projectRoot"]
316
+ },
317
+ handler: async (args) => {
318
+ const parsed = z.object({ projectRoot: z.string() }).parse(args);
319
+ const result = await status({ cwd: parsed.projectRoot });
320
+ const cache = await loadMcpCache(parsed.projectRoot);
321
+ return { ...result, rawIntent: cache.rawIntent };
322
+ }
323
+ },
324
+ koan_update_status: {
325
+ description: "Write a status update into the status.md managed region and mirror it into handoff.md.",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ projectRoot: { type: "string" },
330
+ statusText: { type: "string" },
331
+ source: { type: "string" }
332
+ },
333
+ required: ["projectRoot", "statusText"]
334
+ },
335
+ handler: async (args) => {
336
+ const parsed = z
337
+ .object({ projectRoot: z.string(), statusText: z.string(), source: z.string().optional() })
338
+ .parse(args);
339
+ const result = await updateStatus({
340
+ cwd: parsed.projectRoot,
341
+ update: parsed.statusText,
342
+ source: parsed.source
343
+ });
344
+ return {
345
+ updated: true,
346
+ projectRoot: result.projectRoot,
347
+ files: [CORE_DOCUMENTS.status, LAZY_DOCUMENTS.handoff]
348
+ };
349
+ }
350
+ },
351
+ koan_record_bright_idea: {
352
+ description: "Append a mid-implementation idea to bright-ideas.md and return the classification recommendation.",
353
+ inputSchema: {
354
+ type: "object",
355
+ properties: {
356
+ projectRoot: { type: "string" },
357
+ text: { type: "string" },
358
+ classification: { type: "string", enum: [...BrightIdeaClassificationSchema.options] }
359
+ },
360
+ required: ["projectRoot", "text"]
361
+ },
362
+ handler: async (args) => {
363
+ const parsed = z
364
+ .object({
365
+ projectRoot: z.string(),
366
+ text: z.string(),
367
+ classification: BrightIdeaClassificationSchema.optional()
368
+ })
369
+ .parse(args);
370
+ const result = await brightIdea({
371
+ cwd: parsed.projectRoot,
372
+ idea: parsed.text,
373
+ classification: parsed.classification
374
+ });
375
+ return { recorded: true, classification: result.classification, recommendation: result.recommendation };
376
+ }
377
+ },
378
+ koan_record_insight: {
379
+ description: "Append a product realization — the user discovering that the real product differs from the surface request — to koan/philosophy.md. Append-only: insights chronicle how the product's why sharpened over time.",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ projectRoot: { type: "string" },
384
+ text: { type: "string" }
385
+ },
386
+ required: ["projectRoot", "text"]
387
+ },
388
+ handler: async (args) => {
389
+ const parsed = z.object({ projectRoot: z.string(), text: z.string() }).parse(args);
390
+ const result = await recordInsight({ cwd: parsed.projectRoot, text: parsed.text });
391
+ return { recorded: true, path: result.path };
392
+ }
393
+ },
394
+ koan_synthesize_prd: {
395
+ description: "Synthesize koan/prd.md: deterministic sections are assembled from recorded answers; the host may provide vision, coreValue, problemAntiProblem, and userStories synthesized strictly from the recorded answers and koan/philosophy.md — never invented requirements.",
396
+ inputSchema: {
397
+ type: "object",
398
+ properties: {
399
+ projectRoot: { type: "string" },
400
+ homeDir: { type: "string" },
401
+ sections: {
402
+ type: "object",
403
+ properties: {
404
+ vision: { type: "string" },
405
+ coreValue: { type: "string" },
406
+ problemAntiProblem: { type: "string" },
407
+ userStories: { type: "string" }
408
+ }
409
+ },
410
+ dryRun: { type: "boolean" }
411
+ },
412
+ required: ["projectRoot", "homeDir"]
413
+ },
414
+ handler: async (args, context) => {
415
+ const parsed = z
416
+ .object({
417
+ projectRoot: z.string(),
418
+ homeDir: z.string(),
419
+ sections: z
420
+ .object({
421
+ vision: z.string().optional(),
422
+ coreValue: z.string().optional(),
423
+ problemAntiProblem: z.string().optional(),
424
+ userStories: z.string().optional()
425
+ })
426
+ .optional(),
427
+ dryRun: z.boolean().optional()
428
+ })
429
+ .parse(args);
430
+ const result = await buildPrd({
431
+ cwd: parsed.projectRoot,
432
+ homeDir: parsed.homeDir,
433
+ sections: parsed.sections,
434
+ host: context.host,
435
+ dryRun: parsed.dryRun
436
+ });
437
+ return {
438
+ prepared: result.executed,
439
+ path: result.path,
440
+ operations: result.plan.operations.length,
441
+ document: result.document
442
+ };
443
+ }
444
+ },
445
+ koan_prepare_qa: {
446
+ description: "Generate the QA checklist at koan/qa.md from the goal and plan documents, embedding an optional host-provided implementation summary.",
447
+ inputSchema: {
448
+ type: "object",
449
+ properties: {
450
+ projectRoot: { type: "string" },
451
+ implementationSummary: { type: "string" }
452
+ },
453
+ required: ["projectRoot"]
454
+ },
455
+ handler: async (args, context) => {
456
+ const parsed = z
457
+ .object({ projectRoot: z.string(), implementationSummary: z.string().optional() })
458
+ .parse(args);
459
+ const result = await qa({
460
+ cwd: parsed.projectRoot,
461
+ implementationSummary: parsed.implementationSummary,
462
+ host: context.host
463
+ });
464
+ return { prepared: true, path: LAZY_DOCUMENTS.qa, checklist: result.checklist };
465
+ }
466
+ },
467
+ koan_prepare_handoff: {
468
+ description: "Write the document-based handoff at koan/handoff.md from the optional session summary and return the document with the next action.",
469
+ inputSchema: {
470
+ type: "object",
471
+ properties: { projectRoot: { type: "string" }, text: { type: "string" } },
472
+ required: ["projectRoot"]
473
+ },
474
+ handler: async (args) => {
475
+ const parsed = z.object({ projectRoot: z.string(), text: z.string().optional() }).parse(args);
476
+ const result = await handoff({
477
+ cwd: parsed.projectRoot,
478
+ summary: parsed.text ?? "Handoff prepared via MCP."
479
+ });
480
+ const { nextAction } = await status({ cwd: parsed.projectRoot });
481
+ return {
482
+ prepared: true,
483
+ path: LAZY_DOCUMENTS.handoff,
484
+ handoff: result.document,
485
+ nextAction,
486
+ experimental: { enabled: false, adapter: null }
487
+ };
488
+ }
489
+ }
490
+ };
491
+ function isToolName(name) {
492
+ return toolNames.includes(name);
493
+ }
494
+ export function createServer() {
495
+ const server = new Server({ name: "koan", version: KOAN_VERSION }, { capabilities: { tools: {} } });
496
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
497
+ tools: toolNames.map((name) => ({
498
+ name,
499
+ description: tools[name].description,
500
+ inputSchema: tools[name].inputSchema
501
+ }))
502
+ }));
503
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
504
+ const { name } = request.params;
505
+ if (!isToolName(name))
506
+ throw new Error(`Unknown tool: ${name}`);
507
+ // clientInfo comes from the MCP initialize handshake — a local, deterministic
508
+ // signal; unknown or absent clients fall back to the generic adapter.
509
+ const context = { host: detectHost(server.getClientVersion()?.name) };
510
+ return textContent(await tools[name].handler(request.params.arguments ?? {}, context));
511
+ });
512
+ return server;
513
+ }
514
+ export async function runServer() {
515
+ const server = createServer();
516
+ const transport = new StdioServerTransport();
517
+ await server.connect(transport);
518
+ }
519
+ // npm bin shims invoke this module through a symlink named koan-mcp, so the
520
+ // entry path is realpath-resolved before comparing against this module's file;
521
+ // the suffix checks remain as a fallback for environments without realpath.
522
+ function isDirectInvocation(entry) {
523
+ if (!entry)
524
+ return false;
525
+ try {
526
+ if (realpathSync(entry) === fileURLToPath(import.meta.url))
527
+ return true;
528
+ }
529
+ catch {
530
+ // fall through to the suffix checks
531
+ }
532
+ return entry.endsWith("server.js") || entry.endsWith("server.ts");
533
+ }
534
+ if (isDirectInvocation(process.argv[1])) {
535
+ runServer().catch((error) => {
536
+ console.error(error instanceof Error ? error.message : String(error));
537
+ process.exitCode = 1;
538
+ });
539
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@koan-labs/koan",
3
+ "version": "0.2.0",
4
+ "description": "Local-first philosophical PRD tool that crystallizes vague intent into product requirements, QA criteria, and AI-agent-ready handoff documents.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/project820/koan.git"
9
+ },
10
+ "homepage": "https://github.com/project820/koan#readme",
11
+ "bugs": "https://github.com/project820/koan/issues",
12
+ "keywords": [
13
+ "prd",
14
+ "requirements",
15
+ "product-management",
16
+ "mcp",
17
+ "mcp-server",
18
+ "ai-agents",
19
+ "local-first",
20
+ "cli",
21
+ "intent",
22
+ "philosophy"
23
+ ],
24
+ "type": "module",
25
+ "bin": {
26
+ "koan": "./dist/cli/main.js",
27
+ "koan-mcp": "./dist/mcp/server.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "dev": "tsx src/cli/main.ts",
37
+ "mcp": "tsx src/mcp/server.ts",
38
+ "test": "vitest run",
39
+ "typecheck": "tsc -p tsconfig.json --noEmit",
40
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "1.29.0",
47
+ "zod": "4.4.3"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.19.43",
51
+ "tsx": "4.22.4",
52
+ "typescript": "5.9.3",
53
+ "vitest": "4.1.8"
54
+ }
55
+ }