cairn-ai 0.1.6 → 0.1.7
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/package.json +1 -1
- package/scripts/cairn-state.mjs +121 -13
package/package.json
CHANGED
package/scripts/cairn-state.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
|
|
7
7
|
const memoryTemplate = `# MEMORY
|
|
@@ -58,10 +58,16 @@ This file is a short index of active and completed work plans.
|
|
|
58
58
|
|
|
59
59
|
if (isCliEntry()) {
|
|
60
60
|
const event = process.argv[2] ?? "manual";
|
|
61
|
-
|
|
61
|
+
const result = await runStateResult(event);
|
|
62
|
+
console.log(result.message);
|
|
63
|
+
if (result.status !== 0) process.exitCode = result.status;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
export async function runState(event = "manual", { root = process.env.HARNESS_REPO_ROOT ?? process.cwd(), locale = localeValue() } = {}) {
|
|
67
|
+
return (await runStateResult(event, { root, locale })).message;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runStateResult(event = "manual", { root = process.env.HARNESS_REPO_ROOT ?? process.cwd(), locale = localeValue() } = {}) {
|
|
65
71
|
const resolvedRoot = resolve(root);
|
|
66
72
|
await mkdir(join(resolvedRoot, "docs", "memory"), { recursive: true });
|
|
67
73
|
await mkdir(join(resolvedRoot, "docs", "plan"), { recursive: true });
|
|
@@ -70,23 +76,39 @@ export async function runState(event = "manual", { root = process.env.HARNESS_RE
|
|
|
70
76
|
|
|
71
77
|
const ko = locale.toLowerCase().startsWith("ko");
|
|
72
78
|
if (event === "session-start" || event === "user-prompt-submit") {
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
:
|
|
79
|
+
return {
|
|
80
|
+
status: 0,
|
|
81
|
+
message: ko
|
|
82
|
+
? "Cairn 컨텍스트: 모든 에이전트는 작업 시작 시 도메인 지식과 정책 색인인 프로젝트 루트 MEMORY.md를 먼저 읽어야 합니다."
|
|
83
|
+
: "Cairn context: every agent must start by reading the project-root MEMORY.md for domain knowledge and repository policy.",
|
|
84
|
+
};
|
|
76
85
|
}
|
|
77
86
|
if (event === "post-tool-use") {
|
|
78
|
-
return
|
|
79
|
-
|
|
80
|
-
:
|
|
87
|
+
return {
|
|
88
|
+
status: 0,
|
|
89
|
+
message: ko
|
|
90
|
+
? "Cairn 점검: 외부 상태 변경에는 dry-run/check 증거가 필요하고, 동작이 바뀌었다면 docs/plan 증거를 갱신하세요."
|
|
91
|
+
: "Cairn check: external-state changes need dry-run/check evidence; if behavior changed, update docs/plan evidence.",
|
|
92
|
+
};
|
|
81
93
|
}
|
|
82
94
|
if (event === "stop" || event === "subagent-stop") {
|
|
95
|
+
const pending = await pendingActivePlanItems(resolvedRoot);
|
|
96
|
+
if (pending.length > 0) {
|
|
97
|
+
return {
|
|
98
|
+
status: 1,
|
|
99
|
+
message: stopBlockedMessage({ event, ko, pending }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
83
102
|
return ko
|
|
84
|
-
? "Cairn 종료 게이트: 완료에는 docs/plan의 dry-run/check, 모듈 수용, 표면 통합 증거가 필요합니다."
|
|
85
|
-
: "Cairn stop gate: completion requires dry-run/check, module acceptance, and surface integration evidence in docs/plan.";
|
|
103
|
+
? { status: 0, message: "Cairn 종료 게이트: 완료에는 docs/plan의 dry-run/check, 모듈 수용, 표면 통합 증거가 필요합니다." }
|
|
104
|
+
: { status: 0, message: "Cairn stop gate: completion requires dry-run/check, module acceptance, and surface integration evidence in docs/plan." };
|
|
86
105
|
}
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
:
|
|
106
|
+
return {
|
|
107
|
+
status: 0,
|
|
108
|
+
message: ko
|
|
109
|
+
? "Cairn이 MEMORY.md, PLAN.md, docs/memory, docs/plan을 초기화했습니다."
|
|
110
|
+
: "Cairn initialized MEMORY.md, PLAN.md, docs/memory, and docs/plan.",
|
|
111
|
+
};
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
async function writeIfMissing(path, content) {
|
|
@@ -97,6 +119,92 @@ async function writeIfMissing(path, content) {
|
|
|
97
119
|
}
|
|
98
120
|
}
|
|
99
121
|
|
|
122
|
+
async function pendingActivePlanItems(root) {
|
|
123
|
+
const planPath = join(root, "PLAN.md");
|
|
124
|
+
const plan = await readFile(planPath, "utf8");
|
|
125
|
+
const activePlans = markdownLinksToPlanFiles(section(plan, "Active Plans"));
|
|
126
|
+
const pending = [];
|
|
127
|
+
|
|
128
|
+
for (const relativePath of activePlans) {
|
|
129
|
+
const path = resolve(root, relativePath);
|
|
130
|
+
const pathFromRoot = relative(root, path);
|
|
131
|
+
if (pathFromRoot.startsWith("..") || isAbsolute(pathFromRoot)) continue;
|
|
132
|
+
const text = await readPlanFile(path);
|
|
133
|
+
if (text === null) {
|
|
134
|
+
pending.push({ path: relativePath, unchecked: ["active plan file is missing"], emptyEvidence: [] });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const unchecked = [...text.matchAll(/^\s*-\s+\[\s\]\s+(.+)$/gm)].map((match) => match[1].trim());
|
|
138
|
+
const emptyEvidence = emptyEvidenceItems(text);
|
|
139
|
+
if (unchecked.length > 0 || emptyEvidence.length > 0) {
|
|
140
|
+
pending.push({ path: relativePath, unchecked, emptyEvidence });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return pending;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readPlanFile(path) {
|
|
148
|
+
try {
|
|
149
|
+
return await readFile(path, "utf8");
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error?.code === "ENOENT") return null;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function markdownLinksToPlanFiles(text) {
|
|
157
|
+
const paths = new Set();
|
|
158
|
+
const patterns = [
|
|
159
|
+
/\((docs\/plan\/[^)#\s]+\.md)\)/g,
|
|
160
|
+
/`(docs\/plan\/[^`]+\.md)`/g,
|
|
161
|
+
/\b(docs\/plan\/[^\s)`]+\.md)\b/g,
|
|
162
|
+
];
|
|
163
|
+
for (const pattern of patterns) {
|
|
164
|
+
for (const match of text.matchAll(pattern)) {
|
|
165
|
+
const path = match[1].replace(/[.,;:]+$/, "");
|
|
166
|
+
if (!path.includes("<topic>")) paths.add(path);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return [...paths];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function emptyEvidenceItems(text) {
|
|
173
|
+
const evidence = section(text, "Evidence");
|
|
174
|
+
if (evidence.length === 0) return [];
|
|
175
|
+
const empty = [];
|
|
176
|
+
for (const match of evidence.matchAll(/^\s*-\s+([^:\n]+):\s*$/gm)) empty.push(match[1].trim());
|
|
177
|
+
return empty;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function section(text, title) {
|
|
181
|
+
const pattern = new RegExp(`^##\\s+${escapeRegExp(title)}\\s*$([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "m");
|
|
182
|
+
return text.match(pattern)?.[1]?.trim() ?? "";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function stopBlockedMessage({ event, ko, pending }) {
|
|
186
|
+
const itemText = pending.slice(0, 3).map(formatPendingItem).join("; ");
|
|
187
|
+
const suffix = pending.length > 3 ? `; +${pending.length - 3} more` : "";
|
|
188
|
+
if (ko) {
|
|
189
|
+
const subject = event === "subagent-stop" ? "서브에이전트 완료 신호" : "완료 신호";
|
|
190
|
+
return `Cairn 종료 게이트: ${subject}를 차단했습니다. 미완료 작업이 남아 있습니다. PLAN.md의 Active Plans를 다시 읽고 다음 미완료 slice를 계속 진행하세요: ${itemText}${suffix}`;
|
|
191
|
+
}
|
|
192
|
+
const subject = event === "subagent-stop" ? "subagent completion signal" : "completion signal";
|
|
193
|
+
return `Cairn stop gate: blocked ${subject}. Incomplete work remains. Re-read PLAN.md Active Plans and continue the next incomplete slice: ${itemText}${suffix}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatPendingItem(item) {
|
|
197
|
+
const reasons = [
|
|
198
|
+
...item.unchecked.map((label) => `unchecked: ${label}`),
|
|
199
|
+
...item.emptyEvidence.map((label) => `empty evidence: ${label}`),
|
|
200
|
+
];
|
|
201
|
+
return `${item.path} (${reasons.slice(0, 3).join(", ")})`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function escapeRegExp(value) {
|
|
205
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
206
|
+
}
|
|
207
|
+
|
|
100
208
|
function localeValue() {
|
|
101
209
|
return [process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG]
|
|
102
210
|
.find((value) => typeof value === "string" && value.length > 0) ?? Intl.DateTimeFormat().resolvedOptions().locale;
|