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.
- package/.agents/workflows/cairn-work.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.de.md +10 -10
- package/README.es.md +10 -10
- package/README.fr.md +10 -10
- package/README.ja.md +10 -10
- package/README.ko.md +10 -10
- package/README.md +10 -10
- package/README.pt.md +10 -10
- package/README.zh.md +10 -10
- package/commands/cairn-doctor.md +5 -3
- package/commands/cairn-install.md +5 -4
- package/commands/cairn-uninstall.md +4 -2
- package/commands/cairn-upgrade.md +6 -6
- package/commands/cairn-work.md +2 -2
- package/docs/model-guidance/README.md +1 -1
- package/docs/model-guidance/codex.md +1 -1
- package/package.json +2 -2
- package/scripts/cairn-goal.mjs +149 -45
- package/scripts/cairn-lifecycle.mjs +926 -313
- package/scripts/cairn-safe-fs.mjs +246 -0
- package/scripts/cairn-state.mjs +24 -11
- package/scripts/cairn-toolcheck.mjs +116 -34
- package/scripts/release-integrity-0.2.2.json +68 -0
- package/skills/cairn-work/SKILL.md +2 -0
- package/templates/work-plan.md +2 -0
|
@@ -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
|
+
}
|
package/scripts/cairn-state.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import {
|
|
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
|
|
168
|
-
|
|
169
|
-
:
|
|
170
|
-
|
|
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
|
|
197
|
-
await
|
|
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
|
-
|
|
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
|
|
161
|
-
if (paths.some((path) => path
|
|
162
|
-
if (paths.some((path) => path
|
|
163
|
-
if (paths.some((path) => path
|
|
164
|
-
if (paths
|
|
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
|
|
167
|
-
if (paths.some((path) => path
|
|
168
|
-
if (paths.some((path) => path
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
else if (entries.
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
package/templates/work-plan.md
CHANGED
|
@@ -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.
|