cairn-ai 0.2.2 → 0.2.3

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.
@@ -0,0 +1,246 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { hostname } from "node:os";
3
+ import { link, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
+
6
+ const LOCK_SCHEMA_VERSION = 1;
7
+ const DEFAULT_LOCK_TIMEOUT_MS = 5_000;
8
+ const DEFAULT_RETRY_MS = 25;
9
+ const DEFAULT_MALFORMED_STALE_MS = 30_000;
10
+ const TRANSIENT_LOCK_ERROR_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
11
+
12
+ export function stateLockPath(root = process.cwd()) {
13
+ return safePath(root, ".cairn/state.lock");
14
+ }
15
+
16
+ export function safePath(root, relativePath) {
17
+ const workspaceRoot = resolve(root);
18
+ const target = resolve(workspaceRoot, relativePath);
19
+ const relation = relative(workspaceRoot, target);
20
+ if (relation === ".." || relation.startsWith(`..${sep}`)) {
21
+ throw new Error(`Managed path must remain inside the workspace: ${relativePath}`);
22
+ }
23
+ return target;
24
+ }
25
+
26
+ export async function assertNoSymlinkComponents(root, target, { allowMissing = true } = {}) {
27
+ const workspaceRoot = resolve(root);
28
+ const absoluteTarget = resolve(target);
29
+ const relation = relative(workspaceRoot, absoluteTarget);
30
+ if (relation === ".." || relation.startsWith(`..${sep}`)) {
31
+ throw new Error(`Managed path must remain inside the workspace: ${target}`);
32
+ }
33
+ const components = relation === "" ? [] : relation.split(sep);
34
+ let current = workspaceRoot;
35
+ await rejectSymlink(current);
36
+ for (const component of components) {
37
+ current = join(current, component);
38
+ try {
39
+ await rejectSymlink(current);
40
+ } catch (error) {
41
+ if (allowMissing && error?.code === "ENOENT") return;
42
+ throw error;
43
+ }
44
+ }
45
+ }
46
+
47
+ export async function safeMkdir(root, relativePath) {
48
+ const workspaceRoot = resolve(root);
49
+ const target = safePath(workspaceRoot, relativePath);
50
+ await assertNoSymlinkComponents(workspaceRoot, workspaceRoot, { allowMissing: false });
51
+ const relation = relative(workspaceRoot, target);
52
+ let current = workspaceRoot;
53
+ for (const component of relation.split(sep).filter(Boolean)) {
54
+ current = join(current, component);
55
+ try {
56
+ const stat = await lstat(current);
57
+ if (stat.isSymbolicLink()) throw symlinkError(current);
58
+ if (!stat.isDirectory()) throw new Error(`Managed directory component is not a directory: ${current}`);
59
+ } catch (error) {
60
+ if (error?.code !== "ENOENT") throw error;
61
+ try {
62
+ await mkdir(current);
63
+ } catch (mkdirError) {
64
+ if (mkdirError?.code !== "EEXIST") throw mkdirError;
65
+ const stat = await lstat(current);
66
+ if (stat.isSymbolicLink()) throw symlinkError(current);
67
+ if (!stat.isDirectory()) throw new Error(`Managed directory component is not a directory: ${current}`);
68
+ }
69
+ }
70
+ }
71
+ return target;
72
+ }
73
+
74
+ export async function safeWriteFile(root, target, data, options) {
75
+ const absoluteTarget = safePath(root, relative(resolve(root), resolve(target)));
76
+ await assertNoSymlinkComponents(root, dirname(absoluteTarget), { allowMissing: false });
77
+ await assertNoSymlinkComponents(root, absoluteTarget);
78
+ return writeFile(absoluteTarget, data, options);
79
+ }
80
+
81
+ export async function withStateLock(root, operation, {
82
+ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
83
+ retryMs = DEFAULT_RETRY_MS,
84
+ malformedStaleMs = DEFAULT_MALFORMED_STALE_MS,
85
+ testHooks = {},
86
+ } = {}) {
87
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error("lock timeoutMs must be a non-negative number");
88
+ const workspaceRoot = resolve(root);
89
+ await safeMkdir(workspaceRoot, ".cairn");
90
+ const lockPath = stateLockPath(workspaceRoot);
91
+ const owner = {
92
+ schemaVersion: LOCK_SCHEMA_VERSION,
93
+ pid: process.pid,
94
+ hostname: hostname(),
95
+ nonce: randomUUID(),
96
+ acquiredAt: new Date().toISOString(),
97
+ };
98
+ const deadline = Date.now() + timeoutMs;
99
+ while (true) {
100
+ try {
101
+ await safeWriteFile(workspaceRoot, lockPath, `${JSON.stringify(owner)}\n`, { encoding: "utf8", flag: "wx" });
102
+ await testHooks.afterLockWritten?.({ lockPath, owner: structuredClone(owner) });
103
+ if (await confirmsLockOwnership(lockPath, owner.nonce)) break;
104
+ } catch (error) {
105
+ if (isTransientLockError(error)) {
106
+ // Windows can briefly deny a lock file that another process is closing.
107
+ // Retry within the deadline; permission errors are not evidence of staleness.
108
+ } else if (error?.code === "EEXIST") {
109
+ try {
110
+ await testHooks.beforeContendedLockConfirmation?.({ lockPath });
111
+ if (await confirmsLockOwnership(lockPath, owner.nonce)) break;
112
+ const reclaimed = await reclaimStaleLock(workspaceRoot, lockPath, malformedStaleMs, testHooks);
113
+ if (reclaimed) continue;
114
+ } catch (inspectionError) {
115
+ if (!isTransientLockError(inspectionError)) throw inspectionError;
116
+ }
117
+ } else {
118
+ throw error;
119
+ }
120
+ }
121
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn state lock after ${timeoutMs}ms`);
122
+ await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
123
+ }
124
+
125
+ try {
126
+ return await operation();
127
+ } finally {
128
+ await releaseOwnedLock(lockPath, owner.nonce);
129
+ }
130
+ }
131
+
132
+ async function reclaimStaleLock(root, lockPath, malformedStaleMs, testHooks) {
133
+ let observed;
134
+ let stat;
135
+ try {
136
+ [observed, stat] = await Promise.all([readFile(lockPath, "utf8"), lstat(lockPath)]);
137
+ } catch (error) {
138
+ if (error?.code === "ENOENT") return true;
139
+ throw error;
140
+ }
141
+ if (stat.isSymbolicLink()) throw symlinkError(lockPath);
142
+ const record = parseLockRecord(observed);
143
+ if (record) {
144
+ if (record.hostname !== hostname()) return false;
145
+ if (pidIsAlive(record.pid)) return false;
146
+ } else if (Date.now() - stat.mtimeMs <= malformedStaleMs) {
147
+ return false;
148
+ }
149
+
150
+ await testHooks.afterStaleObserved?.({ lockPath, observed, record: record ? structuredClone(record) : null });
151
+ const quarantine = safePath(root, `.cairn/state.lock.stale.${record?.nonce ?? "malformed"}.${randomUUID()}`);
152
+ try {
153
+ await rename(lockPath, quarantine);
154
+ } catch (error) {
155
+ if (error?.code === "ENOENT") return true;
156
+ throw error;
157
+ }
158
+ const moved = await readFile(quarantine, "utf8");
159
+ if (moved !== observed) {
160
+ const restored = await restoreMovedCandidate(lockPath, quarantine);
161
+ await testHooks.afterReclaimMismatch?.({ lockPath, restored });
162
+ return false;
163
+ }
164
+ await rm(quarantine, { force: true });
165
+ return true;
166
+ }
167
+
168
+ async function restoreMovedCandidate(lockPath, quarantine) {
169
+ try {
170
+ await link(quarantine, lockPath);
171
+ await rm(quarantine);
172
+ return true;
173
+ } catch (error) {
174
+ if (error?.code !== "EEXIST") throw error;
175
+ await rm(quarantine, { force: true });
176
+ return false;
177
+ }
178
+ }
179
+
180
+ async function confirmsLockOwnership(lockPath, nonce) {
181
+ try {
182
+ return parseLockRecord(await readFile(lockPath, "utf8"))?.nonce === nonce;
183
+ } catch (error) {
184
+ if (error?.code === "ENOENT") return false;
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ async function releaseOwnedLock(lockPath, nonce) {
190
+ for (let attempt = 0; attempt < 8; attempt += 1) {
191
+ try {
192
+ const record = parseLockRecord(await readFile(lockPath, "utf8"));
193
+ if (record?.nonce !== nonce) return;
194
+ await rm(lockPath);
195
+ return;
196
+ } catch (error) {
197
+ if (error?.code === "ENOENT") return;
198
+ if (!isTransientLockError(error) || attempt === 7) throw error;
199
+ await delay(10 * (attempt + 1));
200
+ }
201
+ }
202
+ }
203
+
204
+ function isTransientLockError(error) {
205
+ return TRANSIENT_LOCK_ERROR_CODES.has(error?.code);
206
+ }
207
+
208
+ function parseLockRecord(text) {
209
+ try {
210
+ const value = JSON.parse(text);
211
+ if (value?.schemaVersion !== LOCK_SCHEMA_VERSION) return null;
212
+ if (!Number.isSafeInteger(value.pid) || value.pid <= 0) return null;
213
+ if (typeof value.hostname !== "string" || value.hostname.length === 0) return null;
214
+ if (typeof value.nonce !== "string" || value.nonce.length === 0) return null;
215
+ if (typeof value.acquiredAt !== "string" || Number.isNaN(Date.parse(value.acquiredAt))) return null;
216
+ return value;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function pidIsAlive(pid) {
223
+ try {
224
+ process.kill(pid, 0);
225
+ return true;
226
+ } catch (error) {
227
+ if (error?.code === "EPERM") return true;
228
+ if (error?.code === "ESRCH") return false;
229
+ return true;
230
+ }
231
+ }
232
+
233
+ async function rejectSymlink(path) {
234
+ const stat = await lstat(path);
235
+ if (stat.isSymbolicLink()) throw symlinkError(path);
236
+ }
237
+
238
+ function symlinkError(path) {
239
+ const error = new Error(`Managed path must not contain a symbolic link: ${path}`);
240
+ error.code = "CAIRN_SYMLINK";
241
+ return error;
242
+ }
243
+
244
+ function delay(ms) {
245
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
246
+ }
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { evaluateStop, isGoalOwnedBySession, readGoalState } from "./cairn-goal.mjs";
7
7
  import { resolveRepoRoot } from "./cairn-paths.mjs";
8
+ import { assertNoSymlinkComponents, safeMkdir, safeWriteFile } from "./cairn-safe-fs.mjs";
8
9
 
9
10
  const memoryTemplate = `# MEMORY
10
11
 
@@ -162,12 +163,18 @@ export async function runStateResult(event = "manual", {
162
163
  }
163
164
 
164
165
  if (event === "post-tool-use") {
166
+ const message = ko
167
+ ? "Cairn 점검: 외부 상태 변경에는 dry-run/check 증거 기록이 필요합니다. 활성 목표가 있다면 현재 task에 성공 증거를 기록하세요."
168
+ : "Cairn check: external-state changes need dry-run/check evidence. When a goal is active, record successful evidence for its current task.";
165
169
  return {
166
170
  status: 0,
167
- message: ko
168
- ? "Cairn 점검: 외부 상태 변경에는 dry-run/check 증거 기록이 필요합니다. 활성 목표가 있다면 현재 task에 성공 증거를 기록하세요."
169
- : "Cairn check: external-state changes need dry-run/check evidence. When a goal is active, record successful evidence for its current task.",
170
- hookOutput: {},
171
+ message,
172
+ hookOutput: {
173
+ hookSpecificOutput: {
174
+ hookEventName: "PostToolUse",
175
+ additionalContext: message,
176
+ },
177
+ },
171
178
  };
172
179
  }
173
180
 
@@ -193,18 +200,24 @@ export async function runStateResult(event = "manual", {
193
200
  }
194
201
 
195
202
  async function initializeProject(root, ko) {
196
- await mkdir(join(root, "docs", "memory"), { recursive: true });
197
- await mkdir(join(root, "docs", "plan"), { recursive: true });
198
- await writeIfMissing(join(root, "MEMORY.md"), ko ? memoryTemplateKo : memoryTemplate);
199
- await writeIfMissing(join(root, "PLAN.md"), ko ? planTemplateKo : planTemplate);
203
+ await safeMkdir(root, "docs/memory");
204
+ await safeMkdir(root, "docs/plan");
205
+ await writeIfMissing(root, join(root, "MEMORY.md"), ko ? memoryTemplateKo : memoryTemplate);
206
+ await writeIfMissing(root, join(root, "PLAN.md"), ko ? planTemplateKo : planTemplate);
200
207
  }
201
208
 
202
- async function writeIfMissing(path, content) {
209
+ async function writeIfMissing(root, path, content) {
210
+ await assertNoSymlinkComponents(root, path);
203
211
  try {
204
212
  await readFile(path, "utf8");
205
213
  } catch (error) {
206
214
  if (error?.code !== "ENOENT") throw error;
207
- await writeFile(path, content);
215
+ try {
216
+ await safeWriteFile(root, path, content, { encoding: "utf8", flag: "wx" });
217
+ } catch (writeError) {
218
+ if (writeError?.code !== "EEXIST") throw writeError;
219
+ await assertNoSymlinkComponents(root, path, { allowMissing: false });
220
+ }
208
221
  }
209
222
  }
210
223
 
@@ -58,11 +58,12 @@ export async function createReport({
58
58
  runner = run,
59
59
  timeoutMs = DEFAULT_TIMEOUT_MS,
60
60
  localChecker = localExecutableAvailable,
61
+ platform = process.platform,
61
62
  } = {}) {
62
63
  const resolvedRoot = resolve(root);
63
64
  const entries = await collectEntries(resolvedRoot);
64
65
  const detected = detectStacks(entries);
65
- const requirements = buildRequirements(detected, entries);
66
+ const requirements = buildRequirements(detected, entries, commandOk, platform);
66
67
  const results = [];
67
68
  const installRefused = install && !yes;
68
69
 
@@ -72,6 +73,7 @@ export async function createReport({
72
73
  runner,
73
74
  timeoutMs,
74
75
  localChecker,
76
+ platform,
75
77
  });
76
78
  let installed = false;
77
79
  let installDiagnostic = null;
@@ -95,6 +97,7 @@ export async function createReport({
95
97
  runner,
96
98
  timeoutMs,
97
99
  localChecker,
100
+ platform,
98
101
  });
99
102
  }
100
103
  }
@@ -157,22 +160,24 @@ export async function collectEntries(base) {
157
160
 
158
161
  export function detectStacks(paths) {
159
162
  const stacks = new Set();
160
- if (paths.some((path) => path === "package.json")) stacks.add("javascript");
161
- if (paths.some((path) => path === "tsconfig.json" || path.endsWith(".ts") || path.endsWith(".tsx"))) stacks.add("typescript");
162
- if (paths.some((path) => path === "pyproject.toml" || path.endsWith(".py"))) stacks.add("python");
163
- if (paths.some((path) => path === "composer.json" || path.endsWith(".php"))) stacks.add("php");
164
- if (paths.some((path) => path === "pom.xml" || path === "build.gradle" || path.endsWith(".java"))) stacks.add("java");
163
+ if (hasBasename(paths, "package.json")) stacks.add("javascript");
164
+ if (hasBasename(paths, "tsconfig.json") || paths.some((path) => path.endsWith(".ts") || path.endsWith(".tsx"))) stacks.add("typescript");
165
+ if (hasBasename(paths, "pyproject.toml") || paths.some((path) => path.endsWith(".py"))) stacks.add("python");
166
+ if (hasBasename(paths, "composer.json") || paths.some((path) => path.endsWith(".php"))) stacks.add("php");
167
+ if (hasBasename(paths, "pom.xml", "build.gradle", "build.gradle.kts") || paths.some((path) => path.endsWith(".java"))) stacks.add("java");
165
168
  if (paths.some((path) => path.endsWith(".kt") || path.endsWith(".kts"))) stacks.add("kotlin");
166
- if (paths.some((path) => path === "Package.swift" || path.endsWith(".xcodeproj") || path.endsWith(".xcworkspace") || path.endsWith(".swift"))) stacks.add("swift");
167
- if (paths.some((path) => path === "go.mod" || path.endsWith(".go"))) stacks.add("go");
168
- if (paths.some((path) => path === "Cargo.toml" || path.endsWith(".rs"))) stacks.add("rust");
169
+ if (hasBasename(paths, "Package.swift") || paths.some((path) => hasPathSegmentWithSuffix(path, ".xcodeproj", ".xcworkspace") || path.endsWith(".swift"))) stacks.add("swift");
170
+ if (hasBasename(paths, "go.mod") || paths.some((path) => path.endsWith(".go"))) stacks.add("go");
171
+ if (hasBasename(paths, "Cargo.toml") || paths.some((path) => path.endsWith(".rs"))) stacks.add("rust");
169
172
  return [...stacks];
170
173
  }
171
174
 
172
175
  export function buildRequirements(stacks, entries = [], _commandAvailable = commandOk, platform = process.platform) {
173
176
  const requirements = [];
174
177
  if (stacks.includes("javascript")) {
175
- requirements.push(req("node", ["--version"], "JavaScript runtime for package scripts and CLI checks"));
178
+ requirements.push(unsafeReq("node", ["--version"], "JavaScript runtime for package scripts and CLI checks", "automatic runtime installation is unavailable"));
179
+ const manager = packageManager(entries);
180
+ requirements.push(unsafeReq(manager, ["--version"], `JavaScript package manager selected from ${packageManagerSource(entries)}`, "automatic package-manager installation is unavailable"));
176
181
  }
177
182
  if (stacks.includes("typescript")) {
178
183
  requirements.push(unsafeReq("typescript-language-server", ["--version"], "TypeScript LSP server", "automatic package installation is disabled without pinned package versions"));
@@ -183,7 +188,7 @@ export function buildRequirements(stacks, entries = [], _commandAvailable = comm
183
188
  requirements.push(unsafeReq("ruff", ["--version"], "Python lint and format checker", "automatic Python package installation is disabled without pinned package versions"));
184
189
  }
185
190
  if (stacks.includes("php")) {
186
- const composerProject = entries.includes("composer.json");
191
+ const composerProject = hasBasename(entries, "composer.json");
187
192
  requirements.push(req("php", ["--version"], "PHP runtime for Composer scripts and CLI checks"));
188
193
  if (composerProject) requirements.push(req("composer", ["--version"], "PHP package manager for project-local tool installation"));
189
194
  requirements.push(unsafeReq("phpactor", ["--version"], "PHP LSP server", "automatic Composer package installation is disabled without pinned package versions"));
@@ -204,7 +209,7 @@ export function buildRequirements(stacks, entries = [], _commandAvailable = comm
204
209
  if (stacks.includes("swift")) {
205
210
  requirements.push(req("swift", ["--version"], "Swift toolchain for build and test commands"));
206
211
  requirements.push(req("sourcekit-lsp", ["--version"], "Swift LSP server"));
207
- if (entries.some((path) => path.endsWith(".xcodeproj") || path.endsWith(".xcworkspace"))) {
212
+ if (entries.some((path) => hasPathSegmentWithSuffix(path, ".xcodeproj", ".xcworkspace"))) {
208
213
  requirements.push(req("xcodebuild", ["-version"], "Xcode build verification"));
209
214
  }
210
215
  }
@@ -229,13 +234,16 @@ export function addJvmRequirements(requirements) {
229
234
  }
230
235
 
231
236
  export function addJvmBuildToolRequirements(requirements, entries = []) {
232
- if (entries.includes("gradlew.bat")) pushUnique(requirements, req("gradlew.bat", ["--version"], "Gradle wrapper availability", null, { localOnly: true }));
233
- else if (entries.includes("gradlew")) pushUnique(requirements, req("./gradlew", ["--version"], "Gradle wrapper availability", null, { localOnly: true }));
234
- else if (entries.includes("build.gradle") || entries.includes("build.gradle.kts")) pushUnique(requirements, req("gradle", ["--version"], "Gradle availability"));
235
- if (entries.includes("mvnw.cmd")) pushUnique(requirements, req("mvnw.cmd", ["--version"], "Maven wrapper availability", null, { localOnly: true }));
236
- else if (entries.includes("mvnw.bat")) pushUnique(requirements, req("mvnw.bat", ["--version"], "Maven wrapper availability", null, { localOnly: true }));
237
- else if (entries.includes("mvnw")) pushUnique(requirements, req("./mvnw", ["--version"], "Maven wrapper availability", null, { localOnly: true }));
238
- else if (entries.includes("pom.xml")) pushUnique(requirements, req("mvn", ["--version"], "Maven availability"));
237
+ const gradleWindows = findBasename(entries, "gradlew.bat");
238
+ const gradleUnix = findBasename(entries, "gradlew");
239
+ if (gradleWindows) pushUnique(requirements, req(repositoryCommand(gradleWindows), ["--version"], "Gradle wrapper availability", null, { localOnly: true }));
240
+ else if (gradleUnix) pushUnique(requirements, req(repositoryCommand(gradleUnix), ["--version"], "Gradle wrapper availability", null, { localOnly: true }));
241
+ else if (hasBasename(entries, "build.gradle", "build.gradle.kts")) pushUnique(requirements, req("gradle", ["--version"], "Gradle availability"));
242
+ const mavenWindows = findBasename(entries, "mvnw.cmd", "mvnw.bat");
243
+ const mavenUnix = findBasename(entries, "mvnw");
244
+ if (mavenWindows) pushUnique(requirements, req(repositoryCommand(mavenWindows), ["--version"], "Maven wrapper availability", null, { localOnly: true }));
245
+ else if (mavenUnix) pushUnique(requirements, req(repositoryCommand(mavenUnix), ["--version"], "Maven wrapper availability", null, { localOnly: true }));
246
+ else if (hasBasename(entries, "pom.xml")) pushUnique(requirements, req("mvn", ["--version"], "Maven availability"));
239
247
  }
240
248
 
241
249
  export function pushUnique(requirements, requirement) {
@@ -276,10 +284,12 @@ export function unsafeReq(command, args, reason, installUnavailableReason) {
276
284
  }
277
285
 
278
286
  export function packageManager(entries = []) {
279
- if (entries.includes("bun.lockb") || entries.includes("bun.lock")) return "bun";
280
- if (entries.includes("pnpm-lock.yaml")) return "pnpm";
281
- if (entries.includes("yarn.lock")) return "yarn";
282
- return "npm";
287
+ return selectPackageManager(entries).manager;
288
+ }
289
+
290
+ export function packageManagerSource(entries = []) {
291
+ const selected = selectPackageManager(entries);
292
+ return selected.path ?? "the npm default (no lockfile found)";
283
293
  }
284
294
 
285
295
  export function installDev(pm, packages) {
@@ -294,20 +304,24 @@ export function inspectCommand(requirement, {
294
304
  runner = run,
295
305
  timeoutMs = DEFAULT_TIMEOUT_MS,
296
306
  localChecker = localExecutableAvailable,
307
+ platform = process.platform,
297
308
  } = {}) {
298
- const candidates = commandCandidates(requirement.command, root);
309
+ const candidates = commandCandidates(requirement.command, root, platform);
299
310
  const localOnly = requirement.localOnly || isRepositoryCommand(requirement.command, root);
300
311
 
301
312
  if (!localOnly) {
302
- const diagnostic = execute(requirement.command, requirement.args, { root, runner, timeoutMs });
303
- if (diagnostic.status === 0) {
304
- return {
305
- ok: true,
306
- availability: "verified",
307
- source: "system",
308
- candidate: requirement.command,
309
- diagnostic,
310
- };
313
+ let diagnostic = null;
314
+ for (const candidate of systemCommandCandidates(requirement.command, platform)) {
315
+ diagnostic = execute(candidate, requirement.args, { root, runner, timeoutMs });
316
+ if (diagnostic.status === 0) {
317
+ return {
318
+ ok: true,
319
+ availability: "verified",
320
+ source: "system",
321
+ candidate,
322
+ diagnostic,
323
+ };
324
+ }
311
325
  }
312
326
 
313
327
  for (const candidate of candidates.slice(1)) {
@@ -324,7 +338,7 @@ export function inspectCommand(requirement, {
324
338
 
325
339
  return {
326
340
  ok: false,
327
- availability: diagnostic.timedOut ? "timeout" : "missing",
341
+ availability: diagnostic?.timedOut ? "timeout" : "missing",
328
342
  source: null,
329
343
  candidate: null,
330
344
  diagnostic,
@@ -370,6 +384,11 @@ export function commandCandidates(command, root = process.cwd(), platform = proc
370
384
  return [...new Set(candidates.flatMap((candidate) => hasWindowsExecutableExtension(candidate) ? [candidate] : [candidate, ...extensions.map((extension) => `${candidate}${extension}`)]))];
371
385
  }
372
386
 
387
+ export function systemCommandCandidates(command, platform = process.platform) {
388
+ if (platform !== "win32" || hasWindowsExecutableExtension(command) || isAbsolute(command)) return [command];
389
+ return [command, `${command}.cmd`, `${command}.bat`, `${command}.exe`];
390
+ }
391
+
373
392
  export function localExecutableAvailable(command, platform = process.platform) {
374
393
  try {
375
394
  const info = statSync(command);
@@ -479,6 +498,69 @@ function normalizePath(path) {
479
498
  return path.split(sep).join("/");
480
499
  }
481
500
 
501
+ function pathBasename(path) {
502
+ const normalized = normalizePath(path);
503
+ return normalized.slice(normalized.lastIndexOf("/") + 1);
504
+ }
505
+
506
+ function hasBasename(paths, ...names) {
507
+ const expected = new Set(names);
508
+ return paths.some((path) => expected.has(pathBasename(path)));
509
+ }
510
+
511
+ function findBasename(paths, ...names) {
512
+ const expected = new Set(names);
513
+ return [...paths]
514
+ .filter((path) => expected.has(pathBasename(path)))
515
+ .sort(compareRepositoryPaths)[0] ?? null;
516
+ }
517
+
518
+ function hasPathSegmentWithSuffix(path, ...suffixes) {
519
+ return normalizePath(path)
520
+ .split("/")
521
+ .some((segment) => suffixes.some((suffix) => segment.endsWith(suffix)));
522
+ }
523
+
524
+ function repositoryCommand(path) {
525
+ const normalized = normalizePath(path);
526
+ return normalized.startsWith("./") ? normalized : `./${normalized}`;
527
+ }
528
+
529
+ function selectPackageManager(entries) {
530
+ const lockfiles = [
531
+ { manager: "bun", names: new Set(["bun.lock", "bun.lockb"]), priority: 0 },
532
+ { manager: "pnpm", names: new Set(["pnpm-lock.yaml"]), priority: 1 },
533
+ { manager: "yarn", names: new Set(["yarn.lock"]), priority: 2 },
534
+ { manager: "npm", names: new Set(["package-lock.json", "npm-shrinkwrap.json"]), priority: 3 },
535
+ ];
536
+ const candidates = [];
537
+
538
+ for (const path of entries) {
539
+ const normalized = normalizePath(path);
540
+ const name = pathBasename(normalized);
541
+ const match = lockfiles.find(({ names }) => names.has(name));
542
+ if (!match) continue;
543
+ candidates.push({
544
+ manager: match.manager,
545
+ path: normalized,
546
+ depth: normalized.split("/").length - 1,
547
+ priority: match.priority,
548
+ });
549
+ }
550
+
551
+ candidates.sort((left, right) => left.depth - right.depth
552
+ || left.priority - right.priority
553
+ || left.path.localeCompare(right.path));
554
+ return candidates[0] ?? { manager: "npm", path: null };
555
+ }
556
+
557
+ function compareRepositoryPaths(left, right) {
558
+ const leftPath = normalizePath(left);
559
+ const rightPath = normalizePath(right);
560
+ const depth = leftPath.split("/").length - rightPath.split("/").length;
561
+ return depth || leftPath.localeCompare(rightPath);
562
+ }
563
+
482
564
  function hasWindowsExecutableExtension(command) {
483
565
  return /\.(cmd|bat|exe)$/i.test(command);
484
566
  }
@@ -0,0 +1,68 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "release": "0.2.2",
4
+ "files": {
5
+ ".agents/workflows/cairn-doctor.md": "cb5525f795ec1b1b3155396b6e175362af0f88ef376f9b92271ef5f7924f9da8",
6
+ ".agents/workflows/cairn-install.md": "036a74fae6df7c7b7b4dc567a3fdb4986fae4ef41c273ad2950a54e57a5e3cfd",
7
+ ".agents/workflows/cairn-memory.md": "af7c94799d0d0878d5386610ba95be9c22a673f75d81e5449053ef4af6bb8c2c",
8
+ ".agents/workflows/cairn-plan.md": "ad6791e3c34cefff161bf9d0d985dd3fe277808c6d3d3e43ffedd132fda756bd",
9
+ ".agents/workflows/cairn-review.md": "4a8698b2680cab3220a2b318eb053ae9b69378f745c0d9084eabd912a65832f5",
10
+ ".agents/workflows/cairn-toolcheck.md": "530051de82b89ba97a5f8bc8951e0c80482849551cb81c6c5dc5dd975c2f0845",
11
+ ".agents/workflows/cairn-uninstall.md": "81f2e1551eff44b97128da0d715fe1b43f14305eb0dc1b06aad4743fae8d2d67",
12
+ ".agents/workflows/cairn-upgrade.md": "0a646846edb989aef7cbce3963721906860f0edc1af55811cc3b4b3ad3ff9f15",
13
+ ".agents/workflows/cairn-work.md": "c508db4dd2f8c00c4439c10153cce5a63a0fcb60f9e3ffa94412f3e64ab1e84b",
14
+ ".claude/agents/explorer.md": "bb7807ebc8a39d1e63b342e0d4e44d24b93ee110d15e322ec60d43c49b9a9d45",
15
+ ".claude/agents/worker.md": "2619eb04fce8dd637cf506ac604f7d41cb8fb13934a2cd4e9ed2df05b95ab30e",
16
+ ".claude/commands/cairn-doctor.md": "1c6b39fe9f7fdbd285018d39677b0e35b9cd5ddb99eb343f85bb5f735efbb733",
17
+ ".claude/commands/cairn-install.md": "52b2b09c0a756112b13da9dd6653fbc84a86b026b24c123b248c80c797dbd77e",
18
+ ".claude/commands/cairn-memory.md": "3da4f95c24b2b12eddf09ef365023a2d1fd79467f8c50dee86e5a860c62bd9fd",
19
+ ".claude/commands/cairn-plan.md": "dfb9d5fb4643158323f3b4e116dfda518384a74ca300b6571929e806a5cdae1f",
20
+ ".claude/commands/cairn-review.md": "c5a8aa682e871c824f07830ef2cc8d6644df12e256393ed1a54739cbc3b0f817",
21
+ ".claude/commands/cairn-toolcheck.md": "169e52144875b79b230fbe9f20343c820b81c4f6a87a5328d9db7155253d819d",
22
+ ".claude/commands/cairn-uninstall.md": "86c46dc88f1f7d236046d996768b91145deacdbc8b5a456044d9696ee1769038",
23
+ ".claude/commands/cairn-upgrade.md": "8b7b82a3d235e0f6a0dbedc393a3ebe5773e88d593bcaa8468ac7bbc653b038d",
24
+ ".claude/commands/cairn-work.md": "6b434795411ffeac53625f3daceefee2edb47030dd9b93d764a539de5c79562b",
25
+ ".codex-plugin/plugin.json": "5d75b04c4240da2235c5489a328ac94450f7d9c49078fba33273d1ca0eb96a30",
26
+ "LICENSE": "1284a0177aecbc8ed521c2c99480f0f6767ab1ca4b832f30d513ae1aa1368f92",
27
+ "README.de.md": "3d73f892136bd1beb15f1e95071b85b74b9e18ef38d8cebde02a09266beadb28",
28
+ "README.es.md": "7bc44d65b47b1c8ee9a7a6a65360bef89a0ed611c1e808d0ed1a4a95108c9271",
29
+ "README.fr.md": "65eb784d27249c50e317708adf75aafb2495e4f552cb0ec3b123999a920abef0",
30
+ "README.ja.md": "241fd360d6ea430494b099872fbe9f64480e22ef26409e7fc43a4eee1fe183ee",
31
+ "README.ko.md": "f39289045e26834ce518ffded3af3f6ac45ca09ec72c86eef6aa1c265c5d7aad",
32
+ "README.md": "2ccb2cab43bad082ccc566cf9330d577e66df283746e37c4d8ab25a935e57473",
33
+ "README.pt.md": "306ccf468c59d2cea50e385c99308aa4d7a952dbfd65c5586cf06faab13e7b5b",
34
+ "README.zh.md": "d788cdccb4fc327b8242085eed45a3e8ac59a0f3c4bf53f51dcae49e0996f578",
35
+ "agents/explorer.md": "21e0306e1184bd0adc9d5339f86f8e8e0125892054142047446c705581cf50d1",
36
+ "agents/worker.md": "6564dc9676687bc8f5a2b62691f4f3a776c0f5580bfa9f9bf3fe1c4831acd377",
37
+ "commands/cairn-doctor.md": "96af34769f64796d1b75837f4124b75f04da4363d80b521d387489d79a0a5ae3",
38
+ "commands/cairn-install.md": "0a6208ad7a3057bf7ee3af49654e009ae96154d25335f78706c9cca5d9482be9",
39
+ "commands/cairn-memory.md": "396e3d3d0863267aef2e9d450f6c664268aa53f3e61cfd759fd9a6d568b98aa5",
40
+ "commands/cairn-plan.md": "278da82240c8d19f11cf83d8da084f8b309443a8736f7e75503250a0a9b16e1e",
41
+ "commands/cairn-review.md": "1ce071e653ed06cfc915cae134aed8bc3391b3563af5bff5531289e292d90596",
42
+ "commands/cairn-toolcheck.md": "b0ce6737f69bb8e138b49b9b412c0cf0e9dc50534c31657eb0936f1513d5572c",
43
+ "commands/cairn-uninstall.md": "bf7005ef29817c19b39a21c4ffc440da0f0e694a0a09355c6e23777a8a291ab5",
44
+ "commands/cairn-upgrade.md": "593d1ceb3af93c94242ccb8de0a2601519072303f463be4961fd803e71e5d880",
45
+ "commands/cairn-work.md": "475de81bbf54c4deccccd7a63969efb20db6654ff34734f93daf95d4a8799e40",
46
+ "docs/model-guidance/README.md": "9291a6ca8a7717aefb39facf0dd085672e30fed2ba02461bd1e27efb0c6d1826",
47
+ "docs/model-guidance/claude.md": "7c8a3ff0e9d639735088d765794d168c43b87a6611dc89a6776a5e840ea2df19",
48
+ "docs/model-guidance/codex.md": "45eb20fcf35b9ecfc0ea7159235081c5afdce983d73174505079d3db8198ab5c",
49
+ "hooks/hooks.json": "5603c6533a461b53b332273ba2172a625fd6ae21d24e1ceb3a1c16862737b098",
50
+ "package.json": "d8e558f53ad4cd7d24a19d119aed27811a9ce88bde70d6423a7b6bd55e4e8802",
51
+ "scripts/cairn": "0df05a9c2480617eebd11d4e6299c3ff56fe3e243801c1fb55ab6a80a9006fe6",
52
+ "scripts/cairn-goal.mjs": "05f7277ba0ffa26ce5674259a6a4d40de01ffdb746645e7e32ff7c680ca214cd",
53
+ "scripts/cairn-lifecycle.mjs": "74b543bbb201514f4120f6de35b5d27643644b6a01f2ebf347c1f767c57f4109",
54
+ "scripts/cairn-paths.mjs": "815a33bc2ad6b29a3cd1c1db867f7c3e36e2913772e64dc53e563a2f9c30d674",
55
+ "scripts/cairn-state.mjs": "199b28e5ba3e9a296279866ba73f9769150537ceb90722b2958a1a0303511379",
56
+ "scripts/cairn-state.sh": "26f817a50a70b79aade3212ba89dbe0ac507998a4ce1565510624623b3899186",
57
+ "scripts/cairn-toolcheck.mjs": "141ec6572f81f9bf82b3e69b4b074e877ffa6166722faf3dd5b0a003a44e431d",
58
+ "scripts/cairn.mjs": "935048771c7393a0f5900ebc1a0501b0cfe2b63703febe9135c1fba541ca5169",
59
+ "skills/cairn-memory/SKILL.md": "0dcf32ceee7133df2889ea33e1b0856a0d5eedfd6d66ec43835c6b68846b66d1",
60
+ "skills/cairn-plan/SKILL.md": "a6997738a0311d0b9f40cf150e768f879af2577cbc4194595ed267fd42a91ac4",
61
+ "skills/cairn-review/SKILL.md": "52d2d2c3b96d6341f15350255acffa83e54ca3669a88f1c927811accb06f6a6a",
62
+ "skills/cairn-work/SKILL.md": "a20e47bb7cee345c526d619787e6ce054ea96806a51e8815a4329440237592be",
63
+ "templates/MEMORY.md": "a7791c4d400d3acec0dd813709c1dd8362ffc2ed2f9c407730b807b9370bdd75",
64
+ "templates/PLAN.md": "66a53a8f347949761cadfb95677f2e2ffca4e9dcc7b7129cdd70693cc558e00b",
65
+ "templates/domain-memory.md": "4a22556739a6068694c23eef723a6c40e41855884d420ed92623c3a5af42de49",
66
+ "templates/work-plan.md": "40458e17fbd1f8c307ba07638612131f996f711fd100d31e3fca06eeb8a8edb9"
67
+ }
68
+ }
@@ -41,6 +41,8 @@ node "<pluginRoot>/scripts/cairn.mjs" goal complete --quiet --root "<repoRoot>"
41
41
 
42
42
  `goal verify` executes the argv after `--` without a shell and records its exit code, bounded tool output, output digest, and watched-workspace fingerprint. The legacy `goal receipt` command imports declared evidence for compatibility; declared evidence does not satisfy a default tool-bound goal.
43
43
 
44
+ Verification defaults to 600,000 ms (10 minutes), accepts a shorter `--timeout-ms`, and is capped at 3,600,000 ms (1 hour). The external command never holds the state lock. A receipt is committed only when the starting goal/task identity and the pre/post watched fingerprint still match. Choose the watch set and verification command at the semantic boundary that proves the claimed behavior.
45
+
44
46
  Use `goal pause`, `goal block --reason "<concrete blocker>"`, or `goal cancel` when that is the truthful state. A task can be blocked with `goal task --task "<task-id>" --status blocked --reason "<concrete blocker>"`.
45
47
 
46
48
  ## Token-Efficient Test Contract
@@ -84,6 +84,8 @@ Describe the observable result.
84
84
  - Token budget: high reasoning for plan/triage/test design; bounded minimum implementation; concise tool-derived verification summaries.
85
85
  - Verification ladder: focused tests, one final full check, then package dry-run if no files changed. Inspect lifecycle scripts first; run normal `npm pack --dry-run` by default. Content-producing or unknown scripts must never use `--ignore-scripts`; only absent or proven content-neutral scripts may use it while evidence remains fresh.
86
86
  - Evidence freshness: relevant changes after verification make affected evidence stale.
87
+ - Verification execution: `goal verify` defaults to 600,000 ms (10 minutes), is capped at 3,600,000 ms (1 hour), and records only when the starting goal/task identity and pre/post watched fingerprint match.
88
+ - Evidence boundary: choose the semantic boundary whose watch set and command prove the claimed behavior.
87
89
  - Dry-run or check mode before external-state mutation:
88
90
  - No dry-run available, if applicable:
89
91
  - Verification loop budget: two passes per task by default.