@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kwon Dong-in
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # Koan
2
+
3
+ Koan is a local-first philosophical PRD tool. It helps you clarify why a
4
+ product should exist — before asking humans or AI agents to build it — and
5
+ crystallizes that intent into readable project documents: philosophy, goals,
6
+ plans, status, QA criteria, bright ideas, and handoff notes.
7
+
8
+ Named after the Zen practice, Koan asks one reflective question at a time,
9
+ starting from purpose and philosophy rather than feature lists. The result is
10
+ durable project memory that people can read and that AI agents — Codex,
11
+ Claude, and any MCP-capable agent — can consume to build faithfully without
12
+ losing your intent.
13
+
14
+ Koan is MCP-first, but not LLM-provider-first. It never calls an LLM API and
15
+ never transmits project data over the network. The core tool is deterministic;
16
+ host agents provide semantic reasoning through MCP.
17
+
18
+ ## Install
19
+
20
+ Requires Node.js 20+.
21
+
22
+ ```bash
23
+ npm install -g @koan-labs/koan
24
+ ```
25
+
26
+ ## CLI
27
+
28
+ The CLI works standalone with deterministic question templates; no host agent
29
+ or API key is required.
30
+
31
+ - `koan hello` — initialize or resume a session; runs the question loop on a
32
+ TTY (force it with `--interactive`).
33
+ - `koan hello --setup` — guided profile setup.
34
+ - `koan hello --profile` — print the global profile (read-only).
35
+ - `koan hello --reset-profile [--yes]` — delete the global profile (`--yes`
36
+ skips confirmation).
37
+ - `koan status` — show goal, status, and next action without writing.
38
+ - `koan status --update <text>` — record a status update.
39
+ - `koan status --archive` — archive the active goal to `koan/archive/<goal-id>/`.
40
+ - `koan answer <axis> <text>` — record an answer for one ambiguity axis.
41
+ - `koan enough` — accept current clarity and stop questioning.
42
+ - `koan crystallize [--dry-run]` — write recorded answers into documents;
43
+ `--dry-run` previews the write plan.
44
+ - `koan bright-idea [--classify <type>] <text>` — record an idea without
45
+ changing the plan; types: `clarify`, `change-goal`, `later-follow-up`, `reject`.
46
+ - `koan insight <text>` — append a product realization (the moment the real
47
+ product turns out to differ from the surface request) to `koan/philosophy.md`.
48
+ - `koan prd [--dry-run]` — synthesize `koan/prd.md` from recorded answers,
49
+ philosophy first.
50
+ - `koan qa` — create or refresh the QA checklist.
51
+ - `koan handoff <summary>` — create a document-based handoff.
52
+
53
+ ## MCP Server
54
+
55
+ Run `koan-mcp` (stdio transport) and register it with your MCP-capable agent.
56
+ From a checkout, `npm run mcp` starts the same server.
57
+
58
+ | Tool | Purpose |
59
+ | ---- | ------- |
60
+ | `koan_get_profile` | Read the global profile, learning mode, and per-project overrides. |
61
+ | `koan_update_profile` | Apply a partial profile update; reports changed fields. |
62
+ | `koan_inspect_project` | Report Koan state, bootstrap markers, document paths, and git policy. |
63
+ | `koan_start_session` | Initialize or resume a session; optionally captures raw intent and echoes the stored `rawIntent`. |
64
+ | `koan_get_next_question` | Return the question for the most unclear axis and cache it. |
65
+ | `koan_record_answer` | Record an answer (axis from input or the cached question) with optional host interpretation; returns a crystallize preview. |
66
+ | `koan_crystallize_documents` | Write recorded answers into managed document regions (`dryRun` supported). |
67
+ | `koan_get_status` | Summarize goal, status, next action, stale-state warnings, and the stored `rawIntent`. |
68
+ | `koan_update_status` | Write a status update into `koan/status.md` and the handoff document; reports the affected files. |
69
+ | `koan_record_bright_idea` | Record a classified idea plus a deterministic recommendation. |
70
+ | `koan_record_insight` | Append a product realization to `koan/philosophy.md` (append-only insight log). |
71
+ | `koan_synthesize_prd` | Synthesize `koan/prd.md`; hosts may supply vision, core value, problem/anti-problem, and user stories grounded in recorded answers. |
72
+ | `koan_prepare_qa` | Generate `koan/qa.md` with spec-compliance and quality checks; embeds an optional implementation summary and returns the checklist. |
73
+ | `koan_prepare_handoff` | Generate `koan/handoff.md` (summary text optional); returns the document and next action; touchless handoff stays disabled. |
74
+
75
+ All tool results are JSON in the first text content block; inputs are
76
+ zod-validated and failures surface as MCP errors.
77
+
78
+ Koan uses a semantic-host model: the core stays deterministic (state, scoring,
79
+ managed document writes) while the host agent supplies interpretation —
80
+ rephrasing questions, structuring answers, and scoring clarity through
81
+ `interpretation.clarity` on `koan_record_answer`. Without a host, the CLI runs
82
+ the same flow with built-in templates.
83
+
84
+ ## Project Files
85
+
86
+ Human-facing memory lives in `koan/`; machine state lives in `.koan/`.
87
+
88
+ - Core documents (always created): `koan/README.md`, `koan/goal.md`,
89
+ `koan/status.md`, `koan/plan.md`.
90
+ - Lazy documents (created on first use): `koan/philosophy.md`,
91
+ `koan/decisions.md`, `koan/open-questions.md`, `koan/qa.md`,
92
+ `koan/handoff.md`, `koan/bright-ideas.md`, `koan/prd.md`.
93
+ - State: `.koan/project.json` is intended to be committed; session state, the
94
+ ambiguity ledger, command log, MCP cache, and lock files are ignored through
95
+ a generated `.koan/.gitignore`.
96
+
97
+ Koan only rewrites its own managed regions (`<!-- koan:section:start ... -->`);
98
+ manual edits outside the markers are preserved.
99
+
100
+ ## Question Model
101
+
102
+ Koan tracks clarity across 11 ambiguity axes: `purpose`, `target_users`,
103
+ `current_goal`, `scope`, `non_goals`, `constraints`, `success_criteria`,
104
+ `philosophical_intent`, `implementation_plan`, `qa_criteria`, and
105
+ `handoff_readiness`.
106
+
107
+ A new session starts at the why layer: `purpose` and `philosophical_intent`
108
+ are asked before goal shaping and implementation planning, so the deeper
109
+ reason behind the product is captured before features are specified. Users
110
+ who want a shorter path can answer briefly and run `koan enough` at any time
111
+ to accept the current clarity and move on.
112
+
113
+ Question phrasing adapts to the user profile: language (`ko`, `en`, `mixed`)
114
+ crossed with four development-understanding levels. A goal converges when every
115
+ axis reaches the convergence threshold (default `0.7`), configurable as
116
+ `settings.convergenceThreshold` in `.koan/project.json`. Crystallized
117
+ documents put philosophy first; `koan/philosophy.md` is the document future
118
+ contributors and agents should read before changing scope.
119
+
120
+ `koan prd` synthesizes the answers into a single PRD (`koan/prd.md`) ordered
121
+ philosophy-first: the deterministic core fills every section it has answers
122
+ for, and host agents may enrich the vision, core-value, problem/anti-problem,
123
+ and user-story sections through `koan_synthesize_prd` — always grounded in
124
+ what the user actually said. `koan insight` keeps an append-only log of the
125
+ moments when the real product turned out to differ from the surface request.
126
+
127
+ Instructions to host agents adapt to the connected MCP client (detected
128
+ locally from the MCP handshake — Claude, Codex/OpenAI, or generic): the
129
+ phrasing follows each model family's prompting guidance while the contract
130
+ stays identical across hosts. See `docs/host-adapters.md`.
131
+
132
+ ## Privacy
133
+
134
+ Koan has no telemetry and does not transmit profile, project, or answer data
135
+ over the network. The global user profile is stored at `~/.koan/profile.json`.
136
+ Profile inference is allowlist-only: hosts may propose updates to the declared
137
+ profile fields and nothing else, and the default `approval_required` learning
138
+ mode keeps changes user-approved.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from "node:os";
3
+ import { acceptClarity, recordAnswer } from "../core/answers.js";
4
+ import { archive, brightIdea, handoff, hello, qa, recordInsight, status, updateStatus } from "../core/commands.js";
5
+ import { crystallize } from "../core/crystallize.js";
6
+ import { buildPrd } from "../core/prd.js";
7
+ import { defaultProfile, loadProfile, resetProfile, saveProfile } from "../core/profile.js";
8
+ import { getQuestion } from "../core/questions.js";
9
+ import { ANSWERED_CLARITY } from "../core/scoring.js";
10
+ import { createPrompter } from "./prompt.js";
11
+ const BRIGHT_IDEA_CLASSIFICATIONS = [
12
+ "clarify",
13
+ "change-goal",
14
+ "later-follow-up",
15
+ "reject"
16
+ ];
17
+ const DEVELOPMENT_UNDERSTANDING_OPTIONS = ["non_technical", "beginner", "intermediate", "expert"];
18
+ const EXPLANATION_STYLE_OPTIONS = ["short", "example_first", "step_by_step", "technical_ok"];
19
+ const LANGUAGE_OPTIONS = ["ko", "en", "mixed"];
20
+ const OUTPUT_USE_OPTIONS = ["self_implementation", "agent_execution", "team_sharing", "learning"];
21
+ const LEARNING_MODE_OPTIONS = ["approval_required", "auto_with_review"];
22
+ const COMMAND_CONTRACTS = {
23
+ hello: {
24
+ flags: ["--interactive", "--setup", "--profile", "--reset-profile", "--yes"],
25
+ positionals: "none",
26
+ requires: { "--yes": "--reset-profile" }
27
+ },
28
+ status: { flags: ["--update", "--archive"], positionals: "none-unless-update-text" },
29
+ answer: { flags: [], positionals: "axis-then-text" },
30
+ enough: { flags: [], positionals: "none" },
31
+ crystallize: { flags: ["--dry-run"], positionals: "none" },
32
+ "bright-idea": { flags: ["--classify"], positionals: "text" },
33
+ insight: { flags: [], positionals: "text" },
34
+ prd: { flags: ["--dry-run"], positionals: "none" },
35
+ qa: { flags: [], positionals: "none" },
36
+ handoff: { flags: [], positionals: "text" }
37
+ };
38
+ let prompter = null;
39
+ function getPrompter() {
40
+ prompter ??= createPrompter();
41
+ return prompter;
42
+ }
43
+ function usage() {
44
+ return [
45
+ "Usage: koan <command>",
46
+ "",
47
+ "Commands:",
48
+ " hello [--interactive] initialize or resume Koan; run the question loop",
49
+ " hello --setup run guided profile setup",
50
+ " hello --profile print the global profile (read-only)",
51
+ " hello --reset-profile delete the global profile (--yes skips confirmation)",
52
+ " status show project status without writing by default",
53
+ " status --update <text> record a status update",
54
+ " status --archive archive the active goal",
55
+ " answer <axis> <text> record an answer for an ambiguity axis",
56
+ " enough accept current clarity and stop questioning",
57
+ " crystallize [--dry-run] write recorded answers into project documents",
58
+ " bright-idea [--classify <type>] <text>",
59
+ " record a new idea without changing the plan",
60
+ " insight <text> append a product realization to philosophy.md",
61
+ " prd [--dry-run] synthesize koan/prd.md from recorded answers",
62
+ " qa create or refresh QA checklist",
63
+ " handoff <summary> create document-based handoff",
64
+ " (summaries beginning with \"--\" are unsupported)"
65
+ ].join("\n");
66
+ }
67
+ async function runProfileSetup(prompt, homeDir) {
68
+ const profile = defaultProfile();
69
+ let ended = false;
70
+ const choose = async (question, options, fallback) => {
71
+ if (ended)
72
+ return fallback;
73
+ const line = await prompt.ask(question);
74
+ if (line === null) {
75
+ ended = true;
76
+ return fallback;
77
+ }
78
+ if (/^[0-9]+$/.test(line))
79
+ return options[Number.parseInt(line, 10) - 1] ?? fallback;
80
+ return options.find((option) => option === line) ?? fallback;
81
+ };
82
+ profile.developmentUnderstanding = await choose("Development understanding [1 non_technical / 2 beginner / 3 intermediate / 4 expert] (2): ", DEVELOPMENT_UNDERSTANDING_OPTIONS, profile.developmentUnderstanding);
83
+ profile.explanationStyle = await choose("Explanation style [1 short / 2 example_first / 3 step_by_step / 4 technical_ok] (2): ", EXPLANATION_STYLE_OPTIONS, profile.explanationStyle);
84
+ profile.language = await choose("Language [1 ko / 2 en / 3 mixed] (1): ", LANGUAGE_OPTIONS, profile.language);
85
+ profile.outputUse = await choose("Output use [1 self_implementation / 2 agent_execution / 3 team_sharing / 4 learning] (2): ", OUTPUT_USE_OPTIONS, profile.outputUse);
86
+ if (!ended) {
87
+ const background = await prompt.ask("Domain background (free text, empty ok): ");
88
+ if (background === null)
89
+ ended = true;
90
+ else
91
+ profile.domainBackground = background;
92
+ }
93
+ profile.learningMode = await choose("Learning mode [1 approval_required / 2 auto_with_review] (1): ", LEARNING_MODE_OPTIONS, profile.learningMode);
94
+ await saveProfile(homeDir, profile);
95
+ console.log("Profile saved.");
96
+ }
97
+ async function runInteractiveHello(input) {
98
+ const { cwd, homeDir, result, prompt } = input;
99
+ if (input.firstRun)
100
+ await runProfileSetup(prompt, homeDir);
101
+ const profile = (await loadProfile(homeDir)) ?? defaultProfile();
102
+ let question = result.nextQuestion
103
+ ? getQuestion(result.nextQuestion.axis, profile)
104
+ : null;
105
+ if (result.resumed && result.lastAnswer) {
106
+ console.log(`Last answer (${result.lastAnswer.axis}): ${result.lastAnswer.answer}`);
107
+ let resumeChoice = null;
108
+ while (resumeChoice === null) {
109
+ const choice = await prompt.ask("Resume: [c]ontinue, [r]evise last answer, [s]top? ");
110
+ if (choice === null || choice === "s")
111
+ resumeChoice = "stop";
112
+ else if (choice === "r")
113
+ resumeChoice = "revise";
114
+ else if (choice === "c" || choice === "")
115
+ resumeChoice = "continue";
116
+ else
117
+ console.log(`Unrecognized choice: ${choice}`);
118
+ }
119
+ if (resumeChoice === "stop") {
120
+ console.log("Stopped. Run koan hello to continue.");
121
+ return 0;
122
+ }
123
+ if (resumeChoice === "revise")
124
+ question = getQuestion(result.lastAnswer.axis, profile);
125
+ }
126
+ while (question) {
127
+ console.log(question.userFacingQuestion);
128
+ const line = await prompt.ask("> ");
129
+ if (line === null || line === "stop" || line === "quit") {
130
+ console.log("Stopped. Run koan hello to continue.");
131
+ return 0;
132
+ }
133
+ if (line === "enough") {
134
+ await acceptClarity({ cwd });
135
+ console.log("Accepted current clarity.");
136
+ break;
137
+ }
138
+ if (line === "")
139
+ continue;
140
+ const recorded = await recordAnswer({ cwd, homeDir, axis: question.axis, answer: line });
141
+ console.log(`Recorded ${question.axis} (clarity ${ANSWERED_CLARITY}).`);
142
+ if (recorded.converged)
143
+ console.log("All axes converged.");
144
+ question = recorded.nextQuestion;
145
+ }
146
+ const crystallized = await crystallize({ cwd, homeDir });
147
+ console.log(`Crystallized ${crystallized.crystallizedAxes.length} axes.`);
148
+ console.log("Session complete.");
149
+ return 0;
150
+ }
151
+ function parseLeadingFlags(args, known) {
152
+ const flags = [];
153
+ let index = 0;
154
+ while (index < args.length && args[index].startsWith("--")) {
155
+ const token = args[index];
156
+ if (!known.includes(token))
157
+ return { flags, text: args.slice(index), unknown: token };
158
+ flags.push(token);
159
+ index += 1;
160
+ }
161
+ return { flags, text: args.slice(index), unknown: null };
162
+ }
163
+ function contractViolation(message) {
164
+ console.error(message);
165
+ console.error(usage());
166
+ return null;
167
+ }
168
+ // Shared contract enforcement for every command: unknown leading flags,
169
+ // unexpected positional operands, and flag dependencies are all rejected here
170
+ // (null is returned after printing the error) before any handler runs.
171
+ function parseCommandArgs(command, args) {
172
+ const contract = COMMAND_CONTRACTS[command];
173
+ const parsed = parseLeadingFlags(args, contract.flags);
174
+ if (parsed.unknown !== null) {
175
+ return contractViolation(`Unknown flag for koan ${command}: ${parsed.unknown}`);
176
+ }
177
+ const textAllowed = contract.positionals === "text" ||
178
+ contract.positionals === "axis-then-text" ||
179
+ (contract.positionals === "none-unless-update-text" && parsed.flags.includes("--update"));
180
+ if (!textAllowed && parsed.text.length > 0) {
181
+ return contractViolation(`Unexpected argument for koan ${command}: ${parsed.text[0]}`);
182
+ }
183
+ for (const [flag, dependency] of Object.entries(contract.requires ?? {})) {
184
+ if (parsed.flags.includes(flag) && !parsed.flags.includes(dependency)) {
185
+ console.error(`${flag} requires ${dependency}.`);
186
+ return null;
187
+ }
188
+ }
189
+ return { flags: parsed.flags, text: parsed.text };
190
+ }
191
+ // hello mode flags select one exclusive behavior; --interactive only applies
192
+ // to the question loop, so combining it with any mode flag is an error too
193
+ // (--setup exits after saving the profile and never enters the loop).
194
+ const HELLO_MODE_FLAGS = ["--setup", "--profile", "--reset-profile"];
195
+ async function main(argv) {
196
+ const [command, ...rest] = argv;
197
+ const cwd = process.cwd();
198
+ const homeDir = process.env.HOME ?? homedir();
199
+ if (command === "hello") {
200
+ const parsed = parseCommandArgs("hello", rest);
201
+ if (parsed === null)
202
+ return 1;
203
+ const flags = parsed.flags;
204
+ const modeFlags = HELLO_MODE_FLAGS.filter((flag) => flags.includes(flag));
205
+ if (modeFlags.length > 1 || (modeFlags.length > 0 && flags.includes("--interactive"))) {
206
+ console.error("Use only one of --setup, --profile, --reset-profile.");
207
+ return 1;
208
+ }
209
+ const interactive = process.stdin.isTTY === true || flags.includes("--interactive");
210
+ if (flags.includes("--profile")) {
211
+ const profile = await loadProfile(homeDir);
212
+ console.log(JSON.stringify(profile ?? defaultProfile(), null, 2));
213
+ return 0;
214
+ }
215
+ if (flags.includes("--reset-profile")) {
216
+ if (!flags.includes("--yes")) {
217
+ if (process.stdin.isTTY !== true) {
218
+ console.error("Refusing to reset the profile without --yes in non-interactive mode.");
219
+ return 1;
220
+ }
221
+ const confirmation = await getPrompter().ask("Delete the global profile? [y/N] ");
222
+ if (confirmation !== "y" && confirmation !== "yes")
223
+ return 0;
224
+ }
225
+ await resetProfile(homeDir);
226
+ console.log("Profile reset.");
227
+ return 0;
228
+ }
229
+ if (flags.includes("--setup")) {
230
+ await runProfileSetup(getPrompter(), homeDir);
231
+ return 0;
232
+ }
233
+ const hadProfile = (await loadProfile(homeDir)) !== null;
234
+ const result = await hello({ cwd, homeDir });
235
+ console.log(`Koan ready: ${result.projectRoot}`);
236
+ if (!interactive) {
237
+ if (result.nextQuestion)
238
+ console.log(result.nextQuestion.userFacingQuestion);
239
+ return 0;
240
+ }
241
+ return runInteractiveHello({ cwd, homeDir, result, firstRun: !hadProfile, prompt: getPrompter() });
242
+ }
243
+ if (command === "status") {
244
+ const parsed = parseCommandArgs("status", rest);
245
+ if (parsed === null)
246
+ return 1;
247
+ const wantsArchive = parsed.flags.includes("--archive");
248
+ const wantsUpdate = parsed.flags.includes("--update");
249
+ if (wantsArchive && wantsUpdate) {
250
+ console.error("Use either --update or --archive, not both.");
251
+ return 1;
252
+ }
253
+ if (wantsArchive) {
254
+ const result = await archive({ cwd });
255
+ console.log(`Archived ${result.archivedGoalId}.`);
256
+ return 0;
257
+ }
258
+ if (wantsUpdate) {
259
+ let update = parsed.text.join(" ").trim();
260
+ if (!update) {
261
+ if (process.stdin.isTTY !== true) {
262
+ console.error("Usage: koan status --update <text>");
263
+ return 1;
264
+ }
265
+ update = (await getPrompter().ask("Status update: ")) ?? "";
266
+ }
267
+ await updateStatus({ cwd, update });
268
+ console.log("Status updated.");
269
+ return 0;
270
+ }
271
+ const result = await status({ cwd });
272
+ console.log(result.summary);
273
+ return 0;
274
+ }
275
+ if (command === "answer") {
276
+ // No leading flags: the axis is the first positional and everything after
277
+ // it is free text verbatim (flag-like tokens included).
278
+ const parsed = parseCommandArgs("answer", rest);
279
+ if (parsed === null)
280
+ return 1;
281
+ const [axis, ...answerWords] = parsed.text;
282
+ const answer = answerWords.join(" ").trim();
283
+ if (!axis || !answer) {
284
+ console.error("Usage: koan answer <axis> <text>");
285
+ return 1;
286
+ }
287
+ const result = await recordAnswer({ cwd, homeDir, axis: axis, answer });
288
+ console.log(`Recorded ${result.answer.axis}. Next: ${result.nextQuestion?.axis ?? "converged"}.`);
289
+ return 0;
290
+ }
291
+ if (command === "enough") {
292
+ if (parseCommandArgs("enough", rest) === null)
293
+ return 1;
294
+ await acceptClarity({ cwd });
295
+ console.log("Accepted current clarity.");
296
+ return 0;
297
+ }
298
+ if (command === "crystallize") {
299
+ const parsed = parseCommandArgs("crystallize", rest);
300
+ if (parsed === null)
301
+ return 1;
302
+ const dryRun = parsed.flags.includes("--dry-run");
303
+ const result = await crystallize({ cwd, homeDir, dryRun });
304
+ if (dryRun) {
305
+ console.log(`Dry run: ${result.plan.operations.length} operations planned.`);
306
+ return 0;
307
+ }
308
+ console.log(`Crystallized ${result.crystallizedAxes.length} axes.`);
309
+ return 0;
310
+ }
311
+ if (command === "bright-idea") {
312
+ // --classify is the only leading flag; its value is the first non-flag
313
+ // token, and everything after that value is idea text verbatim.
314
+ const parsed = parseCommandArgs("bright-idea", rest);
315
+ if (parsed === null)
316
+ return 1;
317
+ let classification;
318
+ let ideaWords = parsed.text;
319
+ if (parsed.flags.includes("--classify")) {
320
+ const value = ideaWords[0];
321
+ if (!BRIGHT_IDEA_CLASSIFICATIONS.includes(value)) {
322
+ console.error(`Invalid classification: ${value}`);
323
+ return 1;
324
+ }
325
+ classification = value;
326
+ ideaWords = ideaWords.slice(1);
327
+ }
328
+ const idea = ideaWords.join(" ").trim();
329
+ if (!idea) {
330
+ console.error("Usage: koan bright-idea <text>");
331
+ return 1;
332
+ }
333
+ const result = await brightIdea({ cwd, idea, classification });
334
+ console.log(`Bright idea recorded (${result.classification}). ${result.recommendation}`);
335
+ return 0;
336
+ }
337
+ if (command === "insight") {
338
+ const parsed = parseCommandArgs("insight", rest);
339
+ if (parsed === null)
340
+ return 1;
341
+ const text = parsed.text.join(" ").trim();
342
+ if (!text) {
343
+ console.error("Usage: koan insight <text>");
344
+ return 1;
345
+ }
346
+ const result = await recordInsight({ cwd, text });
347
+ console.log(`Insight recorded in ${result.path}.`);
348
+ return 0;
349
+ }
350
+ if (command === "prd") {
351
+ const parsed = parseCommandArgs("prd", rest);
352
+ if (parsed === null)
353
+ return 1;
354
+ const dryRun = parsed.flags.includes("--dry-run");
355
+ const result = await buildPrd({ cwd, homeDir, dryRun });
356
+ if (dryRun) {
357
+ console.log(`Dry run: ${result.plan.operations.length} operations planned.`);
358
+ return 0;
359
+ }
360
+ console.log(`PRD synthesized at ${result.path}.`);
361
+ return 0;
362
+ }
363
+ if (command === "qa") {
364
+ if (parseCommandArgs("qa", rest) === null)
365
+ return 1;
366
+ await qa({ cwd });
367
+ console.log("QA checklist ready.");
368
+ return 0;
369
+ }
370
+ if (command === "handoff") {
371
+ // No leading flags; the summary is everything after them, verbatim. A
372
+ // summary that itself begins with "--" is therefore unsupported (see
373
+ // usage), but later tokens may look like flags.
374
+ const parsed = parseCommandArgs("handoff", rest);
375
+ if (parsed === null)
376
+ return 1;
377
+ const summary = parsed.text.join(" ").trim();
378
+ if (!summary) {
379
+ console.error("Usage: koan handoff <summary>");
380
+ return 1;
381
+ }
382
+ await handoff({ cwd, summary });
383
+ console.log("Handoff ready.");
384
+ return 0;
385
+ }
386
+ console.error(usage());
387
+ return 1;
388
+ }
389
+ main(process.argv.slice(2))
390
+ .then((code) => {
391
+ process.exitCode = code;
392
+ })
393
+ .catch((error) => {
394
+ console.error(error instanceof Error ? error.message : String(error));
395
+ process.exitCode = 1;
396
+ })
397
+ .finally(() => {
398
+ prompter?.close();
399
+ });
@@ -0,0 +1,5 @@
1
+ export interface Prompter {
2
+ ask(question: string): Promise<string | null>;
3
+ close(): void;
4
+ }
5
+ export declare function createPrompter(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Prompter;
@@ -0,0 +1,48 @@
1
+ import { createInterface } from "node:readline";
2
+ // Lines are buffered as they arrive so piped stdin that delivers several
3
+ // lines in one chunk (or closes early) never loses input between asks.
4
+ export function createPrompter(input = process.stdin, output = process.stdout) {
5
+ const rl = createInterface({ input, output });
6
+ const lines = [];
7
+ let closed = false;
8
+ // FIFO queue so concurrent asks each get their own line instead of a later
9
+ // ask overwriting an earlier resolver (which would leave it forever pending).
10
+ const pending = [];
11
+ rl.on("line", (line) => {
12
+ const resolve = pending.shift();
13
+ if (resolve) {
14
+ resolve(line.trim());
15
+ }
16
+ else {
17
+ lines.push(line);
18
+ }
19
+ });
20
+ rl.on("close", () => {
21
+ closed = true;
22
+ const waiting = pending.splice(0, pending.length);
23
+ for (const resolve of waiting)
24
+ resolve(null);
25
+ });
26
+ return {
27
+ ask(question) {
28
+ const buffered = lines.shift();
29
+ if (buffered !== undefined) {
30
+ output.write(question);
31
+ return Promise.resolve(buffered.trim());
32
+ }
33
+ if (closed) {
34
+ output.write(question);
35
+ return Promise.resolve(null);
36
+ }
37
+ rl.setPrompt(question);
38
+ rl.prompt();
39
+ return new Promise((resolve) => {
40
+ pending.push(resolve);
41
+ });
42
+ },
43
+ close() {
44
+ if (!closed)
45
+ rl.close();
46
+ }
47
+ };
48
+ }
@@ -0,0 +1,27 @@
1
+ import { type KoanQuestion } from "./questions.js";
2
+ import { type AmbiguityAxis, type AmbiguityLedger, type AnswerRecord } from "./schemas.js";
3
+ export interface RecordAnswerInput {
4
+ cwd: string;
5
+ homeDir: string;
6
+ axis: AmbiguityAxis;
7
+ answer: string;
8
+ clarity?: number;
9
+ question?: string;
10
+ isoDate?: string;
11
+ source?: string;
12
+ }
13
+ export interface RecordAnswerResult {
14
+ projectRoot: string;
15
+ ledger: AmbiguityLedger;
16
+ answer: AnswerRecord;
17
+ converged: boolean;
18
+ unresolved: AmbiguityAxis[];
19
+ nextQuestion: KoanQuestion | null;
20
+ }
21
+ export declare function recordAnswer(input: RecordAnswerInput): Promise<RecordAnswerResult>;
22
+ export declare function acceptClarity(input: {
23
+ cwd: string;
24
+ isoDate?: string;
25
+ }): Promise<{
26
+ projectRoot: string;
27
+ }>;