@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,151 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { LAZY_DOCUMENTS, managedEnd, managedStart } from "./constants.js";
4
+ import { executeWritePlan, readManagedSection, sanitizeRegionContent } from "./documents.js";
5
+ import { adapterFor } from "./hostAdapter.js";
6
+ import { findProjectRoot, loadProjectConfig } from "./project.js";
7
+ import { DEFAULT_CONVERGENCE_THRESHOLD } from "./schemas.js";
8
+ import { createInitialLedger, loadLedger, unresolvedAxes } from "./scoring.js";
9
+ import { loadSessionState } from "./session.js";
10
+ // §10 output contract order: philosophy first, residual ambiguity last.
11
+ export const PRD_SECTIONS = [
12
+ { region: "philosophy", title: "Philosophy / Why", axis: "philosophical_intent" },
13
+ { region: "vision", title: "Product Vision", hostKey: "vision" },
14
+ { region: "core-value", title: "Core Value", hostKey: "coreValue" },
15
+ { region: "target-users", title: "Target Users", axis: "target_users" },
16
+ { region: "problem-anti-problem", title: "Problem and Anti-Problem", hostKey: "problemAntiProblem" },
17
+ { region: "scope", title: "Scope", axis: "scope" },
18
+ { region: "non-goals", title: "Non-Goals", axis: "non_goals" },
19
+ { region: "user-stories", title: "User Stories", hostKey: "userStories" },
20
+ { region: "success-criteria", title: "Success Criteria", axis: "success_criteria" },
21
+ { region: "implementation-plan", title: "Implementation Plan", axis: "implementation_plan" },
22
+ { region: "qa-criteria", title: "QA Criteria", axis: "qa_criteria" },
23
+ { region: "handoff-notes", title: "Handoff Notes", axis: "handoff_readiness" },
24
+ { region: "residual-ambiguity", title: "Residual Ambiguity" }
25
+ ];
26
+ const NOT_CLARIFIED_PREFIX = "_Not yet clarified";
27
+ const PENDING_SYNTHESIS_PREFIX = "_Pending host synthesis.";
28
+ function notClarified(axis) {
29
+ return `${NOT_CLARIFIED_PREFIX} (answer the \`${axis}\` question)._`;
30
+ }
31
+ function pendingSynthesis(host) {
32
+ return `${PENDING_SYNTHESIS_PREFIX} ${adapterFor(host).prdSynthesisInstruction}_`;
33
+ }
34
+ function isPlaceholder(content) {
35
+ return (content === null ||
36
+ content.length === 0 ||
37
+ content.startsWith(NOT_CLARIFIED_PREFIX) ||
38
+ content.startsWith(PENDING_SYNTHESIS_PREFIX));
39
+ }
40
+ function prdSkeleton() {
41
+ const lines = [
42
+ "# PRD",
43
+ "",
44
+ "Synthesized by `koan prd` and `koan_synthesize_prd` from recorded Koan",
45
+ "answers and `koan/philosophy.md`. Managed regions are rewritten on every",
46
+ "synthesis; content outside the markers is preserved.",
47
+ ""
48
+ ];
49
+ for (const section of PRD_SECTIONS) {
50
+ lines.push(`## ${section.title}`, "", managedStart(section.region), managedEnd(section.region), "");
51
+ }
52
+ return lines.join("\n");
53
+ }
54
+ // Best-effort: collect the first line of each `## <iso> — koan insight` entry
55
+ // appended by recordInsight, in file order.
56
+ export function parseInsights(philosophyText) {
57
+ const insights = [];
58
+ const blocks = philosophyText.split(/^## /m).slice(1);
59
+ for (const block of blocks) {
60
+ const lines = block.split("\n");
61
+ if (!lines[0]?.includes("— koan insight"))
62
+ continue;
63
+ const body = lines.slice(1).find((line) => line.trim().length > 0);
64
+ if (body)
65
+ insights.push(body.trim());
66
+ }
67
+ return insights;
68
+ }
69
+ async function exists(path) {
70
+ try {
71
+ await access(path);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ export async function buildPrd(input) {
79
+ const projectRoot = await findProjectRoot(input.cwd);
80
+ const state = await loadSessionState(projectRoot);
81
+ if (!state)
82
+ throw new Error("No active Koan session. Run koan hello first.");
83
+ if (!state.activeGoalId || state.phase === "archived") {
84
+ throw new Error("No active goal. Run koan hello first.");
85
+ }
86
+ const host = input.host ?? "generic";
87
+ const isoDate = input.isoDate ?? new Date().toISOString();
88
+ const latestAnswers = new Map();
89
+ for (const answer of state.answers) {
90
+ latestAnswers.set(answer.axis, answer.answer);
91
+ }
92
+ const prdPath = join(projectRoot, LAZY_DOCUMENTS.prd);
93
+ const prdExists = await exists(prdPath);
94
+ const existingText = prdExists ? await readFile(prdPath, "utf8") : "";
95
+ const philosophyText = await readFile(join(projectRoot, LAZY_DOCUMENTS.philosophy), "utf8").catch(() => "");
96
+ const insights = parseInsights(philosophyText);
97
+ const stored = await loadLedger(projectRoot);
98
+ const ledger = stored && stored.goalId === state.activeGoalId ? stored : createInitialLedger(state.activeGoalId, isoDate);
99
+ const threshold = (await loadProjectConfig(projectRoot))?.settings.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD;
100
+ const unresolved = unresolvedAxes(ledger, threshold);
101
+ const operations = [];
102
+ if (!prdExists) {
103
+ operations.push({ type: "write", path: LAZY_DOCUMENTS.prd, content: prdSkeleton() });
104
+ }
105
+ for (const section of PRD_SECTIONS) {
106
+ let content;
107
+ if (section.region === "philosophy") {
108
+ const parts = [];
109
+ const answer = latestAnswers.get("philosophical_intent");
110
+ parts.push(answer ?? notClarified("philosophical_intent"));
111
+ if (insights.length > 0) {
112
+ parts.push("", "Insights (see `koan/philosophy.md` for the full log):");
113
+ parts.push(...insights.map((insight) => `- ${insight}`));
114
+ }
115
+ content = parts.join("\n");
116
+ }
117
+ else if (section.region === "residual-ambiguity") {
118
+ content =
119
+ unresolved.length === 0
120
+ ? "None."
121
+ : unresolved.map((axis) => `- \`${axis}\` is below the convergence threshold (${threshold}).`).join("\n");
122
+ }
123
+ else if (section.hostKey) {
124
+ const provided = input.sections?.[section.hostKey]?.trim();
125
+ const existing = prdExists ? readManagedSection(existingText, section.region) : null;
126
+ const seed = section.region === "vision" ? latestAnswers.get("purpose") : undefined;
127
+ content = provided || (!isPlaceholder(existing) ? existing : (seed ?? pendingSynthesis(host)));
128
+ }
129
+ else if (section.axis) {
130
+ content = latestAnswers.get(section.axis) ?? notClarified(section.axis);
131
+ }
132
+ else {
133
+ content = "";
134
+ }
135
+ operations.push({
136
+ type: "managed-region",
137
+ path: LAZY_DOCUMENTS.prd,
138
+ name: section.region,
139
+ content: sanitizeRegionContent(content)
140
+ });
141
+ }
142
+ const plan = { description: "Synthesize the PRD from recorded answers", operations };
143
+ if (input.dryRun) {
144
+ return { projectRoot, path: LAZY_DOCUMENTS.prd, plan, executed: false, document: null };
145
+ }
146
+ await executeWritePlan(projectRoot, plan, {
147
+ log: { command: "koan prd", summary: "Synthesized the PRD." }
148
+ });
149
+ const document = await readFile(prdPath, "utf8");
150
+ return { projectRoot, path: LAZY_DOCUMENTS.prd, plan, executed: true, document };
151
+ }
@@ -0,0 +1,8 @@
1
+ import { type Language, type UserProfile } from "./schemas.js";
2
+ export declare function getProfilePath(homeDir: string): string;
3
+ export declare function defaultProfile(overrides?: Partial<UserProfile>): UserProfile;
4
+ export declare function loadProfile(homeDir: string): Promise<UserProfile | null>;
5
+ export declare function saveProfile(homeDir: string, profile: UserProfile): Promise<UserProfile>;
6
+ export declare function updateProfile(homeDir: string, changes: Partial<UserProfile>): Promise<UserProfile>;
7
+ export declare function resetProfile(homeDir: string): Promise<void>;
8
+ export declare function normalizeLanguage(value: string): Language;
@@ -0,0 +1,47 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { UserProfileSchema } from "./schemas.js";
4
+ export function getProfilePath(homeDir) {
5
+ return join(homeDir, ".koan/profile.json");
6
+ }
7
+ export function defaultProfile(overrides = {}) {
8
+ return {
9
+ developmentUnderstanding: "beginner",
10
+ explanationStyle: "example_first",
11
+ language: "ko",
12
+ outputUse: "agent_execution",
13
+ domainBackground: "",
14
+ learningMode: "approval_required",
15
+ ...overrides
16
+ };
17
+ }
18
+ export async function loadProfile(homeDir) {
19
+ try {
20
+ const raw = await readFile(getProfilePath(homeDir), "utf8");
21
+ return UserProfileSchema.parse(JSON.parse(raw));
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ export async function saveProfile(homeDir, profile) {
28
+ const parsed = UserProfileSchema.parse(profile);
29
+ const path = getProfilePath(homeDir);
30
+ await mkdir(dirname(path), { recursive: true });
31
+ await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
32
+ return parsed;
33
+ }
34
+ export async function updateProfile(homeDir, changes) {
35
+ const approved = UserProfileSchema.partial().parse(changes);
36
+ const base = (await loadProfile(homeDir)) ?? defaultProfile();
37
+ const defined = Object.fromEntries(Object.entries(approved).filter(([, value]) => value !== undefined));
38
+ return saveProfile(homeDir, { ...base, ...defined });
39
+ }
40
+ export async function resetProfile(homeDir) {
41
+ await rm(getProfilePath(homeDir), { force: true });
42
+ }
43
+ export function normalizeLanguage(value) {
44
+ if (value === "en" || value === "mixed")
45
+ return value;
46
+ return "ko";
47
+ }
@@ -0,0 +1,3 @@
1
+ import { type UserProfileRef } from "./schemas.js";
2
+ export declare function loadProfileRef(projectRoot: string): Promise<UserProfileRef | null>;
3
+ export declare function ensureProfileRef(projectRoot: string, homeDir: string): Promise<UserProfileRef>;
@@ -0,0 +1,41 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { STATE_FILES } from "./constants.js";
4
+ import { ensureStateGitignore } from "./gitPolicy.js";
5
+ import { UserProfileRefSchema } from "./schemas.js";
6
+ import { withFileLock } from "./lock.js";
7
+ import { getProfilePath } from "./profile.js";
8
+ export async function loadProfileRef(projectRoot) {
9
+ try {
10
+ const raw = await readFile(join(projectRoot, STATE_FILES.userProfileRef), "utf8");
11
+ return UserProfileRefSchema.parse(JSON.parse(raw));
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ export async function ensureProfileRef(projectRoot, homeDir) {
18
+ const path = join(projectRoot, STATE_FILES.userProfileRef);
19
+ const ref = { version: 1, profilePath: getProfilePath(homeDir), overrides: {} };
20
+ return withFileLock(projectRoot, async () => {
21
+ let raw = null;
22
+ try {
23
+ raw = await readFile(path, "utf8");
24
+ }
25
+ catch {
26
+ raw = null;
27
+ }
28
+ if (raw !== null) {
29
+ try {
30
+ return UserProfileRefSchema.parse(JSON.parse(raw));
31
+ }
32
+ catch {
33
+ await writeFile(`${path}.bak`, raw, "utf8");
34
+ }
35
+ }
36
+ await ensureStateGitignore(projectRoot);
37
+ await mkdir(dirname(path), { recursive: true });
38
+ await writeFile(path, `${JSON.stringify(ref, null, 2)}\n`, "utf8");
39
+ return ref;
40
+ });
41
+ }
@@ -0,0 +1,17 @@
1
+ import { type ProjectConfig } from "./schemas.js";
2
+ export interface ProjectInspection {
3
+ projectRoot: string;
4
+ isKoanProject: boolean;
5
+ hasAgentsMd: boolean;
6
+ hasClaudeMd: boolean;
7
+ hasKoanBootstrap: boolean;
8
+ }
9
+ export declare function findProjectRoot(start: string): Promise<string>;
10
+ export declare function inspectProject(start: string): Promise<ProjectInspection>;
11
+ export declare function patchBootstrap(existing: string): string;
12
+ export declare const DEFAULT_ACTIVE_GOAL_PLACEHOLDER = "No active goal yet.";
13
+ export declare const DEFAULT_PLAN_PLACEHOLDER = "No implementation plan recorded yet.";
14
+ export declare const DEFAULT_STATUS_PLACEHOLDER = "No status recorded yet.";
15
+ export declare const PHILOSOPHY_BOOTSTRAP = "# Philosophy\n\nWhy this product deserves to exist. Read this before implementing; if a\nrequested change conflicts with this philosophy, run a Koan clarification\nloop instead of silently expanding scope.\n";
16
+ export declare function loadProjectConfig(projectRoot: string): Promise<ProjectConfig | null>;
17
+ export declare function ensureKoanProject(start: string): Promise<ProjectConfig>;
@@ -0,0 +1,126 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { BOOTSTRAP_END, BOOTSTRAP_START, CORE_DOCUMENTS, KOAN_DIR, KOAN_STATE_DIR, KOAN_VERSION, STATE_FILES } from "./constants.js";
4
+ import { defaultKoanGitignore } from "./gitPolicy.js";
5
+ import { withFileLock } from "./lock.js";
6
+ import { DEFAULT_CONVERGENCE_THRESHOLD, ProjectConfigSchema } from "./schemas.js";
7
+ async function exists(path) {
8
+ try {
9
+ await access(path);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ export async function findProjectRoot(start) {
17
+ let current = resolve(start);
18
+ while (true) {
19
+ if ((await exists(join(current, "package.json"))) ||
20
+ (await exists(join(current, ".git"))) ||
21
+ (await exists(join(current, KOAN_DIR)))) {
22
+ return current;
23
+ }
24
+ const parent = dirname(current);
25
+ if (parent === current)
26
+ return resolve(start);
27
+ current = parent;
28
+ }
29
+ }
30
+ export async function inspectProject(start) {
31
+ const projectRoot = await findProjectRoot(start);
32
+ const agentsPath = join(projectRoot, "AGENTS.md");
33
+ const claudePath = join(projectRoot, "CLAUDE.md");
34
+ const hasAgentsMd = await exists(agentsPath);
35
+ const hasClaudeMd = await exists(claudePath);
36
+ const isKoanProject = await exists(join(projectRoot, STATE_FILES.project));
37
+ const bootstrapTargets = [agentsPath, claudePath];
38
+ let hasKoanBootstrap = false;
39
+ for (const target of bootstrapTargets) {
40
+ if (await exists(target)) {
41
+ const text = await readFile(target, "utf8");
42
+ hasKoanBootstrap = hasKoanBootstrap || text.includes(BOOTSTRAP_START);
43
+ }
44
+ }
45
+ return { projectRoot, isKoanProject, hasAgentsMd, hasClaudeMd, hasKoanBootstrap };
46
+ }
47
+ function bootstrapBlock() {
48
+ return [
49
+ BOOTSTRAP_START,
50
+ "Before working in this project, read:",
51
+ "1. koan/philosophy.md if it exists — the product philosophy behind every goal",
52
+ "2. koan/goal.md",
53
+ "3. koan/status.md",
54
+ "4. koan/plan.md",
55
+ "5. koan/handoff.md if continuing prior work",
56
+ "",
57
+ "Stay aligned with Koan documents. If the requested work changes scope,",
58
+ "introduces a new direction, or conflicts with the product philosophy,",
59
+ "record it through `koan bright-idea` and ask the user to run a Koan",
60
+ "clarification loop instead of silently expanding scope.",
61
+ "",
62
+ "For review work, also read koan/qa.md when it exists.",
63
+ BOOTSTRAP_END,
64
+ ""
65
+ ].join("\n");
66
+ }
67
+ export function patchBootstrap(existing) {
68
+ const block = bootstrapBlock();
69
+ const start = existing.indexOf(BOOTSTRAP_START);
70
+ const end = existing.indexOf(BOOTSTRAP_END);
71
+ if (start >= 0 && end > start) {
72
+ return `${existing.slice(0, start)}${block}${existing.slice(end + BOOTSTRAP_END.length).replace(/^\n?/, "")}`;
73
+ }
74
+ return existing.trimEnd().length > 0 ? `${existing.trimEnd()}\n\n${block}` : block;
75
+ }
76
+ async function ensureFile(path, content) {
77
+ if (!(await exists(path))) {
78
+ await mkdir(dirname(path), { recursive: true });
79
+ await writeFile(path, content, "utf8");
80
+ }
81
+ }
82
+ async function patchFile(path) {
83
+ const current = (await exists(path)) ? await readFile(path, "utf8") : "";
84
+ const next = patchBootstrap(current);
85
+ if (next !== current)
86
+ await writeFile(path, next, "utf8");
87
+ }
88
+ export const DEFAULT_ACTIVE_GOAL_PLACEHOLDER = "No active goal yet.";
89
+ export const DEFAULT_PLAN_PLACEHOLDER = "No implementation plan recorded yet.";
90
+ export const DEFAULT_STATUS_PLACEHOLDER = "No status recorded yet.";
91
+ export const PHILOSOPHY_BOOTSTRAP = "# Philosophy\n\nWhy this product deserves to exist. Read this before implementing; if a\nrequested change conflicts with this philosophy, run a Koan clarification\nloop instead of silently expanding scope.\n";
92
+ export async function loadProjectConfig(projectRoot) {
93
+ try {
94
+ const raw = await readFile(join(projectRoot, STATE_FILES.project), "utf8");
95
+ return ProjectConfigSchema.parse(JSON.parse(raw));
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ export async function ensureKoanProject(start) {
102
+ const projectRoot = await findProjectRoot(start);
103
+ return withFileLock(projectRoot, async () => {
104
+ await mkdir(join(projectRoot, KOAN_DIR), { recursive: true });
105
+ await mkdir(join(projectRoot, KOAN_STATE_DIR), { recursive: true });
106
+ await ensureFile(join(projectRoot, CORE_DOCUMENTS.readme), "# Koan Project Memory\n\nRead `philosophy.md` first when it exists, then `goal.md`, `status.md`, and `plan.md`.\n");
107
+ await ensureFile(join(projectRoot, CORE_DOCUMENTS.goal), `# Goal\n\nThe active goal serves the product philosophy in \`philosophy.md\` when it exists.\n\n## Active Goal\n\n<!-- koan:section:start name="active-goal" -->\n${DEFAULT_ACTIVE_GOAL_PLACEHOLDER}\n<!-- koan:section:end name="active-goal" -->\n`);
108
+ await ensureFile(join(projectRoot, CORE_DOCUMENTS.status), `# Status\n\n<!-- koan:section:start name="current-status" -->\n${DEFAULT_STATUS_PLACEHOLDER}\n<!-- koan:section:end name="current-status" -->\n`);
109
+ await ensureFile(join(projectRoot, CORE_DOCUMENTS.plan), `# Plan\n\nImplementation must preserve the product philosophy in \`philosophy.md\` when it exists.\n\n<!-- koan:section:start name="implementation-plan" -->\n${DEFAULT_PLAN_PLACEHOLDER}\n<!-- koan:section:end name="implementation-plan" -->\n`);
110
+ await ensureFile(join(projectRoot, STATE_FILES.gitignore), defaultKoanGitignore());
111
+ await patchFile(join(projectRoot, "AGENTS.md"));
112
+ await patchFile(join(projectRoot, "CLAUDE.md"));
113
+ const existing = await loadProjectConfig(projectRoot);
114
+ const config = {
115
+ version: 1,
116
+ koanVersion: KOAN_VERSION,
117
+ projectRoot,
118
+ strictness: existing?.strictness ?? "advisory",
119
+ experimentalHandoff: existing?.experimentalHandoff ?? false,
120
+ documents: CORE_DOCUMENTS,
121
+ settings: existing?.settings ?? { convergenceThreshold: DEFAULT_CONVERGENCE_THRESHOLD }
122
+ };
123
+ await writeFile(join(projectRoot, STATE_FILES.project), `${JSON.stringify(config, null, 2)}\n`, "utf8");
124
+ return config;
125
+ });
126
+ }
@@ -0,0 +1,6 @@
1
+ import { type HostId } from "./hostAdapter.js";
2
+ export interface QaContext {
3
+ activeGoal: string | null;
4
+ planSection: string | null;
5
+ }
6
+ export declare function buildQaChecklist(context?: QaContext, host?: HostId): string;
@@ -0,0 +1,26 @@
1
+ import { adapterFor } from "./hostAdapter.js";
2
+ import { DEFAULT_ACTIVE_GOAL_PLACEHOLDER, DEFAULT_PLAN_PLACEHOLDER } from "./project.js";
3
+ function stripListMarker(line) {
4
+ return line.replace(/^(?:\d+[.)]\s+|[-*+]\s+(?:\[.\]\s+)?)/, "");
5
+ }
6
+ export function buildQaChecklist(context = { activeGoal: null, planSection: null }, host = "generic") {
7
+ const lines = ["# QA"];
8
+ if (context.activeGoal !== null &&
9
+ context.activeGoal.trim().length > 0 &&
10
+ !context.activeGoal.startsWith(DEFAULT_ACTIVE_GOAL_PLACEHOLDER)) {
11
+ lines.push("", "## Active Goal Under Review", "", context.activeGoal);
12
+ }
13
+ lines.push("", "## Spec Compliance", "", "- Does the implementation follow `koan/goal.md`?", "- Does the implementation follow `koan/plan.md`?", "- Are scope changes recorded through `koan bright-idea`?", "", "## Philosophy Alignment", "", "- Does the implementation preserve the product philosophy in `koan/philosophy.md` (when it exists)?", "- Did any tradeoff quietly betray the stated core value or non-goals?", "", "## General Quality", "", "- Are targeted tests present?", "- Are destructive operations avoided?", "- Are privacy and local-first assumptions preserved?", "");
14
+ if (context.planSection !== null && !context.planSection.startsWith(DEFAULT_PLAN_PLACEHOLDER)) {
15
+ const checks = context.planSection
16
+ .split("\n")
17
+ .map((line) => line.trim())
18
+ .filter((line) => line.length > 0)
19
+ .map((line) => `- [ ] ${stripListMarker(line)}`);
20
+ if (checks.length > 0) {
21
+ lines.push("## Plan-Derived Checks", "", ...checks, "");
22
+ }
23
+ }
24
+ lines.push("## MCP Host Agent Prompt", "", adapterFor(host).qaPrompt, "");
25
+ return lines.join("\n");
26
+ }
@@ -0,0 +1,10 @@
1
+ import { type HostId } from "./hostAdapter.js";
2
+ import type { AmbiguityAxis, UserProfile } from "./schemas.js";
3
+ export interface KoanQuestion {
4
+ axis: AmbiguityAxis;
5
+ intent: string;
6
+ userFacingQuestion: string;
7
+ answerSchema: "free_text";
8
+ hostAgentInstruction: string;
9
+ }
10
+ export declare function getQuestion(axis: AmbiguityAxis, profile: UserProfile, host?: HostId): KoanQuestion;