@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,124 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { CORE_DOCUMENTS, LAZY_DOCUMENTS } from "./constants.js";
4
+ import { executeWritePlan, sanitizeRegionContent } from "./documents.js";
5
+ import { defaultProfile, loadProfile } from "./profile.js";
6
+ import { PHILOSOPHY_BOOTSTRAP, findProjectRoot, loadProjectConfig } from "./project.js";
7
+ import { getQuestion } from "./questions.js";
8
+ import { DEFAULT_CONVERGENCE_THRESHOLD } from "./schemas.js";
9
+ import { createInitialLedger, loadLedger, unresolvedAxes } from "./scoring.js";
10
+ import { loadSessionState } from "./session.js";
11
+ // Deterministic mapping from answered axes to managed regions. goal.md and
12
+ // plan.md always exist after hello; lazy documents are bootstrapped with their
13
+ // H1 the first time an answer lands in them.
14
+ const AXIS_TARGETS = [
15
+ { axis: "current_goal", path: CORE_DOCUMENTS.goal, region: "active-goal" },
16
+ { axis: "purpose", path: CORE_DOCUMENTS.goal, region: "purpose" },
17
+ { axis: "target_users", path: CORE_DOCUMENTS.goal, region: "target-users" },
18
+ { axis: "scope", path: CORE_DOCUMENTS.goal, region: "scope" },
19
+ { axis: "non_goals", path: CORE_DOCUMENTS.goal, region: "non-goals" },
20
+ { axis: "success_criteria", path: CORE_DOCUMENTS.goal, region: "success-criteria" },
21
+ { axis: "constraints", path: CORE_DOCUMENTS.goal, region: "constraints" },
22
+ { axis: "implementation_plan", path: CORE_DOCUMENTS.plan, region: "implementation-plan" },
23
+ {
24
+ axis: "philosophical_intent",
25
+ path: LAZY_DOCUMENTS.philosophy,
26
+ region: "philosophy",
27
+ bootstrapHeader: PHILOSOPHY_BOOTSTRAP
28
+ },
29
+ { axis: "qa_criteria", path: LAZY_DOCUMENTS.qa, region: "qa-criteria", bootstrapHeader: "# QA\n" },
30
+ { axis: "handoff_readiness", path: LAZY_DOCUMENTS.handoff, region: "handoff-context", bootstrapHeader: "# Handoff\n" }
31
+ ];
32
+ async function exists(path) {
33
+ try {
34
+ await access(path);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ export async function crystallize(input) {
42
+ const projectRoot = await findProjectRoot(input.cwd);
43
+ const state = await loadSessionState(projectRoot);
44
+ if (!state)
45
+ throw new Error("No active Koan session. Run koan hello first.");
46
+ if (!state.activeGoalId || state.phase === "archived") {
47
+ throw new Error("No active goal. Run koan hello first.");
48
+ }
49
+ const isoDate = input.isoDate ?? new Date().toISOString();
50
+ const description = "Crystallize recorded answers into project documents";
51
+ const latestAnswers = new Map();
52
+ for (const answer of state.answers) {
53
+ latestAnswers.set(answer.axis, answer.answer);
54
+ }
55
+ const targets = AXIS_TARGETS.filter((target) => latestAnswers.has(target.axis));
56
+ const crystallizedAxes = targets.map((target) => target.axis);
57
+ if (crystallizedAxes.length === 0) {
58
+ return {
59
+ projectRoot,
60
+ plan: { description, operations: [] },
61
+ executed: false,
62
+ files: [],
63
+ crystallizedAxes: []
64
+ };
65
+ }
66
+ const operations = [];
67
+ for (const target of targets) {
68
+ if (target.bootstrapHeader && !(await exists(join(projectRoot, target.path)))) {
69
+ operations.push({ type: "write", path: target.path, content: target.bootstrapHeader });
70
+ }
71
+ operations.push({
72
+ type: "managed-region",
73
+ path: target.path,
74
+ name: target.region,
75
+ content: sanitizeRegionContent((latestAnswers.get(target.axis) ?? "").trim())
76
+ });
77
+ }
78
+ const stored = await loadLedger(projectRoot);
79
+ const ledger = stored && stored.goalId === state.activeGoalId ? stored : createInitialLedger(state.activeGoalId, isoDate);
80
+ const threshold = (await loadProjectConfig(projectRoot))?.settings.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD;
81
+ const unresolved = unresolvedAxes(ledger, threshold);
82
+ const hasOpenQuestionsFile = await exists(join(projectRoot, LAZY_DOCUMENTS.openQuestions));
83
+ if (unresolved.length > 0) {
84
+ const profile = (await loadProfile(input.homeDir)) ?? defaultProfile();
85
+ if (!hasOpenQuestionsFile) {
86
+ operations.push({ type: "write", path: LAZY_DOCUMENTS.openQuestions, content: "# Open Questions\n" });
87
+ }
88
+ operations.push({
89
+ type: "managed-region",
90
+ path: LAZY_DOCUMENTS.openQuestions,
91
+ name: "open-questions",
92
+ content: unresolved
93
+ .map((axis) => `- ${axis}: ${getQuestion(axis, profile).userFacingQuestion}`)
94
+ .join("\n")
95
+ });
96
+ }
97
+ else if (hasOpenQuestionsFile) {
98
+ operations.push({
99
+ type: "managed-region",
100
+ path: LAZY_DOCUMENTS.openQuestions,
101
+ name: "open-questions",
102
+ content: "None."
103
+ });
104
+ }
105
+ operations.push({
106
+ type: "append",
107
+ path: LAZY_DOCUMENTS.decisions,
108
+ content: `## ${isoDate} — koan crystallize\n\nCrystallized axes: ${crystallizedAxes.join(", ")}.`,
109
+ headerIfMissing: "# Decisions"
110
+ });
111
+ const plan = { description, operations };
112
+ const files = [];
113
+ for (const operation of operations) {
114
+ if (!files.includes(operation.path))
115
+ files.push(operation.path);
116
+ }
117
+ if (input.dryRun) {
118
+ return { projectRoot, plan, executed: false, files, crystallizedAxes };
119
+ }
120
+ await executeWritePlan(projectRoot, plan, {
121
+ log: { command: "koan crystallize", summary: `Crystallized ${crystallizedAxes.length} axes.` }
122
+ });
123
+ return { projectRoot, plan, executed: true, files, crystallizedAxes };
124
+ }
@@ -0,0 +1,11 @@
1
+ import { type CommandLogInput } from "./commandLog.js";
2
+ import { type WritePlan } from "./schemas.js";
3
+ export interface WritePlanOptions {
4
+ log?: CommandLogInput;
5
+ }
6
+ export declare function replaceManagedRegion(input: string, name: string, content: string): string;
7
+ export declare function readManagedSection(text: string, name: string): string | null;
8
+ export declare function appendLogEntry(input: string, source: string, content: string, isoDate?: string): string;
9
+ export declare function buildManagedRegion(name: string, content: string): string;
10
+ export declare function sanitizeRegionContent(text: string): string;
11
+ export declare function executeWritePlan(projectRoot: string, plan: WritePlan, options?: WritePlanOptions): Promise<void>;
@@ -0,0 +1,72 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { appendCommandLogInLock } from "./commandLog.js";
4
+ import { managedEnd, managedStart } from "./constants.js";
5
+ import { withFileLock } from "./lock.js";
6
+ async function readExisting(path) {
7
+ try {
8
+ return await readFile(path, "utf8");
9
+ }
10
+ catch {
11
+ return "";
12
+ }
13
+ }
14
+ export function replaceManagedRegion(input, name, content) {
15
+ const startMarker = managedStart(name);
16
+ const endMarker = managedEnd(name);
17
+ const start = input.indexOf(startMarker);
18
+ const end = input.indexOf(endMarker);
19
+ const block = `${startMarker}\n${content.trimEnd()}\n${endMarker}`;
20
+ if (start >= 0 && end > start) {
21
+ return `${input.slice(0, start)}${block}${input.slice(end + endMarker.length)}`;
22
+ }
23
+ const prefix = input.trimEnd().length > 0 ? `${input.trimEnd()}\n\n` : "";
24
+ return `${prefix}${block}\n`;
25
+ }
26
+ export function readManagedSection(text, name) {
27
+ const startMarker = managedStart(name);
28
+ const endMarker = managedEnd(name);
29
+ const start = text.indexOf(startMarker);
30
+ const end = text.indexOf(endMarker);
31
+ if (start < 0 || end <= start)
32
+ return null;
33
+ return text.slice(start + startMarker.length, end).trim();
34
+ }
35
+ export function appendLogEntry(input, source, content, isoDate = new Date().toISOString()) {
36
+ const entry = [`## ${isoDate} — ${source}`, "", content.trimEnd(), ""].join("\n");
37
+ return `${input.trimEnd()}\n\n${entry}`;
38
+ }
39
+ export function buildManagedRegion(name, content) {
40
+ return `${managedStart(name)}\n${content.trimEnd()}\n${managedEnd(name)}`;
41
+ }
42
+ export function sanitizeRegionContent(text) {
43
+ return text.replaceAll("<!-- koan:", "<!- koan:");
44
+ }
45
+ export async function executeWritePlan(projectRoot, plan, options = {}) {
46
+ await withFileLock(projectRoot, async () => {
47
+ for (const operation of plan.operations) {
48
+ const absolute = join(projectRoot, operation.path);
49
+ await mkdir(dirname(absolute), { recursive: true });
50
+ if (operation.type === "write") {
51
+ await writeFile(absolute, operation.content, "utf8");
52
+ }
53
+ if (operation.type === "append") {
54
+ const current = await readExisting(absolute);
55
+ const base = current.trimEnd();
56
+ const prefix = base.length > 0
57
+ ? `${base}\n\n`
58
+ : operation.headerIfMissing
59
+ ? `${operation.headerIfMissing.trimEnd()}\n\n`
60
+ : "";
61
+ await writeFile(absolute, `${prefix}${operation.content.trimEnd()}\n`, "utf8");
62
+ }
63
+ if (operation.type === "managed-region") {
64
+ const current = await readExisting(absolute);
65
+ await writeFile(absolute, replaceManagedRegion(current, operation.name, operation.content), "utf8");
66
+ }
67
+ }
68
+ if (options.log) {
69
+ await appendCommandLogInLock(projectRoot, options.log);
70
+ }
71
+ });
72
+ }
@@ -0,0 +1,2 @@
1
+ export declare function defaultKoanGitignore(): string;
2
+ export declare function ensureStateGitignore(projectRoot: string): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import { access, mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { STATE_FILES } from "./constants.js";
4
+ export function defaultKoanGitignore() {
5
+ return [
6
+ "user-profile-ref.json",
7
+ "session-state.json",
8
+ "ambiguity-ledger.json",
9
+ "command-log.json",
10
+ "mcp-cache.json",
11
+ "write.lock*",
12
+ "*.bak",
13
+ ""
14
+ ].join("\n");
15
+ }
16
+ export async function ensureStateGitignore(projectRoot) {
17
+ const path = join(projectRoot, STATE_FILES.gitignore);
18
+ try {
19
+ await access(path);
20
+ }
21
+ catch {
22
+ await mkdir(dirname(path), { recursive: true });
23
+ await writeFile(path, defaultKoanGitignore(), "utf8");
24
+ }
25
+ }
@@ -0,0 +1,5 @@
1
+ export interface HandoffDocumentInput {
2
+ summary: string;
3
+ experimentalHandoff: boolean;
4
+ }
5
+ export declare function buildHandoffDocument(input: HandoffDocumentInput): string;
@@ -0,0 +1,20 @@
1
+ export function buildHandoffDocument(input) {
2
+ const experimentalStatus = input.experimentalHandoff
3
+ ? "MVP status: extension flag enabled, but no touchless adapter is implemented."
4
+ : "MVP status: disabled. This handoff is document-based.";
5
+ return [
6
+ "# Handoff",
7
+ "",
8
+ "Read `koan/philosophy.md` first when it exists. Keep the continuation",
9
+ "aligned with the product philosophy, not just the remaining task list.",
10
+ "",
11
+ "## Current Summary",
12
+ "",
13
+ input.summary,
14
+ "",
15
+ "## Experimental Touchless Handoff",
16
+ "",
17
+ experimentalStatus,
18
+ ""
19
+ ].join("\n");
20
+ }
@@ -0,0 +1,8 @@
1
+ export type HostId = "claude" | "codex" | "generic";
2
+ export interface HostAdapter {
3
+ questionInstruction: string;
4
+ qaPrompt: string;
5
+ prdSynthesisInstruction: string;
6
+ }
7
+ export declare function detectHost(clientName?: string): HostId;
8
+ export declare function adapterFor(host: HostId): HostAdapter;
@@ -0,0 +1,34 @@
1
+ // Host adapters tune how Koan speaks to the host agent, not what it says:
2
+ // every variant must carry the same contract (preserve user reasoning,
3
+ // structured answer recording, philosophy-aware QA, no invented requirements)
4
+ // in the phrasing that works best for that model family. The variants are
5
+ // static strings — no network, no model calls. See docs/host-adapters.md for
6
+ // the vendor-guide rationale behind each variant.
7
+ export function detectHost(clientName) {
8
+ const name = clientName?.toLowerCase() ?? "";
9
+ if (name.includes("claude"))
10
+ return "claude";
11
+ if (name.includes("codex") || name.includes("openai") || name.includes("gpt"))
12
+ return "codex";
13
+ return "generic";
14
+ }
15
+ const adapters = {
16
+ generic: {
17
+ questionInstruction: "Preserve the user's reasoning. If using MCP mode, structure the answer into decision, reasoning, constraints, out-of-scope, and project context before recording it. If the answer reveals that the real product differs from the surface request, record that realization with koan_record_insight.",
18
+ qaPrompt: "Compare the implementation summary against Koan documents, including `koan/philosophy.md` when it exists. Separate Koan-spec compliance issues, philosophy-alignment issues, and general quality issues.",
19
+ prdSynthesisInstruction: "Synthesize this section from the recorded answers and `koan/philosophy.md` only; do not invent requirements the user never stated. Write the result with koan_synthesize_prd."
20
+ },
21
+ claude: {
22
+ questionInstruction: "Preserve the user's exact reasoning — quote their key phrases instead of paraphrasing them away. Before recording, structure the answer as: decision, reasoning, constraints, out-of-scope, and project context. Consider what the answer implies before scoring clarity. If the answer reveals that the real product differs from the surface request, record that realization with koan_record_insight before asking the next question.",
23
+ qaPrompt: "Review the implementation against the Koan documents, reading `koan/philosophy.md` first when it exists. Report findings in three separate groups — Koan-spec compliance issues, philosophy-alignment issues, and general quality issues — and cite the document passage each finding violates.",
24
+ prdSynthesisInstruction: "Synthesize this section strictly from the recorded answers and `koan/philosophy.md`: ground every sentence in something the user actually said and prefer their own wording. Do not invent requirements the user never stated. Write the result with koan_synthesize_prd."
25
+ },
26
+ codex: {
27
+ questionInstruction: "Preserve the user's reasoning verbatim where possible. Record the answer structured as: decision; reasoning; constraints; out-of-scope; project context. Score clarity conservatively. If the answer reveals that the real product differs from the surface request, call koan_record_insight with that realization.",
28
+ qaPrompt: "Compare the implementation against Koan documents; read `koan/philosophy.md` first when it exists. Output three lists: Koan-spec compliance issues; philosophy-alignment issues; general quality issues. Review only — do not fix.",
29
+ prdSynthesisInstruction: "Synthesize from the recorded answers and `koan/philosophy.md` only. Do not invent requirements the user never stated. Keep the user's wording. Write the result with koan_synthesize_prd."
30
+ }
31
+ };
32
+ export function adapterFor(host) {
33
+ return adapters[host];
34
+ }
@@ -0,0 +1,5 @@
1
+ export declare const LOCK_STALE_MS: number;
2
+ export declare class KoanLockError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export declare function withFileLock<T>(projectRoot: string, fn: () => Promise<T>): Promise<T>;
@@ -0,0 +1,142 @@
1
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { STATE_FILES } from "./constants.js";
5
+ export const LOCK_STALE_MS = 10 * 60 * 1000;
6
+ export class KoanLockError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "KoanLockError";
10
+ }
11
+ }
12
+ function lockPayload(token) {
13
+ return `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), token })}\n`;
14
+ }
15
+ function parseLockInfo(raw) {
16
+ try {
17
+ const parsed = JSON.parse(raw);
18
+ if (typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string")
19
+ return null;
20
+ if (Number.isNaN(Date.parse(parsed.createdAt)))
21
+ return null;
22
+ return {
23
+ pid: parsed.pid,
24
+ createdAt: parsed.createdAt,
25
+ token: typeof parsed.token === "string" ? parsed.token : null
26
+ };
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function isPidAlive(pid) {
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ }
37
+ catch (error) {
38
+ return error.code === "EPERM";
39
+ }
40
+ }
41
+ async function inspectLock(lockPath) {
42
+ let raw;
43
+ try {
44
+ raw = await readFile(lockPath, "utf8");
45
+ }
46
+ catch {
47
+ return { stale: true, holderPid: null, raw: null };
48
+ }
49
+ const info = parseLockInfo(raw);
50
+ if (info) {
51
+ const expired = Date.now() - Date.parse(info.createdAt) > LOCK_STALE_MS;
52
+ return { stale: !isPidAlive(info.pid) || expired, holderPid: info.pid, raw };
53
+ }
54
+ try {
55
+ const stats = await stat(lockPath);
56
+ return { stale: Date.now() - stats.mtimeMs > LOCK_STALE_MS, holderPid: null, raw };
57
+ }
58
+ catch {
59
+ return { stale: true, holderPid: null, raw };
60
+ }
61
+ }
62
+ async function tryAcquire(lockPath, token) {
63
+ try {
64
+ await writeFile(lockPath, lockPayload(token), { encoding: "utf8", flag: "wx" });
65
+ return true;
66
+ }
67
+ catch (error) {
68
+ if (error.code === "EEXIST")
69
+ return false;
70
+ throw error;
71
+ }
72
+ }
73
+ async function reclaimStaleLock(lockPath, expectedRaw) {
74
+ // rename is atomic: of N processes that judged the same lock stale, only
75
+ // the rename winner proceeds; losers fall through to the single retry.
76
+ const reclaimPath = `${lockPath}.reclaim-${process.pid}-${randomUUID()}`;
77
+ try {
78
+ await rename(lockPath, reclaimPath);
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ let reclaimedRaw = null;
84
+ try {
85
+ reclaimedRaw = await readFile(reclaimPath, "utf8");
86
+ }
87
+ catch {
88
+ reclaimedRaw = null;
89
+ }
90
+ if (reclaimedRaw !== null && reclaimedRaw !== expectedRaw) {
91
+ // The lock changed between inspection and rename: a fresh holder was
92
+ // displaced. Restore it; if restore loses a race to a newer acquirer,
93
+ // the displaced holder's release is token-checked and harmless.
94
+ try {
95
+ await rename(reclaimPath, lockPath);
96
+ return;
97
+ }
98
+ catch {
99
+ // fall through to remove the displaced copy
100
+ }
101
+ }
102
+ await rm(reclaimPath, { force: true });
103
+ }
104
+ async function releaseLock(lockPath, token) {
105
+ let raw;
106
+ try {
107
+ raw = await readFile(lockPath, "utf8");
108
+ }
109
+ catch {
110
+ return;
111
+ }
112
+ if (parseLockInfo(raw)?.token === token) {
113
+ await rm(lockPath, { force: true });
114
+ }
115
+ }
116
+ // Advisory single-writer lock (spec §6.4): stop-on-conflict guidance for a
117
+ // one-writer-at-a-time model, not contention-grade mutual exclusion. Without
118
+ // OS-level flock a microsecond window remains between staleness inspection
119
+ // and reclaim/release; reclaim verifies the displaced payload and restores
120
+ // fresh locks, and release only removes a lock holding its own token.
121
+ export async function withFileLock(projectRoot, fn) {
122
+ const lockPath = join(projectRoot, STATE_FILES.lock);
123
+ await mkdir(dirname(lockPath), { recursive: true });
124
+ const token = randomUUID();
125
+ if (!(await tryAcquire(lockPath, token))) {
126
+ const inspection = await inspectLock(lockPath);
127
+ if (!inspection.stale) {
128
+ const holder = inspection.holderPid === null ? "another process" : `pid ${inspection.holderPid}`;
129
+ throw new KoanLockError(`Koan write lock at ${STATE_FILES.lock} is held by ${holder}. Remove ${STATE_FILES.lock} if no Koan process is running.`);
130
+ }
131
+ await reclaimStaleLock(lockPath, inspection.raw);
132
+ if (!(await tryAcquire(lockPath, token))) {
133
+ throw new KoanLockError(`Koan write lock already exists at ${STATE_FILES.lock}`);
134
+ }
135
+ }
136
+ try {
137
+ return await fn();
138
+ }
139
+ finally {
140
+ await releaseLock(lockPath, token);
141
+ }
142
+ }
@@ -0,0 +1,4 @@
1
+ import { type McpCache } from "./schemas.js";
2
+ export declare function loadMcpCache(projectRoot: string): Promise<McpCache>;
3
+ export declare function saveMcpCache(projectRoot: string, cache: McpCache): Promise<void>;
4
+ export declare function updateMcpCache(projectRoot: string, mutate: (cache: McpCache) => McpCache): Promise<McpCache>;
@@ -0,0 +1,37 @@
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 { McpCacheSchema } from "./schemas.js";
6
+ import { withFileLock } from "./lock.js";
7
+ function freshMcpCache() {
8
+ return { version: 1, lastQuestion: null, rawIntent: null };
9
+ }
10
+ export async function loadMcpCache(projectRoot) {
11
+ try {
12
+ const raw = await readFile(join(projectRoot, STATE_FILES.mcpCache), "utf8");
13
+ return McpCacheSchema.parse(JSON.parse(raw));
14
+ }
15
+ catch {
16
+ return freshMcpCache();
17
+ }
18
+ }
19
+ // Caller must hold the project write lock.
20
+ async function writeMcpCacheUnlocked(projectRoot, cache) {
21
+ const parsed = McpCacheSchema.parse(cache);
22
+ const path = join(projectRoot, STATE_FILES.mcpCache);
23
+ await ensureStateGitignore(projectRoot);
24
+ await mkdir(dirname(path), { recursive: true });
25
+ await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
26
+ return parsed;
27
+ }
28
+ export async function saveMcpCache(projectRoot, cache) {
29
+ await withFileLock(projectRoot, async () => {
30
+ await writeMcpCacheUnlocked(projectRoot, cache);
31
+ });
32
+ }
33
+ // Atomic read-modify-write: load, mutate, and persist happen inside a single
34
+ // file lock so concurrent tool calls cannot interleave between read and write.
35
+ export async function updateMcpCache(projectRoot, mutate) {
36
+ return withFileLock(projectRoot, async () => writeMcpCacheUnlocked(projectRoot, mutate(await loadMcpCache(projectRoot))));
37
+ }
@@ -0,0 +1,33 @@
1
+ import { type HostId } from "./hostAdapter.js";
2
+ import { type AmbiguityAxis, type WritePlan } from "./schemas.js";
3
+ export interface PrdHostSections {
4
+ vision?: string;
5
+ coreValue?: string;
6
+ problemAntiProblem?: string;
7
+ userStories?: string;
8
+ }
9
+ export interface BuildPrdInput {
10
+ cwd: string;
11
+ homeDir: string;
12
+ sections?: PrdHostSections;
13
+ host?: HostId;
14
+ dryRun?: boolean;
15
+ isoDate?: string;
16
+ }
17
+ export interface BuildPrdResult {
18
+ projectRoot: string;
19
+ path: string;
20
+ plan: WritePlan;
21
+ executed: boolean;
22
+ document: string | null;
23
+ }
24
+ interface PrdSection {
25
+ region: string;
26
+ title: string;
27
+ axis?: AmbiguityAxis;
28
+ hostKey?: keyof PrdHostSections;
29
+ }
30
+ export declare const PRD_SECTIONS: readonly PrdSection[];
31
+ export declare function parseInsights(philosophyText: string): string[];
32
+ export declare function buildPrd(input: BuildPrdInput): Promise<BuildPrdResult>;
33
+ export {};