@koan-labs/koan 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Koan
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/%40koan-labs%2Fkoan)](https://www.npmjs.com/package/@koan-labs/koan)
4
+ [![CI](https://github.com/project820/koan/actions/workflows/ci.yml/badge.svg)](https://github.com/project820/koan/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+
3
7
  Koan is a local-first philosophical PRD tool. It helps you clarify why a
4
8
  product should exist — before asking humans or AI agents to build it — and
5
9
  crystallizes that intent into readable project documents: philosophy, goals,
@@ -35,6 +39,9 @@ or API key is required.
35
39
  - `koan hello --reset-profile [--yes]` — delete the global profile (`--yes`
36
40
  skips confirmation).
37
41
  - `koan status` — show goal, status, and next action without writing.
42
+ - `koan dashboard [--once]` — live read-only view of per-axis clarity, the
43
+ next question, insights, and warnings; refreshes as project files change
44
+ (`q` to quit, `--once` or a pipe prints a single frame).
38
45
  - `koan status --update <text>` — record a status update.
39
46
  - `koan status --archive` — archive the active goal to `koan/archive/<goal-id>/`.
40
47
  - `koan answer <axis> <text>` — record an answer for one ambiguity axis.
@@ -71,6 +78,7 @@ From a checkout, `npm run mcp` starts the same server.
71
78
  | `koan_synthesize_prd` | Synthesize `koan/prd.md`; hosts may supply vision, core value, problem/anti-problem, and user stories grounded in recorded answers. |
72
79
  | `koan_prepare_qa` | Generate `koan/qa.md` with spec-compliance and quality checks; embeds an optional implementation summary and returns the checklist. |
73
80
  | `koan_prepare_handoff` | Generate `koan/handoff.md` (summary text optional); returns the document and next action; touchless handoff stays disabled. |
81
+ | `koan_get_dashboard` | Read-only snapshot of session phase, per-axis clarity, next question, insights, and warnings — the same data behind `koan dashboard`. |
74
82
 
75
83
  All tool results are JSON in the first text content block; inputs are
76
84
  zod-validated and failures surface as MCP errors.
@@ -0,0 +1,15 @@
1
+ import { type DashboardSnapshot } from "../core/dashboard.js";
2
+ export declare function displayWidth(text: string): number;
3
+ export declare function truncateToWidth(text: string, maxWidth: number): string;
4
+ export interface RenderOptions {
5
+ width: number;
6
+ color: boolean;
7
+ live: boolean;
8
+ }
9
+ export declare function renderDashboard(snapshot: DashboardSnapshot, options: RenderOptions): string;
10
+ export interface RunDashboardInput {
11
+ cwd: string;
12
+ homeDir: string;
13
+ once?: boolean;
14
+ }
15
+ export declare function runDashboard(input: RunDashboardInput): Promise<number>;
@@ -0,0 +1,183 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { KOAN_DIR, KOAN_STATE_DIR } from "../core/constants.js";
4
+ import { collectDashboardSnapshot } from "../core/dashboard.js";
5
+ import { findProjectRoot } from "../core/project.js";
6
+ const RESET = "\x1b[0m";
7
+ const BOLD = "\x1b[1m";
8
+ const DIM = "\x1b[2m";
9
+ const GREEN = "\x1b[32m";
10
+ const YELLOW = "\x1b[33m";
11
+ const BAR_CELLS = 10;
12
+ const LABELS = {
13
+ en: {
14
+ next: "Next",
15
+ goal: "Goal",
16
+ status: "Status",
17
+ insights: "Insights",
18
+ threshold: "threshold",
19
+ unresolved: "unresolved",
20
+ noSession: "no session — run koan hello",
21
+ converged: "converged — ready to crystallize and archive",
22
+ quit: "q quit",
23
+ live: "live",
24
+ last: "last"
25
+ },
26
+ ko: {
27
+ next: "다음 질문",
28
+ goal: "목표",
29
+ status: "상태",
30
+ insights: "인사이트",
31
+ threshold: "임계",
32
+ unresolved: "축 미해결",
33
+ noSession: "세션 없음 — koan hello를 실행하세요",
34
+ converged: "수렴 완료 — crystallize 후 archive 가능",
35
+ quit: "q 종료",
36
+ live: "실시간",
37
+ last: "최근"
38
+ }
39
+ };
40
+ // CJK codepoints occupy two terminal columns; everything else is treated as
41
+ // one. Close enough for truncation — exact wcwidth is not worth a dependency.
42
+ const WIDE_CHAR = new RegExp("[\\u1100-\\u115F\\u2E80-\\uA4CF\\uAC00-\\uD7A3\\uF900-\\uFAFF\\uFE30-\\uFE4F\\uFF00-\\uFF60\\uFFE0-\\uFFE6]");
43
+ function charWidth(char) {
44
+ return WIDE_CHAR.test(char) ? 2 : 1;
45
+ }
46
+ export function displayWidth(text) {
47
+ let width = 0;
48
+ for (const char of text)
49
+ width += charWidth(char);
50
+ return width;
51
+ }
52
+ export function truncateToWidth(text, maxWidth) {
53
+ if (displayWidth(text) <= maxWidth)
54
+ return text;
55
+ let width = 0;
56
+ let out = "";
57
+ for (const char of text) {
58
+ const next = width + charWidth(char);
59
+ if (next > maxWidth - 1)
60
+ break;
61
+ out += char;
62
+ width = next;
63
+ }
64
+ return `${out}…`;
65
+ }
66
+ export function renderDashboard(snapshot, options) {
67
+ const width = Math.max(40, options.width);
68
+ const paint = (text, code) => (options.color ? `${code}${text}${RESET}` : text);
69
+ const labels = LABELS[snapshot.profileLanguage === "ko" ? "ko" : "en"];
70
+ const lines = [];
71
+ const title = `Koan — ${basename(snapshot.projectRoot)}`;
72
+ const session = snapshot.goalId === null ? labels.noSession : `goal ${snapshot.goalId} · ${snapshot.phase}`;
73
+ const gap = width - displayWidth(title) - displayWidth(session);
74
+ lines.push(gap >= 2
75
+ ? `${paint(title, BOLD)}${" ".repeat(gap)}${paint(session, DIM)}`
76
+ : truncateToWidth(`${title} · ${session}`, width));
77
+ lines.push("─".repeat(width));
78
+ const axisColumn = Math.max(...snapshot.axes.map((entry) => entry.axis.length));
79
+ for (const entry of snapshot.axes) {
80
+ const filled = Math.round(entry.clarity * BAR_CELLS);
81
+ const bar = "█".repeat(filled) + "░".repeat(BAR_CELLS - filled);
82
+ const barColor = entry.clarity >= snapshot.threshold ? GREEN : entry.clarity > 0 ? YELLOW : DIM;
83
+ const marker = snapshot.nextQuestion?.axis === entry.axis ? " ← next" : "";
84
+ lines.push(`${entry.axis.padEnd(axisColumn)} ${paint(bar, barColor)} ${entry.clarity.toFixed(1)}${paint(marker, DIM)}`);
85
+ }
86
+ lines.push(paint(snapshot.converged
87
+ ? labels.converged
88
+ : `${labels.threshold} ${snapshot.threshold} · ${snapshot.unresolvedCount} ${labels.unresolved}`, DIM));
89
+ lines.push("─".repeat(width));
90
+ if (snapshot.nextQuestion) {
91
+ lines.push(truncateToWidth(`${labels.next}: ${snapshot.nextQuestion.userFacingQuestion}`, width));
92
+ }
93
+ if (snapshot.activeGoal) {
94
+ lines.push(truncateToWidth(`${labels.goal}: ${snapshot.activeGoal.split("\n")[0]}`, width));
95
+ }
96
+ if (snapshot.latestStatus) {
97
+ lines.push(truncateToWidth(`${labels.status}: ${snapshot.latestStatus.split("\n")[0]}`, width));
98
+ }
99
+ if (snapshot.insights.length > 0) {
100
+ const recent = snapshot.insights.slice(-2).join(" · ");
101
+ lines.push(truncateToWidth(`${labels.insights}(${snapshot.insights.length}): ${recent}`, width));
102
+ }
103
+ for (const warning of snapshot.staleWarnings) {
104
+ lines.push(paint(truncateToWidth(`⚠ ${warning}`, width), YELLOW));
105
+ }
106
+ lines.push(truncateToWidth(`→ ${snapshot.nextAction}`, width));
107
+ const footerParts = [];
108
+ if (options.live)
109
+ footerParts.push(labels.quit, labels.live);
110
+ if (snapshot.lastCommand) {
111
+ footerParts.push(`${labels.last}: ${snapshot.lastCommand.command} (${snapshot.lastCommand.at})`);
112
+ }
113
+ if (footerParts.length > 0) {
114
+ lines.push(paint(truncateToWidth(footerParts.join(" · "), width), DIM));
115
+ }
116
+ return lines.join("\n");
117
+ }
118
+ export async function runDashboard(input) {
119
+ const live = input.once !== true && process.stdout.isTTY === true && process.stdin.isTTY === true;
120
+ const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
121
+ const render = async () => {
122
+ const snapshot = await collectDashboardSnapshot({ cwd: input.cwd, homeDir: input.homeDir });
123
+ return renderDashboard(snapshot, { width: process.stdout.columns ?? 80, color, live });
124
+ };
125
+ if (!live) {
126
+ console.log(await render());
127
+ return 0;
128
+ }
129
+ const projectRoot = await findProjectRoot(input.cwd);
130
+ const watchers = [];
131
+ let redrawTimer = null;
132
+ let pollTimer = null;
133
+ let finished = false;
134
+ const draw = async () => {
135
+ const frame = await render().catch((error) => error instanceof Error ? error.message : String(error));
136
+ if (!finished)
137
+ process.stdout.write(`\x1b[H\x1b[2J${frame}\n`);
138
+ };
139
+ const scheduleDraw = () => {
140
+ if (redrawTimer)
141
+ clearTimeout(redrawTimer);
142
+ redrawTimer = setTimeout(() => void draw(), 150);
143
+ };
144
+ return new Promise((resolve) => {
145
+ const cleanup = () => {
146
+ if (finished)
147
+ return;
148
+ finished = true;
149
+ if (redrawTimer)
150
+ clearTimeout(redrawTimer);
151
+ if (pollTimer)
152
+ clearInterval(pollTimer);
153
+ for (const watcher of watchers)
154
+ watcher.close();
155
+ process.stdin.setRawMode?.(false);
156
+ process.stdin.pause();
157
+ process.stdout.removeListener("resize", scheduleDraw);
158
+ process.stdout.write("\x1b[?1049l\x1b[?25h");
159
+ resolve(0);
160
+ };
161
+ process.stdout.write("\x1b[?1049h\x1b[?25l");
162
+ for (const dir of [join(projectRoot, KOAN_STATE_DIR), join(projectRoot, KOAN_DIR)]) {
163
+ try {
164
+ watchers.push(watch(dir, scheduleDraw));
165
+ }
166
+ catch {
167
+ // Directory may not exist yet (bare project); the poll timer covers it.
168
+ }
169
+ }
170
+ pollTimer = setInterval(() => void draw(), 5000);
171
+ process.stdout.on("resize", scheduleDraw);
172
+ process.stdin.setRawMode?.(true);
173
+ process.stdin.resume();
174
+ process.stdin.on("data", (chunk) => {
175
+ const key = chunk.toString("utf8");
176
+ if (key === "q" || key === "Q" || key === "\x03")
177
+ cleanup();
178
+ });
179
+ process.once("SIGINT", cleanup);
180
+ process.once("SIGTERM", cleanup);
181
+ void draw();
182
+ });
183
+ }
package/dist/cli/main.js CHANGED
@@ -4,6 +4,7 @@ import { acceptClarity, recordAnswer } from "../core/answers.js";
4
4
  import { archive, brightIdea, handoff, hello, qa, recordInsight, status, updateStatus } from "../core/commands.js";
5
5
  import { crystallize } from "../core/crystallize.js";
6
6
  import { buildPrd } from "../core/prd.js";
7
+ import { runDashboard } from "./dashboard.js";
7
8
  import { defaultProfile, loadProfile, resetProfile, saveProfile } from "../core/profile.js";
8
9
  import { getQuestion } from "../core/questions.js";
9
10
  import { ANSWERED_CLARITY } from "../core/scoring.js";
@@ -30,6 +31,7 @@ const COMMAND_CONTRACTS = {
30
31
  enough: { flags: [], positionals: "none" },
31
32
  crystallize: { flags: ["--dry-run"], positionals: "none" },
32
33
  "bright-idea": { flags: ["--classify"], positionals: "text" },
34
+ dashboard: { flags: ["--once"], positionals: "none" },
33
35
  insight: { flags: [], positionals: "text" },
34
36
  prd: { flags: ["--dry-run"], positionals: "none" },
35
37
  qa: { flags: [], positionals: "none" },
@@ -57,6 +59,7 @@ function usage() {
57
59
  " crystallize [--dry-run] write recorded answers into project documents",
58
60
  " bright-idea [--classify <type>] <text>",
59
61
  " record a new idea without changing the plan",
62
+ " dashboard [--once] live read-only view of clarity, goal, and insights",
60
63
  " insight <text> append a product realization to philosophy.md",
61
64
  " prd [--dry-run] synthesize koan/prd.md from recorded answers",
62
65
  " qa create or refresh QA checklist",
@@ -334,6 +337,12 @@ async function main(argv) {
334
337
  console.log(`Bright idea recorded (${result.classification}). ${result.recommendation}`);
335
338
  return 0;
336
339
  }
340
+ if (command === "dashboard") {
341
+ const parsed = parseCommandArgs("dashboard", rest);
342
+ if (parsed === null)
343
+ return 1;
344
+ return runDashboard({ cwd, homeDir, once: parsed.flags.includes("--once") });
345
+ }
337
346
  if (command === "insight") {
338
347
  const parsed = parseCommandArgs("insight", rest);
339
348
  if (parsed === null)
@@ -1,4 +1,4 @@
1
- export declare const KOAN_VERSION = "0.2.0";
1
+ export declare const KOAN_VERSION = "0.3.0";
2
2
  export declare const KOAN_DIR = "koan";
3
3
  export declare const KOAN_STATE_DIR = ".koan";
4
4
  export declare const CORE_DOCUMENTS: {
@@ -1,4 +1,4 @@
1
- export const KOAN_VERSION = "0.2.0";
1
+ export const KOAN_VERSION = "0.3.0";
2
2
  export const KOAN_DIR = "koan";
3
3
  export const KOAN_STATE_DIR = ".koan";
4
4
  export const CORE_DOCUMENTS = {
@@ -0,0 +1,29 @@
1
+ import { type HostId } from "./hostAdapter.js";
2
+ import { type KoanQuestion } from "./questions.js";
3
+ import { type AmbiguityAxis, type CommandLogEntry, type Language } from "./schemas.js";
4
+ export interface DashboardAxis {
5
+ axis: AmbiguityAxis;
6
+ clarity: number;
7
+ }
8
+ export interface DashboardSnapshot {
9
+ projectRoot: string;
10
+ goalId: string | null;
11
+ phase: string | null;
12
+ axes: DashboardAxis[];
13
+ threshold: number;
14
+ converged: boolean;
15
+ unresolvedCount: number;
16
+ nextQuestion: KoanQuestion | null;
17
+ nextAction: string;
18
+ activeGoal: string | null;
19
+ latestStatus: string | null;
20
+ insights: string[];
21
+ staleWarnings: string[];
22
+ lastCommand: CommandLogEntry | null;
23
+ profileLanguage: Language;
24
+ }
25
+ export declare function collectDashboardSnapshot(input: {
26
+ cwd: string;
27
+ homeDir: string;
28
+ host?: HostId;
29
+ }): Promise<DashboardSnapshot>;
@@ -0,0 +1,61 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { loadCommandLog } from "./commandLog.js";
4
+ import { status } from "./commands.js";
5
+ import { CORE_DOCUMENTS, LAZY_DOCUMENTS } from "./constants.js";
6
+ import { readManagedSection } from "./documents.js";
7
+ import { parseInsights } from "./prd.js";
8
+ import { defaultProfile, loadProfile } from "./profile.js";
9
+ import { DEFAULT_ACTIVE_GOAL_PLACEHOLDER, DEFAULT_STATUS_PLACEHOLDER, findProjectRoot, loadProjectConfig } from "./project.js";
10
+ import { getQuestion } from "./questions.js";
11
+ import { DEFAULT_CONVERGENCE_THRESHOLD } from "./schemas.js";
12
+ import { AXIS_PRIORITY, isConverged, loadLedger, selectMostUnclearAxis, unresolvedAxes } from "./scoring.js";
13
+ import { loadSessionState } from "./session.js";
14
+ async function readRegion(path, region, placeholder) {
15
+ const text = await readFile(path, "utf8").catch(() => null);
16
+ if (text === null)
17
+ return null;
18
+ const section = readManagedSection(text, region);
19
+ if (section === null || section.length === 0 || section.startsWith(placeholder))
20
+ return null;
21
+ return section;
22
+ }
23
+ export async function collectDashboardSnapshot(input) {
24
+ const projectRoot = await findProjectRoot(input.cwd);
25
+ const state = await loadSessionState(projectRoot);
26
+ const profile = (await loadProfile(input.homeDir)) ?? defaultProfile();
27
+ const threshold = (await loadProjectConfig(projectRoot))?.settings.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD;
28
+ const hasGoal = state !== null && state.activeGoalId !== null && state.phase !== "archived";
29
+ const stored = await loadLedger(projectRoot);
30
+ const ledger = hasGoal && stored && stored.goalId === state.activeGoalId ? stored : null;
31
+ const clarityByAxis = new Map(ledger?.axes.map((entry) => [entry.axis, entry.clarity]) ?? []);
32
+ const axes = AXIS_PRIORITY.map((axis) => ({
33
+ axis,
34
+ clarity: clarityByAxis.get(axis) ?? 0
35
+ }));
36
+ const converged = hasGoal && (state.phase === "ready" || (ledger !== null && isConverged(ledger, threshold)));
37
+ const nextQuestion = hasGoal && !converged && ledger !== null
38
+ ? getQuestion(selectMostUnclearAxis(ledger), profile, input.host ?? "generic")
39
+ : null;
40
+ const unresolvedCount = ledger !== null && !converged ? unresolvedAxes(ledger, threshold).length : 0;
41
+ const { nextAction, staleWarnings } = await status({ cwd: projectRoot });
42
+ const philosophyText = await readFile(join(projectRoot, LAZY_DOCUMENTS.philosophy), "utf8").catch(() => "");
43
+ const log = await loadCommandLog(projectRoot);
44
+ return {
45
+ projectRoot,
46
+ goalId: hasGoal ? state.activeGoalId : null,
47
+ phase: state?.phase ?? null,
48
+ axes,
49
+ threshold,
50
+ converged,
51
+ unresolvedCount,
52
+ nextQuestion,
53
+ nextAction,
54
+ activeGoal: await readRegion(join(projectRoot, CORE_DOCUMENTS.goal), "active-goal", DEFAULT_ACTIVE_GOAL_PLACEHOLDER),
55
+ latestStatus: await readRegion(join(projectRoot, CORE_DOCUMENTS.status), "current-status", DEFAULT_STATUS_PLACEHOLDER),
56
+ insights: parseInsights(philosophyText),
57
+ staleWarnings,
58
+ lastCommand: log.entries.at(-1) ?? null,
59
+ profileLanguage: profile.language
60
+ };
61
+ }
package/dist/index.d.ts CHANGED
@@ -16,3 +16,4 @@ export * from "./core/crystallize.js";
16
16
  export * from "./core/commands.js";
17
17
  export * from "./core/hostAdapter.js";
18
18
  export * from "./core/prd.js";
19
+ export * from "./core/dashboard.js";
package/dist/index.js CHANGED
@@ -16,3 +16,4 @@ export * from "./core/crystallize.js";
16
16
  export * from "./core/commands.js";
17
17
  export * from "./core/hostAdapter.js";
18
18
  export * from "./core/prd.js";
19
+ export * from "./core/dashboard.js";
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
- export declare const toolNames: readonly ["koan_get_profile", "koan_update_profile", "koan_inspect_project", "koan_start_session", "koan_get_next_question", "koan_record_answer", "koan_crystallize_documents", "koan_get_status", "koan_update_status", "koan_record_bright_idea", "koan_record_insight", "koan_synthesize_prd", "koan_prepare_qa", "koan_prepare_handoff"];
3
+ export declare const toolNames: readonly ["koan_get_profile", "koan_update_profile", "koan_inspect_project", "koan_start_session", "koan_get_next_question", "koan_record_answer", "koan_crystallize_documents", "koan_get_status", "koan_update_status", "koan_record_bright_idea", "koan_record_insight", "koan_synthesize_prd", "koan_prepare_qa", "koan_prepare_handoff", "koan_get_dashboard"];
4
4
  export declare function createServer(): Server;
5
5
  export declare function runServer(): Promise<void>;
@@ -11,6 +11,7 @@ import { recordAnswer } from "../core/answers.js";
11
11
  import { brightIdea, handoff, hello, qa, recordInsight, status, updateStatus } from "../core/commands.js";
12
12
  import { CORE_DOCUMENTS, KOAN_VERSION, LAZY_DOCUMENTS, STATE_FILES } from "../core/constants.js";
13
13
  import { crystallize } from "../core/crystallize.js";
14
+ import { collectDashboardSnapshot } from "../core/dashboard.js";
14
15
  import { defaultKoanGitignore } from "../core/gitPolicy.js";
15
16
  import { adapterFor, detectHost } from "../core/hostAdapter.js";
16
17
  import { buildPrd } from "../core/prd.js";
@@ -36,7 +37,8 @@ export const toolNames = [
36
37
  "koan_record_insight",
37
38
  "koan_synthesize_prd",
38
39
  "koan_prepare_qa",
39
- "koan_prepare_handoff"
40
+ "koan_prepare_handoff",
41
+ "koan_get_dashboard"
40
42
  ];
41
43
  const BrightIdeaClassificationSchema = z.enum(["clarify", "change-goal", "later-follow-up", "reject"]);
42
44
  const profileFieldProperties = {
@@ -464,6 +466,22 @@ const tools = {
464
466
  return { prepared: true, path: LAZY_DOCUMENTS.qa, checklist: result.checklist };
465
467
  }
466
468
  },
469
+ koan_get_dashboard: {
470
+ description: "Read-only snapshot of session phase, per-axis clarity in question-priority order, next question, document summaries, insights, and warnings. Never writes project state.",
471
+ inputSchema: {
472
+ type: "object",
473
+ properties: { projectRoot: { type: "string" }, homeDir: { type: "string" } },
474
+ required: ["projectRoot", "homeDir"]
475
+ },
476
+ handler: async (args, context) => {
477
+ const parsed = z.object({ projectRoot: z.string(), homeDir: z.string() }).parse(args);
478
+ return collectDashboardSnapshot({
479
+ cwd: parsed.projectRoot,
480
+ homeDir: parsed.homeDir,
481
+ host: context.host
482
+ });
483
+ }
484
+ },
467
485
  koan_prepare_handoff: {
468
486
  description: "Write the document-based handoff at koan/handoff.md from the optional session summary and return the document with the next action.",
469
487
  inputSchema: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koan-labs/koan",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local-first philosophical PRD tool that crystallizes vague intent into product requirements, QA criteria, and AI-agent-ready handoff documents.",
5
5
  "license": "MIT",
6
6
  "repository": {