@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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +399 -0
- package/dist/cli/prompt.d.ts +5 -0
- package/dist/cli/prompt.js +48 -0
- package/dist/core/answers.d.ts +27 -0
- package/dist/core/answers.js +86 -0
- package/dist/core/commandLog.d.ts +8 -0
- package/dist/core/commandLog.js +51 -0
- package/dist/core/commands.d.ts +71 -0
- package/dist/core/commands.js +252 -0
- package/dist/core/constants.d.ts +32 -0
- package/dist/core/constants.js +36 -0
- package/dist/core/crystallize.d.ts +15 -0
- package/dist/core/crystallize.js +124 -0
- package/dist/core/documents.d.ts +11 -0
- package/dist/core/documents.js +72 -0
- package/dist/core/gitPolicy.d.ts +2 -0
- package/dist/core/gitPolicy.js +25 -0
- package/dist/core/handoff.d.ts +5 -0
- package/dist/core/handoff.js +20 -0
- package/dist/core/hostAdapter.d.ts +8 -0
- package/dist/core/hostAdapter.js +34 -0
- package/dist/core/lock.d.ts +5 -0
- package/dist/core/lock.js +142 -0
- package/dist/core/mcpCache.d.ts +4 -0
- package/dist/core/mcpCache.js +37 -0
- package/dist/core/prd.d.ts +33 -0
- package/dist/core/prd.js +151 -0
- package/dist/core/profile.d.ts +8 -0
- package/dist/core/profile.js +47 -0
- package/dist/core/profileRef.d.ts +3 -0
- package/dist/core/profileRef.js +41 -0
- package/dist/core/project.d.ts +17 -0
- package/dist/core/project.js +126 -0
- package/dist/core/qa.d.ts +6 -0
- package/dist/core/qa.js +26 -0
- package/dist/core/questions.d.ts +10 -0
- package/dist/core/questions.js +272 -0
- package/dist/core/reconstruct.d.ts +7 -0
- package/dist/core/reconstruct.js +62 -0
- package/dist/core/schemas.d.ts +331 -0
- package/dist/core/schemas.js +132 -0
- package/dist/core/scoring.d.ts +9 -0
- package/dist/core/scoring.js +72 -0
- package/dist/core/session.d.ts +6 -0
- package/dist/core/session.js +88 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/mcp/server.d.ts +5 -0
- package/dist/mcp/server.js +539 -0
- 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.
|
package/dist/cli/main.js
ADDED
|
@@ -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,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
|
+
}>;
|