cairn-ai 0.1.5 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairn",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Token-efficient agent harness with tool readiness for Codex, Claude Code, and Antigravity.",
5
5
  "author": {
6
6
  "name": "WkNam"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairn-ai",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Token-efficient agent harness with tool readiness for Codex, Claude Code, and Antigravity",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
2
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
4
- import { pathToFileURL } from "node:url";
4
+ import { isAbsolute, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
6
 
6
7
  const memoryTemplate = `# MEMORY
7
8
 
@@ -57,10 +58,16 @@ This file is a short index of active and completed work plans.
57
58
 
58
59
  if (isCliEntry()) {
59
60
  const event = process.argv[2] ?? "manual";
60
- console.log(await runState(event));
61
+ const result = await runStateResult(event);
62
+ console.log(result.message);
63
+ if (result.status !== 0) process.exitCode = result.status;
61
64
  }
62
65
 
63
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() } = {}) {
64
71
  const resolvedRoot = resolve(root);
65
72
  await mkdir(join(resolvedRoot, "docs", "memory"), { recursive: true });
66
73
  await mkdir(join(resolvedRoot, "docs", "plan"), { recursive: true });
@@ -69,23 +76,39 @@ export async function runState(event = "manual", { root = process.env.HARNESS_RE
69
76
 
70
77
  const ko = locale.toLowerCase().startsWith("ko");
71
78
  if (event === "session-start" || event === "user-prompt-submit") {
72
- return ko
73
- ? "Cairn 컨텍스트: 모든 에이전트는 작업 시작 시 도메인 지식과 정책 색인인 프로젝트 루트 MEMORY.md를 먼저 읽어야 합니다."
74
- : "Cairn context: every agent must start by reading the project-root MEMORY.md for domain knowledge and repository policy.";
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
+ };
75
85
  }
76
86
  if (event === "post-tool-use") {
77
- return ko
78
- ? "Cairn 점검: 외부 상태 변경에는 dry-run/check 증거가 필요하고, 동작이 바뀌었다면 docs/plan 증거를 갱신하세요."
79
- : "Cairn check: external-state changes need dry-run/check evidence; if behavior changed, update docs/plan evidence.";
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
+ };
80
93
  }
81
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
+ }
82
102
  return ko
83
- ? "Cairn 종료 게이트: 완료에는 docs/plan의 dry-run/check, 모듈 수용, 표면 통합 증거가 필요합니다."
84
- : "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." };
85
105
  }
86
- return ko
87
- ? "Cairn이 MEMORY.md, PLAN.md, docs/memory, docs/plan을 초기화했습니다."
88
- : "Cairn initialized MEMORY.md, PLAN.md, docs/memory, and docs/plan.";
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
+ };
89
112
  }
90
113
 
91
114
  async function writeIfMissing(path, content) {
@@ -96,11 +119,102 @@ async function writeIfMissing(path, content) {
96
119
  }
97
120
  }
98
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
+
99
208
  function localeValue() {
100
209
  return [process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG]
101
210
  .find((value) => typeof value === "string" && value.length > 0) ?? Intl.DateTimeFormat().resolvedOptions().locale;
102
211
  }
103
212
 
104
213
  function isCliEntry() {
105
- return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
214
+ if (!process.argv[1]) return false;
215
+ try {
216
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
217
+ } catch {
218
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
219
+ }
106
220
  }
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
2
3
  import { readdir, stat } from "node:fs/promises";
3
4
  import { join, relative, sep } from "node:path";
4
5
  import { spawnSync } from "node:child_process";
5
- import { pathToFileURL } from "node:url";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
7
 
7
8
  if (isCliEntry()) {
8
9
  const root = process.cwd();
@@ -233,7 +234,12 @@ export function shouldUseShell(command, platform = process.platform) {
233
234
  }
234
235
 
235
236
  function isCliEntry() {
236
- return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
237
+ if (!process.argv[1]) return false;
238
+ try {
239
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
240
+ } catch {
241
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
242
+ }
237
243
  }
238
244
 
239
245
  function normalizePath(path) {
package/scripts/cairn.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
4
  import { fileURLToPath, pathToFileURL } from "node:url";
4
5
  import { spawnSync } from "node:child_process";
@@ -47,7 +48,12 @@ function localeValue() {
47
48
  }
48
49
 
49
50
  function isCliEntry() {
50
- return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
51
+ if (!process.argv[1]) return false;
52
+ try {
53
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
54
+ } catch {
55
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
56
+ }
51
57
  }
52
58
 
53
59
  const messages = {