@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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { STATE_FILES } from "./constants.js";
|
|
2
|
+
import { executeWritePlan } from "./documents.js";
|
|
3
|
+
import { defaultProfile, loadProfile } from "./profile.js";
|
|
4
|
+
import { findProjectRoot, loadProjectConfig } from "./project.js";
|
|
5
|
+
import { getQuestion } from "./questions.js";
|
|
6
|
+
import { AmbiguityAxisSchema, DEFAULT_CONVERGENCE_THRESHOLD } from "./schemas.js";
|
|
7
|
+
import { ANSWERED_CLARITY, createInitialLedger, isConverged, loadLedger, selectMostUnclearAxis, unresolvedAxes, updateAxisScore } from "./scoring.js";
|
|
8
|
+
import { loadSessionState } from "./session.js";
|
|
9
|
+
const EVIDENCE_PREVIEW_LIMIT = 120;
|
|
10
|
+
export async function recordAnswer(input) {
|
|
11
|
+
const projectRoot = await findProjectRoot(input.cwd);
|
|
12
|
+
const state = await loadSessionState(projectRoot);
|
|
13
|
+
if (!state)
|
|
14
|
+
throw new Error("No active Koan session. Run koan hello first.");
|
|
15
|
+
if (!state.activeGoalId || state.phase === "archived") {
|
|
16
|
+
throw new Error("No active goal. Run koan hello first.");
|
|
17
|
+
}
|
|
18
|
+
if (input.clarity !== undefined && (!Number.isFinite(input.clarity) || input.clarity < 0 || input.clarity > 1)) {
|
|
19
|
+
throw new Error("clarity must be a finite number between 0 and 1.");
|
|
20
|
+
}
|
|
21
|
+
const axis = AmbiguityAxisSchema.parse(input.axis);
|
|
22
|
+
const profile = (await loadProfile(input.homeDir)) ?? defaultProfile();
|
|
23
|
+
const isoDate = input.isoDate ?? new Date().toISOString();
|
|
24
|
+
const stored = await loadLedger(projectRoot);
|
|
25
|
+
const ledger = stored && stored.goalId === state.activeGoalId
|
|
26
|
+
? stored
|
|
27
|
+
: createInitialLedger(state.activeGoalId, isoDate);
|
|
28
|
+
const trimmed = input.answer.trim();
|
|
29
|
+
const clarity = input.clarity ?? (trimmed.length > 0 ? ANSWERED_CLARITY : 0);
|
|
30
|
+
const updatedLedger = updateAxisScore(ledger, axis, clarity, Array.from(trimmed).slice(0, EVIDENCE_PREVIEW_LIMIT).join(""), isoDate);
|
|
31
|
+
const answer = {
|
|
32
|
+
questionId: axis,
|
|
33
|
+
axis,
|
|
34
|
+
question: input.question ?? getQuestion(axis, profile).userFacingQuestion,
|
|
35
|
+
answer: input.answer,
|
|
36
|
+
recordedAt: isoDate
|
|
37
|
+
};
|
|
38
|
+
const threshold = (await loadProjectConfig(projectRoot))?.settings.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD;
|
|
39
|
+
const converged = isConverged(updatedLedger, threshold);
|
|
40
|
+
const nextState = {
|
|
41
|
+
...state,
|
|
42
|
+
answers: [...state.answers, answer],
|
|
43
|
+
lastQuestionId: axis,
|
|
44
|
+
phase: converged ? "ready" : "questioning",
|
|
45
|
+
updatedAt: isoDate
|
|
46
|
+
};
|
|
47
|
+
const source = input.source?.trim();
|
|
48
|
+
await executeWritePlan(projectRoot, {
|
|
49
|
+
description: "Persist recorded answer and ambiguity ledger",
|
|
50
|
+
operations: [
|
|
51
|
+
{ type: "write", path: STATE_FILES.sessionState, content: `${JSON.stringify(nextState, null, 2)}\n` },
|
|
52
|
+
{ type: "write", path: STATE_FILES.ambiguityLedger, content: `${JSON.stringify(updatedLedger, null, 2)}\n` }
|
|
53
|
+
]
|
|
54
|
+
}, {
|
|
55
|
+
log: {
|
|
56
|
+
command: "koan answer",
|
|
57
|
+
summary: source ? `Recorded answer for ${axis} (source: ${source}).` : `Recorded answer for ${axis}.`
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
projectRoot,
|
|
62
|
+
ledger: updatedLedger,
|
|
63
|
+
answer,
|
|
64
|
+
converged,
|
|
65
|
+
unresolved: unresolvedAxes(updatedLedger, threshold),
|
|
66
|
+
nextQuestion: converged ? null : getQuestion(selectMostUnclearAxis(updatedLedger), profile)
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export async function acceptClarity(input) {
|
|
70
|
+
const projectRoot = await findProjectRoot(input.cwd);
|
|
71
|
+
const state = await loadSessionState(projectRoot);
|
|
72
|
+
if (!state)
|
|
73
|
+
throw new Error("No active Koan session. Run koan hello first.");
|
|
74
|
+
if (!state.activeGoalId || state.phase === "archived") {
|
|
75
|
+
throw new Error("No active goal. Run koan hello first.");
|
|
76
|
+
}
|
|
77
|
+
const isoDate = input.isoDate ?? new Date().toISOString();
|
|
78
|
+
const nextState = { ...state, phase: "ready", updatedAt: isoDate };
|
|
79
|
+
await executeWritePlan(projectRoot, {
|
|
80
|
+
description: "Accept current clarity as enough",
|
|
81
|
+
operations: [
|
|
82
|
+
{ type: "write", path: STATE_FILES.sessionState, content: `${JSON.stringify(nextState, null, 2)}\n` }
|
|
83
|
+
]
|
|
84
|
+
}, { log: { command: "koan enough", summary: "Accepted current clarity as enough." } });
|
|
85
|
+
return { projectRoot };
|
|
86
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type CommandLog } from "./schemas.js";
|
|
2
|
+
export interface CommandLogInput {
|
|
3
|
+
command: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function loadCommandLog(projectRoot: string): Promise<CommandLog>;
|
|
7
|
+
export declare function appendCommandLogInLock(projectRoot: string, entry: CommandLogInput, isoDate?: string): Promise<CommandLog>;
|
|
8
|
+
export declare function appendCommandLog(projectRoot: string, entry: CommandLogInput, isoDate?: string): Promise<CommandLog>;
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { CommandLogEntrySchema, CommandLogSchema } from "./schemas.js";
|
|
6
|
+
import { withFileLock } from "./lock.js";
|
|
7
|
+
const COMMAND_LOG_LIMIT = 500;
|
|
8
|
+
function freshCommandLog() {
|
|
9
|
+
return { version: 1, entries: [] };
|
|
10
|
+
}
|
|
11
|
+
export async function loadCommandLog(projectRoot) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await readFile(join(projectRoot, STATE_FILES.commandLog), "utf8");
|
|
14
|
+
return CommandLogSchema.parse(JSON.parse(raw));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return freshCommandLog();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Caller must hold the project write lock.
|
|
21
|
+
export async function appendCommandLogInLock(projectRoot, entry, isoDate = new Date().toISOString()) {
|
|
22
|
+
const path = join(projectRoot, STATE_FILES.commandLog);
|
|
23
|
+
const parsed = CommandLogEntrySchema.parse({ at: isoDate, command: entry.command, summary: entry.summary });
|
|
24
|
+
let current = freshCommandLog();
|
|
25
|
+
let raw = null;
|
|
26
|
+
try {
|
|
27
|
+
raw = await readFile(path, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
raw = null;
|
|
31
|
+
}
|
|
32
|
+
if (raw !== null) {
|
|
33
|
+
try {
|
|
34
|
+
current = CommandLogSchema.parse(JSON.parse(raw));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
await writeFile(`${path}.bak`, raw, "utf8");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const next = {
|
|
41
|
+
version: 1,
|
|
42
|
+
entries: [...current.entries, parsed].slice(-COMMAND_LOG_LIMIT)
|
|
43
|
+
};
|
|
44
|
+
await ensureStateGitignore(projectRoot);
|
|
45
|
+
await mkdir(dirname(path), { recursive: true });
|
|
46
|
+
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
49
|
+
export async function appendCommandLog(projectRoot, entry, isoDate = new Date().toISOString()) {
|
|
50
|
+
return withFileLock(projectRoot, () => appendCommandLogInLock(projectRoot, entry, isoDate));
|
|
51
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type HostId } from "./hostAdapter.js";
|
|
2
|
+
import { type KoanQuestion } from "./questions.js";
|
|
3
|
+
import { type AmbiguityAxis, type AnswerRecord } from "./schemas.js";
|
|
4
|
+
export interface HelloResult {
|
|
5
|
+
projectRoot: string;
|
|
6
|
+
resumed: boolean;
|
|
7
|
+
activeGoalId: string | null;
|
|
8
|
+
lastAnswer: AnswerRecord | null;
|
|
9
|
+
unresolved: AmbiguityAxis[];
|
|
10
|
+
converged: boolean;
|
|
11
|
+
reconstructed: boolean;
|
|
12
|
+
nextQuestion: KoanQuestion | null;
|
|
13
|
+
}
|
|
14
|
+
export declare function hello(input: {
|
|
15
|
+
cwd: string;
|
|
16
|
+
homeDir: string;
|
|
17
|
+
}): Promise<HelloResult>;
|
|
18
|
+
export declare function status(input: {
|
|
19
|
+
cwd: string;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
summary: string;
|
|
22
|
+
didWrite: boolean;
|
|
23
|
+
nextAction: string;
|
|
24
|
+
staleWarnings: string[];
|
|
25
|
+
}>;
|
|
26
|
+
export interface UpdateStatusInput {
|
|
27
|
+
cwd: string;
|
|
28
|
+
update: string;
|
|
29
|
+
isoDate?: string;
|
|
30
|
+
source?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function updateStatus(input: UpdateStatusInput): Promise<{
|
|
33
|
+
projectRoot: string;
|
|
34
|
+
}>;
|
|
35
|
+
export declare function archive(input: {
|
|
36
|
+
cwd: string;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
archivedGoalId: string;
|
|
39
|
+
}>;
|
|
40
|
+
export type BrightIdeaClassification = "clarify" | "change-goal" | "later-follow-up" | "reject";
|
|
41
|
+
export declare function brightIdea(input: {
|
|
42
|
+
cwd: string;
|
|
43
|
+
idea: string;
|
|
44
|
+
classification?: BrightIdeaClassification;
|
|
45
|
+
}): Promise<{
|
|
46
|
+
classification: BrightIdeaClassification;
|
|
47
|
+
recommendation: string;
|
|
48
|
+
}>;
|
|
49
|
+
export declare function recordInsight(input: {
|
|
50
|
+
cwd: string;
|
|
51
|
+
text: string;
|
|
52
|
+
isoDate?: string;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
projectRoot: string;
|
|
55
|
+
path: string;
|
|
56
|
+
}>;
|
|
57
|
+
export declare function qa(input: {
|
|
58
|
+
cwd: string;
|
|
59
|
+
implementationSummary?: string;
|
|
60
|
+
host?: HostId;
|
|
61
|
+
}): Promise<{
|
|
62
|
+
projectRoot: string;
|
|
63
|
+
checklist: string;
|
|
64
|
+
}>;
|
|
65
|
+
export declare function handoff(input: {
|
|
66
|
+
cwd: string;
|
|
67
|
+
summary: string;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
projectRoot: string;
|
|
70
|
+
document: string;
|
|
71
|
+
}>;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CORE_DOCUMENTS, LAZY_DOCUMENTS, STATE_FILES } from "./constants.js";
|
|
4
|
+
import { loadCommandLog } from "./commandLog.js";
|
|
5
|
+
import { executeWritePlan, readManagedSection, sanitizeRegionContent } from "./documents.js";
|
|
6
|
+
import { buildHandoffDocument } from "./handoff.js";
|
|
7
|
+
import { PHILOSOPHY_BOOTSTRAP, ensureKoanProject, findProjectRoot, loadProjectConfig } from "./project.js";
|
|
8
|
+
import { ensureProfileRef } from "./profileRef.js";
|
|
9
|
+
import { buildQaChecklist } from "./qa.js";
|
|
10
|
+
import { defaultProfile, loadProfile, saveProfile } from "./profile.js";
|
|
11
|
+
import { getQuestion } from "./questions.js";
|
|
12
|
+
import { reconstructFromDocuments } from "./reconstruct.js";
|
|
13
|
+
import { DEFAULT_CONVERGENCE_THRESHOLD } from "./schemas.js";
|
|
14
|
+
import { createInitialLedger, isConverged, loadLedger, selectMostUnclearAxis, unresolvedAxes } from "./scoring.js";
|
|
15
|
+
import { archiveGoal, createSessionState, goalIdFromDate, loadSessionState } from "./session.js";
|
|
16
|
+
const STALE_SESSION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
async function exists(path) {
|
|
18
|
+
try {
|
|
19
|
+
await access(path);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function hello(input) {
|
|
27
|
+
const config = await ensureKoanProject(input.cwd);
|
|
28
|
+
await ensureProfileRef(config.projectRoot, input.homeDir);
|
|
29
|
+
const profile = (await loadProfile(input.homeDir)) ?? (await saveProfile(input.homeDir, defaultProfile()));
|
|
30
|
+
const existing = await loadSessionState(config.projectRoot);
|
|
31
|
+
let state;
|
|
32
|
+
let ledger;
|
|
33
|
+
let reconstructed = false;
|
|
34
|
+
let resumed = false;
|
|
35
|
+
if (existing && existing.activeGoalId && existing.phase !== "archived") {
|
|
36
|
+
const stored = await loadLedger(config.projectRoot);
|
|
37
|
+
state = existing;
|
|
38
|
+
ledger =
|
|
39
|
+
stored && stored.goalId === existing.activeGoalId
|
|
40
|
+
? stored
|
|
41
|
+
: createInitialLedger(existing.activeGoalId);
|
|
42
|
+
resumed = true;
|
|
43
|
+
}
|
|
44
|
+
else if (!existing) {
|
|
45
|
+
const survivingLedger = await loadLedger(config.projectRoot);
|
|
46
|
+
const recovered = survivingLedger ? null : await reconstructFromDocuments(config.projectRoot);
|
|
47
|
+
if (survivingLedger) {
|
|
48
|
+
state = createSessionState(survivingLedger.goalId);
|
|
49
|
+
ledger = survivingLedger;
|
|
50
|
+
}
|
|
51
|
+
else if (recovered && recovered.sources.length > 0) {
|
|
52
|
+
state = recovered.state;
|
|
53
|
+
ledger = recovered.ledger;
|
|
54
|
+
reconstructed = true;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const goalId = goalIdFromDate();
|
|
58
|
+
state = createSessionState(goalId);
|
|
59
|
+
ledger = createInitialLedger(goalId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const goalId = goalIdFromDate();
|
|
64
|
+
state = createSessionState(goalId);
|
|
65
|
+
ledger = createInitialLedger(goalId);
|
|
66
|
+
}
|
|
67
|
+
await executeWritePlan(config.projectRoot, {
|
|
68
|
+
description: "Persist session state and ambiguity ledger",
|
|
69
|
+
operations: [
|
|
70
|
+
{ type: "write", path: STATE_FILES.sessionState, content: `${JSON.stringify(state, null, 2)}\n` },
|
|
71
|
+
{ type: "write", path: STATE_FILES.ambiguityLedger, content: `${JSON.stringify(ledger, null, 2)}\n` }
|
|
72
|
+
]
|
|
73
|
+
}, { log: { command: "koan hello", summary: "Initialized or resumed Koan session." } });
|
|
74
|
+
const threshold = config.settings.convergenceThreshold;
|
|
75
|
+
const converged = state.phase === "ready" || isConverged(ledger, threshold);
|
|
76
|
+
return {
|
|
77
|
+
projectRoot: config.projectRoot,
|
|
78
|
+
resumed,
|
|
79
|
+
activeGoalId: state.activeGoalId,
|
|
80
|
+
lastAnswer: state.answers.at(-1) ?? null,
|
|
81
|
+
unresolved: unresolvedAxes(ledger, threshold),
|
|
82
|
+
converged,
|
|
83
|
+
reconstructed,
|
|
84
|
+
nextQuestion: converged ? null : getQuestion(selectMostUnclearAxis(ledger), profile)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export async function status(input) {
|
|
88
|
+
const projectRoot = await findProjectRoot(input.cwd);
|
|
89
|
+
const goal = await readFile(join(projectRoot, CORE_DOCUMENTS.goal), "utf8").catch(() => "# Goal\n");
|
|
90
|
+
const current = await readFile(join(projectRoot, CORE_DOCUMENTS.status), "utf8").catch(() => "# Status\n");
|
|
91
|
+
const session = await loadSessionState(projectRoot);
|
|
92
|
+
const ledger = await loadLedger(projectRoot);
|
|
93
|
+
const threshold = (await loadProjectConfig(projectRoot))?.settings.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD;
|
|
94
|
+
let nextAction;
|
|
95
|
+
if (!session) {
|
|
96
|
+
nextAction = "run koan hello";
|
|
97
|
+
}
|
|
98
|
+
else if (session.phase === "archived" || !session.activeGoalId) {
|
|
99
|
+
nextAction = "run koan hello to start a new goal";
|
|
100
|
+
}
|
|
101
|
+
else if (session.phase === "ready" ||
|
|
102
|
+
(ledger !== null && ledger.goalId === session.activeGoalId && isConverged(ledger, threshold))) {
|
|
103
|
+
nextAction = "archive the completed goal (koan status --archive)";
|
|
104
|
+
}
|
|
105
|
+
else if (!ledger || ledger.goalId !== session.activeGoalId) {
|
|
106
|
+
nextAction = "run koan hello";
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
nextAction = `answer the ${selectMostUnclearAxis(ledger)} question (${unresolvedAxes(ledger, threshold).length} axes unresolved)`;
|
|
110
|
+
}
|
|
111
|
+
const staleWarnings = [];
|
|
112
|
+
if (session && Date.now() - Date.parse(session.updatedAt) > STALE_SESSION_MS) {
|
|
113
|
+
staleWarnings.push(`session state is stale (last updated ${session.updatedAt})`);
|
|
114
|
+
}
|
|
115
|
+
if (session && session.answers.length > 0) {
|
|
116
|
+
const log = await loadCommandLog(projectRoot);
|
|
117
|
+
const lastCrystallize = [...log.entries].reverse().find((entry) => entry.command === "koan crystallize");
|
|
118
|
+
const lastAnswer = session.answers.at(-1);
|
|
119
|
+
if (lastAnswer &&
|
|
120
|
+
(!lastCrystallize || Date.parse(lastCrystallize.at) < Date.parse(lastAnswer.recordedAt))) {
|
|
121
|
+
staleWarnings.push("recorded answers are not crystallized yet (run koan crystallize)");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let summary = `Active Goal\n\n${goal}\n\nCurrent Status\n\n${current}\n\nNext action: ${nextAction}`;
|
|
125
|
+
if (staleWarnings.length > 0) {
|
|
126
|
+
summary += `\n\nWarnings:\n${staleWarnings.map((warning) => `- ${warning}`).join("\n")}`;
|
|
127
|
+
}
|
|
128
|
+
return { summary, didWrite: false, nextAction, staleWarnings };
|
|
129
|
+
}
|
|
130
|
+
export async function updateStatus(input) {
|
|
131
|
+
const projectRoot = await findProjectRoot(input.cwd);
|
|
132
|
+
const state = await loadSessionState(projectRoot);
|
|
133
|
+
if (!state)
|
|
134
|
+
throw new Error("No active Koan session. Run koan hello first.");
|
|
135
|
+
const update = sanitizeRegionContent(input.update.trim());
|
|
136
|
+
if (!update)
|
|
137
|
+
throw new Error("Status update text is required.");
|
|
138
|
+
const isoDate = input.isoDate ?? new Date().toISOString();
|
|
139
|
+
const operations = [
|
|
140
|
+
{ type: "managed-region", path: CORE_DOCUMENTS.status, name: "current-status", content: update }
|
|
141
|
+
];
|
|
142
|
+
if (!(await exists(join(projectRoot, LAZY_DOCUMENTS.handoff)))) {
|
|
143
|
+
operations.push({ type: "write", path: LAZY_DOCUMENTS.handoff, content: "# Handoff\n" });
|
|
144
|
+
}
|
|
145
|
+
operations.push({
|
|
146
|
+
type: "managed-region",
|
|
147
|
+
path: LAZY_DOCUMENTS.handoff,
|
|
148
|
+
name: "latest-status",
|
|
149
|
+
content: `${update}\n\n(Updated ${isoDate} via koan status)`
|
|
150
|
+
}, {
|
|
151
|
+
type: "write",
|
|
152
|
+
path: STATE_FILES.sessionState,
|
|
153
|
+
content: `${JSON.stringify({ ...state, updatedAt: isoDate }, null, 2)}\n`
|
|
154
|
+
});
|
|
155
|
+
const source = input.source?.trim();
|
|
156
|
+
await executeWritePlan(projectRoot, { description: "Record status update", operations }, {
|
|
157
|
+
log: {
|
|
158
|
+
command: "koan status",
|
|
159
|
+
summary: source ? `Recorded a status update (source: ${source}).` : "Recorded a status update."
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
return { projectRoot };
|
|
163
|
+
}
|
|
164
|
+
export async function archive(input) {
|
|
165
|
+
const projectRoot = await findProjectRoot(input.cwd);
|
|
166
|
+
const state = await loadSessionState(projectRoot);
|
|
167
|
+
if (!state?.activeGoalId)
|
|
168
|
+
throw new Error("No active goal to archive.");
|
|
169
|
+
const goalId = state.activeGoalId;
|
|
170
|
+
await archiveGoal(projectRoot, goalId, { command: "koan archive", summary: `Archived goal ${goalId}.` });
|
|
171
|
+
return { archivedGoalId: goalId };
|
|
172
|
+
}
|
|
173
|
+
const BRIGHT_IDEA_RECOMMENDATIONS = {
|
|
174
|
+
clarify: "Refine the current goal with koan hello before implementing.",
|
|
175
|
+
"change-goal": "Archive or re-scope the current goal before adopting this direction.",
|
|
176
|
+
"later-follow-up": "Keep the current plan; revisit this after the active goal completes.",
|
|
177
|
+
reject: "Recorded for reference; no action planned."
|
|
178
|
+
};
|
|
179
|
+
export async function brightIdea(input) {
|
|
180
|
+
const projectRoot = await findProjectRoot(input.cwd);
|
|
181
|
+
const classification = input.classification ?? "later-follow-up";
|
|
182
|
+
const entry = `## ${new Date().toISOString()} — koan bright-idea\n\nClassification: ${classification}\n\n${sanitizeRegionContent(input.idea.trim())}`;
|
|
183
|
+
await executeWritePlan(projectRoot, {
|
|
184
|
+
description: "Record bright idea",
|
|
185
|
+
operations: [
|
|
186
|
+
{
|
|
187
|
+
type: "append",
|
|
188
|
+
path: LAZY_DOCUMENTS.brightIdeas,
|
|
189
|
+
content: entry,
|
|
190
|
+
headerIfMissing: "# Bright Ideas"
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
}, { log: { command: "koan bright-idea", summary: "Recorded a bright idea." } });
|
|
194
|
+
return { classification, recommendation: BRIGHT_IDEA_RECOMMENDATIONS[classification] };
|
|
195
|
+
}
|
|
196
|
+
// Append-only by design: insights are a chronicle of how the product's "why"
|
|
197
|
+
// sharpened over time, so later entries never replace earlier ones.
|
|
198
|
+
export async function recordInsight(input) {
|
|
199
|
+
const projectRoot = await findProjectRoot(input.cwd);
|
|
200
|
+
const text = sanitizeRegionContent(input.text.trim());
|
|
201
|
+
if (!text)
|
|
202
|
+
throw new Error("Insight text is required.");
|
|
203
|
+
const isoDate = input.isoDate ?? new Date().toISOString();
|
|
204
|
+
await executeWritePlan(projectRoot, {
|
|
205
|
+
description: "Record insight",
|
|
206
|
+
operations: [
|
|
207
|
+
{
|
|
208
|
+
type: "append",
|
|
209
|
+
path: LAZY_DOCUMENTS.philosophy,
|
|
210
|
+
content: `## ${isoDate} — koan insight\n\n${text}`,
|
|
211
|
+
headerIfMissing: PHILOSOPHY_BOOTSTRAP
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
}, { log: { command: "koan insight", summary: "Recorded an insight." } });
|
|
215
|
+
return { projectRoot, path: LAZY_DOCUMENTS.philosophy };
|
|
216
|
+
}
|
|
217
|
+
export async function qa(input) {
|
|
218
|
+
const projectRoot = await findProjectRoot(input.cwd);
|
|
219
|
+
const goalText = await readFile(join(projectRoot, CORE_DOCUMENTS.goal), "utf8").catch(() => null);
|
|
220
|
+
const planText = await readFile(join(projectRoot, CORE_DOCUMENTS.plan), "utf8").catch(() => null);
|
|
221
|
+
let checklist = buildQaChecklist({
|
|
222
|
+
activeGoal: goalText === null ? null : readManagedSection(goalText, "active-goal"),
|
|
223
|
+
planSection: planText === null ? null : readManagedSection(planText, "implementation-plan")
|
|
224
|
+
}, input.host ?? "generic");
|
|
225
|
+
if (input.implementationSummary !== undefined) {
|
|
226
|
+
checklist += `\n## Implementation Summary (host-provided)\n\n${input.implementationSummary.trim()}\n`;
|
|
227
|
+
}
|
|
228
|
+
checklist = sanitizeRegionContent(checklist);
|
|
229
|
+
// A managed-region write only touches the qa-checklist region, so manual
|
|
230
|
+
// edits outside the markers and the crystallized qa-criteria region survive.
|
|
231
|
+
await executeWritePlan(projectRoot, {
|
|
232
|
+
description: "Create QA checklist",
|
|
233
|
+
operations: [
|
|
234
|
+
{ type: "managed-region", path: LAZY_DOCUMENTS.qa, name: "qa-checklist", content: checklist }
|
|
235
|
+
]
|
|
236
|
+
}, { log: { command: "koan qa", summary: "Generated QA checklist." } });
|
|
237
|
+
return { projectRoot, checklist };
|
|
238
|
+
}
|
|
239
|
+
export async function handoff(input) {
|
|
240
|
+
const projectRoot = await findProjectRoot(input.cwd);
|
|
241
|
+
const content = sanitizeRegionContent(buildHandoffDocument({ summary: input.summary, experimentalHandoff: false }));
|
|
242
|
+
// A managed-region write only touches the handoff-document region, so manual
|
|
243
|
+
// edits outside the markers and the latest-status / crystallized
|
|
244
|
+
// handoff-context regions survive.
|
|
245
|
+
await executeWritePlan(projectRoot, {
|
|
246
|
+
description: "Create handoff",
|
|
247
|
+
operations: [
|
|
248
|
+
{ type: "managed-region", path: LAZY_DOCUMENTS.handoff, name: "handoff-document", content }
|
|
249
|
+
]
|
|
250
|
+
}, { log: { command: "koan handoff", summary: "Created handoff document." } });
|
|
251
|
+
return { projectRoot, document: content };
|
|
252
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const KOAN_VERSION = "0.2.0";
|
|
2
|
+
export declare const KOAN_DIR = "koan";
|
|
3
|
+
export declare const KOAN_STATE_DIR = ".koan";
|
|
4
|
+
export declare const CORE_DOCUMENTS: {
|
|
5
|
+
readonly readme: "koan/README.md";
|
|
6
|
+
readonly goal: "koan/goal.md";
|
|
7
|
+
readonly status: "koan/status.md";
|
|
8
|
+
readonly plan: "koan/plan.md";
|
|
9
|
+
};
|
|
10
|
+
export declare const LAZY_DOCUMENTS: {
|
|
11
|
+
readonly philosophy: "koan/philosophy.md";
|
|
12
|
+
readonly decisions: "koan/decisions.md";
|
|
13
|
+
readonly openQuestions: "koan/open-questions.md";
|
|
14
|
+
readonly qa: "koan/qa.md";
|
|
15
|
+
readonly handoff: "koan/handoff.md";
|
|
16
|
+
readonly brightIdeas: "koan/bright-ideas.md";
|
|
17
|
+
readonly prd: "koan/prd.md";
|
|
18
|
+
};
|
|
19
|
+
export declare const STATE_FILES: {
|
|
20
|
+
readonly project: ".koan/project.json";
|
|
21
|
+
readonly userProfileRef: ".koan/user-profile-ref.json";
|
|
22
|
+
readonly sessionState: ".koan/session-state.json";
|
|
23
|
+
readonly ambiguityLedger: ".koan/ambiguity-ledger.json";
|
|
24
|
+
readonly commandLog: ".koan/command-log.json";
|
|
25
|
+
readonly mcpCache: ".koan/mcp-cache.json";
|
|
26
|
+
readonly gitignore: ".koan/.gitignore";
|
|
27
|
+
readonly lock: ".koan/write.lock";
|
|
28
|
+
};
|
|
29
|
+
export declare const BOOTSTRAP_START = "<!-- koan:start -->";
|
|
30
|
+
export declare const BOOTSTRAP_END = "<!-- koan:end -->";
|
|
31
|
+
export declare function managedStart(name: string): string;
|
|
32
|
+
export declare function managedEnd(name: string): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const KOAN_VERSION = "0.2.0";
|
|
2
|
+
export const KOAN_DIR = "koan";
|
|
3
|
+
export const KOAN_STATE_DIR = ".koan";
|
|
4
|
+
export const CORE_DOCUMENTS = {
|
|
5
|
+
readme: "koan/README.md",
|
|
6
|
+
goal: "koan/goal.md",
|
|
7
|
+
status: "koan/status.md",
|
|
8
|
+
plan: "koan/plan.md"
|
|
9
|
+
};
|
|
10
|
+
export const LAZY_DOCUMENTS = {
|
|
11
|
+
philosophy: "koan/philosophy.md",
|
|
12
|
+
decisions: "koan/decisions.md",
|
|
13
|
+
openQuestions: "koan/open-questions.md",
|
|
14
|
+
qa: "koan/qa.md",
|
|
15
|
+
handoff: "koan/handoff.md",
|
|
16
|
+
brightIdeas: "koan/bright-ideas.md",
|
|
17
|
+
prd: "koan/prd.md"
|
|
18
|
+
};
|
|
19
|
+
export const STATE_FILES = {
|
|
20
|
+
project: ".koan/project.json",
|
|
21
|
+
userProfileRef: ".koan/user-profile-ref.json",
|
|
22
|
+
sessionState: ".koan/session-state.json",
|
|
23
|
+
ambiguityLedger: ".koan/ambiguity-ledger.json",
|
|
24
|
+
commandLog: ".koan/command-log.json",
|
|
25
|
+
mcpCache: ".koan/mcp-cache.json",
|
|
26
|
+
gitignore: ".koan/.gitignore",
|
|
27
|
+
lock: ".koan/write.lock"
|
|
28
|
+
};
|
|
29
|
+
export const BOOTSTRAP_START = "<!-- koan:start -->";
|
|
30
|
+
export const BOOTSTRAP_END = "<!-- koan:end -->";
|
|
31
|
+
export function managedStart(name) {
|
|
32
|
+
return `<!-- koan:section:start name="${name}" -->`;
|
|
33
|
+
}
|
|
34
|
+
export function managedEnd(name) {
|
|
35
|
+
return `<!-- koan:section:end name="${name}" -->`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type AmbiguityAxis, type WritePlan } from "./schemas.js";
|
|
2
|
+
export interface CrystallizeInput {
|
|
3
|
+
cwd: string;
|
|
4
|
+
homeDir: string;
|
|
5
|
+
dryRun?: boolean;
|
|
6
|
+
isoDate?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CrystallizeResult {
|
|
9
|
+
projectRoot: string;
|
|
10
|
+
plan: WritePlan;
|
|
11
|
+
executed: boolean;
|
|
12
|
+
files: string[];
|
|
13
|
+
crystallizedAxes: AmbiguityAxis[];
|
|
14
|
+
}
|
|
15
|
+
export declare function crystallize(input: CrystallizeInput): Promise<CrystallizeResult>;
|