cairn-ai 0.2.6 → 0.2.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/.agents/workflows/cairn-memory.md +1 -1
- package/.agents/workflows/cairn-plan.md +2 -2
- package/.agents/workflows/cairn-review.md +1 -1
- package/.agents/workflows/cairn-work.md +3 -3
- package/.claude/agents/explorer.md +1 -1
- package/.claude/agents/worker.md +1 -1
- package/.claude/commands/cairn-memory.md +1 -1
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-review.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.de.md +3 -2
- package/README.es.md +3 -2
- package/README.fr.md +3 -2
- package/README.ja.md +3 -2
- package/README.ko.md +7 -6
- package/README.md +5 -6
- package/README.pt.md +3 -2
- package/README.zh.md +3 -2
- package/agents/worker.md +1 -1
- package/commands/cairn-install.md +2 -1
- package/commands/cairn-memory.md +1 -1
- package/commands/cairn-plan.md +2 -2
- package/commands/cairn-review.md +1 -1
- package/commands/cairn-work.md +3 -3
- package/docs/model-guidance/README.md +4 -4
- package/docs/model-guidance/claude.md +1 -1
- package/docs/model-guidance/codex.md +2 -2
- package/package.json +4 -2
- package/scripts/cairn-cleanup.mjs +12 -6
- package/scripts/cairn-goal-cli.mjs +233 -0
- package/scripts/cairn-goal-evidence.mjs +179 -0
- package/scripts/cairn-goal-operations-evidence.mjs +124 -0
- package/scripts/cairn-goal-operations-mutations.mjs +63 -0
- package/scripts/cairn-goal-operations.mjs +177 -0
- package/scripts/cairn-goal-policy.mjs +33 -0
- package/scripts/cairn-goal-state-validation.mjs +199 -0
- package/scripts/cairn-goal-state.mjs +89 -0
- package/scripts/cairn-goal.mjs +32 -1078
- package/scripts/cairn-lifecycle-commands.mjs +200 -0
- package/scripts/cairn-lifecycle-config.mjs +149 -0
- package/scripts/cairn-lifecycle-integrity.mjs +218 -0
- package/scripts/cairn-lifecycle-legacy.mjs +142 -0
- package/scripts/cairn-lifecycle-lock.mjs +205 -0
- package/scripts/cairn-lifecycle-mirror.mjs +91 -0
- package/scripts/cairn-lifecycle-staging.mjs +152 -0
- package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
- package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/cairn-lifecycle-transaction.mjs +12 -0
- package/scripts/cairn-lifecycle.mjs +103 -1185
- package/scripts/cairn-paths.mjs +35 -5
- package/scripts/cairn-safe-fs.mjs +61 -35
- package/scripts/cairn-state-context.mjs +148 -0
- package/scripts/cairn-state-hook.mjs +24 -0
- package/scripts/cairn-state-init.mjs +137 -0
- package/scripts/cairn-state.mjs +6 -286
- package/scripts/cairn-toolcheck-detect.mjs +229 -0
- package/scripts/cairn-toolcheck-runtime.mjs +178 -0
- package/scripts/cairn-toolcheck.mjs +79 -424
- package/scripts/cairn.mjs +17 -3
- package/skills/cairn-memory/SKILL.md +1 -1
- package/skills/cairn-plan/SKILL.md +3 -3
- package/skills/cairn-review/SKILL.md +1 -1
- package/skills/cairn-work/SKILL.md +4 -4
- package/scripts/cairn-state.sh +0 -167
|
@@ -19,7 +19,7 @@ Codex-family models are strong at small implementation tasks, explicit tool use,
|
|
|
19
19
|
|
|
20
20
|
- On every fresh entry, compaction recovery, restart, delegation, or handoff, restore optional root `MEMORY.md` → current phase skill → active plan → current-task references → recorded model guidance. Missing `MEMORY.md` never blocks and must not trigger another memory service. If persisted state or the assignment requires another missing, unreadable, or inconsistent reference, fail closed and report a blocker before editing, delegating, approving, or completing.
|
|
21
21
|
- Do not start implementation without a plan.
|
|
22
|
-
- Keep the model inherited.
|
|
22
|
+
- Keep the model inherited. `Requested reasoning effort` is Cairn's desired profile; `Effective reasoning effort` is what the dispatch response confirms. Only a newly dispatched task or worker may receive the requested effort through the host's task/subagent option, with no model override. For an unsupported host or value, record Effective `inherited`; this means no task override was confirmed, not proof of missing host capability. Infer capability only from the exposed interface or dispatch response, distinguish Cairn non-integration from host non-support, and leave model/global configuration unchanged.
|
|
23
23
|
- Do not treat complexity as a one-time choice. Require the provisional request checkpoint, post-exploration planning checkpoint, and code checkpoint after exact file/caller/test inspection immediately before the first edit. Before editing, evidence may change either route. Every change must synchronize the plan artifact, repository goal task roadmap through `goal replan`, and native UI plan, including reviews and required evidence. After editing begins, a new Heavy signal promotes Light Path to Heavy Path: stop further edits, mark affected evidence stale, synchronize all three roadmaps, and repeat the code checkpoint.
|
|
24
24
|
- Do not stop before local implementation solely because subagent tools are unavailable; the main agent takes over implementation directly and records that takeover in evidence.
|
|
25
25
|
- Keep work units small. Split large changes into smaller plan tasks.
|
|
@@ -46,7 +46,7 @@ Codex-family models are strong at small implementation tasks, explicit tool use,
|
|
|
46
46
|
### Implementation
|
|
47
47
|
|
|
48
48
|
- Invest Codex reasoning in executable test design first, then hand the bounded failing contract to implementation and require the minimum passing patch.
|
|
49
|
-
- Run verification through `goal verify -- ...`. Its default timeout is 600,000 ms (10 minutes), capped at 3,600,000 ms (1 hour). On success, retain exact argv, tool exit code, pass count, output digest, and watched-workspace fingerprint; commit evidence only when the starting goal/task identity and pre/post watched fingerprint match. Choose the watch set and command at the semantic boundary that proves the claim. Expand diagnostics only for the failing test and its related file/symbol scope; reject stale evidence after related changes.
|
|
49
|
+
- Run verification through `goal verify -- ...`. Its default timeout is 600,000 ms (10 minutes), capped at 3,600,000 ms (1 hour). On success, retain exact argv, tool exit code, pass count, output digest, and watched-workspace fingerprint; commit evidence only when the starting goal/task identity and pre/post watched fingerprint match. Choose the watch set and command at the semantic boundary that proves the claim. The default Git fingerprint excludes ignored files; explicitly `--watch` every ignored artifact needed by the claim. Expand diagnostics only for the failing test and its related file/symbol scope; reject stale evidence after related changes.
|
|
50
50
|
- If acting as the user-called/main agent, orchestrate the work and delegate actual implementation edits to `worker` whenever subagent tools are available, regardless of Light Path or Heavy Path.
|
|
51
51
|
- If acting as the user-called/main agent, immediately relay received subagent status events to the user. If no mid-run reporting channel exists, relay observable events such as assignment, waiting, and final completion.
|
|
52
52
|
- If acting as the user-called/main agent, close or release completed subagents after capturing their final report and evidence, then review the final report and evidence before marking the work complete.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cairn-ai",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Token-efficient agent harness with tool readiness for Codex, Claude Code, and Antigravity",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
"commands/",
|
|
33
33
|
"docs/model-guidance/",
|
|
34
34
|
"hooks/",
|
|
35
|
-
"scripts/",
|
|
35
|
+
"scripts/cairn",
|
|
36
|
+
"scripts/*.mjs",
|
|
37
|
+
"scripts/release-integrity-*.json",
|
|
36
38
|
"skills/",
|
|
37
39
|
"templates/",
|
|
38
40
|
"README.md",
|
|
@@ -5,24 +5,28 @@ import { pathToFileURL } from "node:url";
|
|
|
5
5
|
import { reconcileWorktreeState } from "./cairn-goal.mjs";
|
|
6
6
|
import { parseRootArgs } from "./cairn-paths.mjs";
|
|
7
7
|
|
|
8
|
-
const
|
|
8
|
+
const staleLockName = /^state\.lock\.stale\..+\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
9
9
|
|
|
10
10
|
export async function cleanupProjectCairn({ root = process.cwd(), apply = false } = {}) {
|
|
11
11
|
const repoRoot = resolve(root);
|
|
12
12
|
const legacyRoot = join(repoRoot, ".cairn");
|
|
13
|
-
let
|
|
13
|
+
let rootStat;
|
|
14
14
|
try {
|
|
15
|
-
|
|
15
|
+
rootStat = await lstat(legacyRoot);
|
|
16
16
|
} catch (error) {
|
|
17
17
|
if (error?.code === "ENOENT") return { root: repoRoot, apply, known: [], unknown: [], removed: [], projectDirectoryRemoved: false };
|
|
18
18
|
throw error;
|
|
19
19
|
}
|
|
20
|
+
if (rootStat.isSymbolicLink()) throw new Error(`Legacy Cairn root must not be a symbolic link: ${legacyRoot}`);
|
|
21
|
+
if (!rootStat.isDirectory()) throw new Error(`Legacy Cairn root is not a directory: ${legacyRoot}`);
|
|
20
22
|
|
|
23
|
+
const entries = await readdir(legacyRoot, { withFileTypes: true });
|
|
21
24
|
const known = [];
|
|
22
25
|
const unknown = [];
|
|
23
26
|
for (const entry of entries) {
|
|
24
|
-
const
|
|
25
|
-
|
|
27
|
+
const isKnownFile = entry.isFile() && (entry.name === "state.json" || entry.name === "state.lock" || staleLockName.test(entry.name));
|
|
28
|
+
const isKnownDirectory = entry.isDirectory() && entry.name === "tools";
|
|
29
|
+
(isKnownFile || isKnownDirectory ? known : unknown).push(entry.name);
|
|
26
30
|
}
|
|
27
31
|
if (!apply) return { root: repoRoot, apply, known: known.sort(), unknown: unknown.sort(), removed: [], projectDirectoryRemoved: false };
|
|
28
32
|
|
|
@@ -32,7 +36,9 @@ export async function cleanupProjectCairn({ root = process.cwd(), apply = false
|
|
|
32
36
|
const path = join(legacyRoot, name);
|
|
33
37
|
try {
|
|
34
38
|
const stat = await lstat(path);
|
|
35
|
-
|
|
39
|
+
const expectedType = name === "tools" ? stat.isDirectory() : stat.isFile();
|
|
40
|
+
if (!expectedType) continue;
|
|
41
|
+
await rm(path, { recursive: name === "tools", force: true });
|
|
36
42
|
removed.push(name);
|
|
37
43
|
} catch (error) {
|
|
38
44
|
if (error?.code !== "ENOENT") throw error;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
export async function runGoalCli(args, {
|
|
2
|
+
stdout = console.log,
|
|
3
|
+
defaultGoalEvidence,
|
|
4
|
+
operations,
|
|
5
|
+
}) {
|
|
6
|
+
const [firstCommand = "help", ...remaining] = args;
|
|
7
|
+
const [command = "help", ...rest] = firstCommand === "goal" ? remaining : [firstCommand, ...remaining];
|
|
8
|
+
const { options, positional, passthrough } = parseArgs(rest, command);
|
|
9
|
+
const root = options.root ?? process.cwd();
|
|
10
|
+
const emit = options.quiet ? () => {} : stdout;
|
|
11
|
+
if (command === "start") {
|
|
12
|
+
const tasks = parseTasks(options.tasks ?? positional.slice(0));
|
|
13
|
+
const state = await operations.startGoal({
|
|
14
|
+
root,
|
|
15
|
+
goal: options.goal,
|
|
16
|
+
planId: options.plan,
|
|
17
|
+
tasks,
|
|
18
|
+
completionCriteria: splitValues(options.criteria),
|
|
19
|
+
requiredEvidence: options.requiredEvidence ? splitValues(options.requiredEvidence) : defaultGoalEvidence,
|
|
20
|
+
ownerSessionId: options.session,
|
|
21
|
+
evidencePolicy: options.evidencePolicy ?? options["evidence-policy"] ?? "tool-bound",
|
|
22
|
+
});
|
|
23
|
+
emit(JSON.stringify(state));
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
if (command === "status") {
|
|
27
|
+
const state = await operations.readGoalState({ root });
|
|
28
|
+
stdout(JSON.stringify(state));
|
|
29
|
+
return state;
|
|
30
|
+
}
|
|
31
|
+
if (command === "task") {
|
|
32
|
+
if (positional[0] === "status" && options.status === undefined) {
|
|
33
|
+
const state = await operations.readGoalState({ root });
|
|
34
|
+
if (!state) {
|
|
35
|
+
stdout(JSON.stringify(null));
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const requestedTaskId = options.task ?? positional[1];
|
|
39
|
+
const task = requestedTaskId === undefined
|
|
40
|
+
? state.tasks.find((candidate) => candidate.status === "active") ?? null
|
|
41
|
+
: state.tasks.find((candidate) => candidate.id === requestedTaskId);
|
|
42
|
+
if (requestedTaskId !== undefined && task === undefined) throw new Error(`Unknown task: ${requestedTaskId}`);
|
|
43
|
+
stdout(JSON.stringify(task));
|
|
44
|
+
return task;
|
|
45
|
+
}
|
|
46
|
+
const taskAction = taskStatusFromAction(positional[0]);
|
|
47
|
+
const state = await operations.setTaskStatus({
|
|
48
|
+
root,
|
|
49
|
+
taskId: options.task ?? (taskAction ? positional[1] : positional[0]),
|
|
50
|
+
status: options.status ?? taskAction ?? positional[1],
|
|
51
|
+
blocker: options.reason,
|
|
52
|
+
});
|
|
53
|
+
emit(JSON.stringify(state));
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
if (command === "replan") {
|
|
57
|
+
const state = await operations.replanGoal({ root, tasks: parseTasks(options.tasks ?? positional.slice(0)) });
|
|
58
|
+
emit(JSON.stringify(state));
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
if (command === "assign") {
|
|
62
|
+
const state = await operations.assignTask({
|
|
63
|
+
root,
|
|
64
|
+
taskId: options.task ?? positional[0],
|
|
65
|
+
agentId: options.agent ?? positional[1] ?? null,
|
|
66
|
+
});
|
|
67
|
+
emit(JSON.stringify(state));
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
70
|
+
if (command === "receipt") {
|
|
71
|
+
const state = await operations.recordReceipt({
|
|
72
|
+
root,
|
|
73
|
+
taskId: options.task ?? positional[0],
|
|
74
|
+
kind: options.kind,
|
|
75
|
+
scope: options.scope,
|
|
76
|
+
command: options.command,
|
|
77
|
+
exitCode: numberOption(options.exitCode ?? options["exit-code"] ?? options.exit),
|
|
78
|
+
timestamp: options.timestamp,
|
|
79
|
+
goalId: options.goalId ?? options["goal-id"],
|
|
80
|
+
planId: options.plan,
|
|
81
|
+
});
|
|
82
|
+
emit(JSON.stringify(state));
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
85
|
+
if (command === "verify") {
|
|
86
|
+
const result = await operations.verifyAndRecord({
|
|
87
|
+
root,
|
|
88
|
+
taskId: options.task ?? positional[0],
|
|
89
|
+
kind: options.kind,
|
|
90
|
+
scope: options.scope,
|
|
91
|
+
watchPaths: splitValues(options.watch),
|
|
92
|
+
argv: passthrough,
|
|
93
|
+
timeoutMs: options["timeout-ms"] === undefined
|
|
94
|
+
? 600_000
|
|
95
|
+
: positiveIntegerOption(options["timeout-ms"], "timeout-ms"),
|
|
96
|
+
});
|
|
97
|
+
emit(JSON.stringify({
|
|
98
|
+
id: result.evidence.id,
|
|
99
|
+
scope: result.evidence.scope,
|
|
100
|
+
kind: result.evidence.kind,
|
|
101
|
+
exitCode: result.evidence.exitCode,
|
|
102
|
+
outputDigest: result.evidence.outputDigest,
|
|
103
|
+
workspaceFingerprint: result.evidence.workspaceFingerprint,
|
|
104
|
+
}));
|
|
105
|
+
return result.state;
|
|
106
|
+
}
|
|
107
|
+
if (["pause", "resume", "block", "cancel", "complete"].includes(command)) {
|
|
108
|
+
const state = await operations.transitionGoal({
|
|
109
|
+
root,
|
|
110
|
+
status: { pause: "paused", resume: "active", block: "blocked", cancel: "cancelled", complete: "completed" }[command],
|
|
111
|
+
blocker: options.reason,
|
|
112
|
+
});
|
|
113
|
+
emit(JSON.stringify(state));
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
throw new Error("Usage: cairn-goal start|status|task|replan|assign|receipt|verify|pause|resume|block|cancel|complete [--root PATH]");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseArgs(args, command) {
|
|
120
|
+
const options = {};
|
|
121
|
+
const positional = [];
|
|
122
|
+
const passthrough = [];
|
|
123
|
+
const booleanOptions = new Set(["quiet"]);
|
|
124
|
+
const allowed = allowedOptions(command);
|
|
125
|
+
const seenCanonicalOptions = new Set();
|
|
126
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
127
|
+
const value = args[index];
|
|
128
|
+
if (value === "--") {
|
|
129
|
+
passthrough.push(...args.slice(index + 1));
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (!value.startsWith("--")) {
|
|
133
|
+
positional.push(value);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const option = value.slice(2);
|
|
137
|
+
const equalsIndex = option.indexOf("=");
|
|
138
|
+
const key = equalsIndex === -1 ? option : option.slice(0, equalsIndex);
|
|
139
|
+
const inline = equalsIndex === -1 ? undefined : option.slice(equalsIndex + 1);
|
|
140
|
+
if (!allowed.has(key)) throw new Error(`Unknown option for ${command}: --${key}`);
|
|
141
|
+
const canonicalKey = canonicalOptionKey(key);
|
|
142
|
+
if (seenCanonicalOptions.has(canonicalKey)) throw new Error(`--${canonicalKey} may only be provided once`);
|
|
143
|
+
seenCanonicalOptions.add(canonicalKey);
|
|
144
|
+
if (inline !== undefined) {
|
|
145
|
+
if (!booleanOptions.has(key) && inline.length === 0) throw new Error(`--${key} requires a value`);
|
|
146
|
+
if (booleanOptions.has(key) && !["true", "false"].includes(inline)) throw new Error(`--${key} must be true or false`);
|
|
147
|
+
options[key] = booleanOptions.has(key) ? inline !== "false" : inline;
|
|
148
|
+
} else if (booleanOptions.has(key)) {
|
|
149
|
+
options[key] = true;
|
|
150
|
+
} else {
|
|
151
|
+
const next = args[index + 1];
|
|
152
|
+
if (next === undefined || next === "--" || next.startsWith("--")) throw new Error(`--${key} requires a value`);
|
|
153
|
+
options[key] = next;
|
|
154
|
+
index += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { options, positional, passthrough };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function canonicalOptionKey(key) {
|
|
161
|
+
return {
|
|
162
|
+
exitCode: "exit-code",
|
|
163
|
+
exit: "exit-code",
|
|
164
|
+
goalId: "goal-id",
|
|
165
|
+
evidencePolicy: "evidence-policy",
|
|
166
|
+
}[key] ?? key;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function allowedOptions(command) {
|
|
170
|
+
const common = ["root", "quiet"];
|
|
171
|
+
const byCommand = {
|
|
172
|
+
start: ["goal", "plan", "tasks", "criteria", "requiredEvidence", "session", "evidencePolicy", "evidence-policy"],
|
|
173
|
+
status: [],
|
|
174
|
+
task: ["task", "status", "reason"],
|
|
175
|
+
replan: ["tasks"],
|
|
176
|
+
assign: ["task", "agent"],
|
|
177
|
+
receipt: ["task", "kind", "scope", "command", "exitCode", "exit-code", "exit", "timestamp", "goalId", "goal-id", "plan"],
|
|
178
|
+
verify: ["task", "kind", "scope", "watch", "timeout-ms"],
|
|
179
|
+
pause: [],
|
|
180
|
+
resume: [],
|
|
181
|
+
block: ["reason"],
|
|
182
|
+
cancel: [],
|
|
183
|
+
complete: [],
|
|
184
|
+
help: [],
|
|
185
|
+
};
|
|
186
|
+
return new Set([...common, ...(byCommand[command] ?? [])]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseTasks(value) {
|
|
190
|
+
if (Array.isArray(value)) return value;
|
|
191
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error("Provide tasks with --tasks JSON or positional task titles");
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(value);
|
|
194
|
+
if (!Array.isArray(parsed)) throw new Error("--tasks JSON must be an array");
|
|
195
|
+
return parsed;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof SyntaxError) return value.split("|").map((item) => item.trim()).filter(Boolean);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function splitValues(value) {
|
|
203
|
+
if (!value) return [];
|
|
204
|
+
return String(value).split("|").map((item) => item.trim()).filter(Boolean);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function numberOption(value) {
|
|
208
|
+
if (value === undefined) return undefined;
|
|
209
|
+
const result = Number(value);
|
|
210
|
+
if (!Number.isInteger(result)) throw new Error("exitCode must be an integer");
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function positiveIntegerOption(value, label) {
|
|
215
|
+
const result = Number(value);
|
|
216
|
+
if (!Number.isSafeInteger(result) || result <= 0 || result > 3_600_000) {
|
|
217
|
+
throw new Error(`${label} must be an integer between 1 and 3600000`);
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function taskStatusFromAction(value) {
|
|
223
|
+
return {
|
|
224
|
+
start: "active",
|
|
225
|
+
activate: "active",
|
|
226
|
+
active: "active",
|
|
227
|
+
complete: "completed",
|
|
228
|
+
completed: "completed",
|
|
229
|
+
block: "blocked",
|
|
230
|
+
blocked: "blocked",
|
|
231
|
+
pending: "pending",
|
|
232
|
+
}[value] ?? null;
|
|
233
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { lstatSync, readFileSync, readlinkSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
export function normalizeVerificationArgv(argv) {
|
|
7
|
+
if (!Array.isArray(argv) || argv.length === 0) throw new Error("verification command must follow -- as argv");
|
|
8
|
+
return argv.map((item, index) => {
|
|
9
|
+
if (typeof item !== "string") throw new Error(`verification argv[${index}] must be a string`);
|
|
10
|
+
if (index === 0 && item.trim().length === 0) throw new Error("verification executable must be a non-empty string");
|
|
11
|
+
return item;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeWatchPaths(root, watchPaths) {
|
|
16
|
+
if (!Array.isArray(watchPaths)) throw new Error("watchPaths must be an array");
|
|
17
|
+
const workspaceRoot = resolve(root);
|
|
18
|
+
return [...new Set(watchPaths.map((item, index) => {
|
|
19
|
+
const source = requiredText(item, `watchPaths[${index}]`);
|
|
20
|
+
const absolutePath = resolve(workspaceRoot, source);
|
|
21
|
+
const relativePath = relative(workspaceRoot, absolutePath);
|
|
22
|
+
if (relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
|
|
23
|
+
throw new Error(`watch path must remain inside the workspace: ${source}`);
|
|
24
|
+
}
|
|
25
|
+
const normalizedPath = relativePath === "" ? "." : relativePath.split(sep).join("/");
|
|
26
|
+
if (isInternalStatePath(normalizedPath)) throw new Error(`watch path cannot target internal Cairn or Git state: ${source}`);
|
|
27
|
+
return normalizedPath;
|
|
28
|
+
}))];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function workspaceFingerprint(root = process.cwd(), watchPaths = []) {
|
|
32
|
+
const workspaceRoot = resolve(root);
|
|
33
|
+
const normalizedWatchPaths = normalizeWatchPaths(workspaceRoot, watchPaths);
|
|
34
|
+
const gitPaths = normalizedWatchPaths.length === 0 ? listGitWorkspacePaths(workspaceRoot) : null;
|
|
35
|
+
const paths = normalizedWatchPaths.length > 0
|
|
36
|
+
? listFilesystemPaths(workspaceRoot, normalizedWatchPaths)
|
|
37
|
+
: (gitPaths ?? listFilesystemPaths(workspaceRoot, normalizedWatchPaths));
|
|
38
|
+
const hash = createHash("sha256");
|
|
39
|
+
for (const path of [...new Set(paths)].sort()) hashWorkspacePath(hash, workspaceRoot, path);
|
|
40
|
+
return `sha256:${hash.digest("hex")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function runVerificationEvidence({
|
|
44
|
+
root,
|
|
45
|
+
argv,
|
|
46
|
+
watchPaths,
|
|
47
|
+
runner = spawnSync,
|
|
48
|
+
timeoutMs,
|
|
49
|
+
}) {
|
|
50
|
+
const initialFingerprint = workspaceFingerprint(root, watchPaths);
|
|
51
|
+
const result = runner(argv[0], argv.slice(1), {
|
|
52
|
+
cwd: resolve(root),
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
55
|
+
shell: false,
|
|
56
|
+
timeout: validTimeout(timeoutMs),
|
|
57
|
+
});
|
|
58
|
+
if (result.error?.code === "ETIMEDOUT") throw new Error(`Verification timed out after ${timeoutMs}ms`);
|
|
59
|
+
if (result.error) throw new Error(`Verification could not start: ${result.error.message}`);
|
|
60
|
+
const exitCode = Number.isInteger(result.status) ? result.status : 1;
|
|
61
|
+
const output = combinedOutput(result.stdout, result.stderr);
|
|
62
|
+
if (exitCode !== 0) {
|
|
63
|
+
const detail = boundedText(output, 2000);
|
|
64
|
+
throw new Error(`Verification failed with exit ${exitCode}${detail ? `:\n${detail}` : ""}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const fingerprint = workspaceFingerprint(root, watchPaths);
|
|
68
|
+
if (fingerprint !== initialFingerprint) throw new Error("Watched workspace changed during verification; evidence was not recorded");
|
|
69
|
+
return {
|
|
70
|
+
argv,
|
|
71
|
+
command: argv.map((value) => JSON.stringify(value)).join(" "),
|
|
72
|
+
outputDigest: sha256(output),
|
|
73
|
+
summary: successSummary(output),
|
|
74
|
+
workspaceFingerprint: fingerprint,
|
|
75
|
+
watchPaths,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function listGitWorkspacePaths(root) {
|
|
80
|
+
const result = spawnSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
83
|
+
});
|
|
84
|
+
if (result.status !== 0 || result.error) return null;
|
|
85
|
+
return result.stdout.split("\0").filter(Boolean).filter((path) => !isInternalStatePath(path));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function listFilesystemPaths(root, watchPaths) {
|
|
89
|
+
const paths = [];
|
|
90
|
+
const visit = (relativePath) => {
|
|
91
|
+
if (isInternalStatePath(relativePath)) return;
|
|
92
|
+
const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
|
|
93
|
+
let stat;
|
|
94
|
+
try {
|
|
95
|
+
stat = lstatSync(absolutePath);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error?.code === "ENOENT") {
|
|
98
|
+
paths.push(relativePath);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isDirectory()) {
|
|
104
|
+
paths.push(relativePath);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const entries = readdirSync(absolutePath).sort();
|
|
108
|
+
if (entries.length === 0) paths.push(relativePath);
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
const child = relativePath === "." ? entry : `${relativePath}/${entry}`;
|
|
111
|
+
visit(child);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
for (const path of watchPaths.length > 0 ? watchPaths : ["."]) visit(path);
|
|
115
|
+
return paths;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hashWorkspacePath(hash, root, relativePath) {
|
|
119
|
+
const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
|
|
120
|
+
hash.update(`${relativePath}\0`);
|
|
121
|
+
let stat;
|
|
122
|
+
try {
|
|
123
|
+
stat = lstatSync(absolutePath);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error?.code === "ENOENT") {
|
|
126
|
+
hash.update("missing\0");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
hash.update(`${stat.mode}\0`);
|
|
132
|
+
if (stat.isSymbolicLink()) {
|
|
133
|
+
hash.update(`symlink\0${readlinkSync(absolutePath)}\0`);
|
|
134
|
+
} else if (stat.isFile()) {
|
|
135
|
+
hash.update("file\0");
|
|
136
|
+
hash.update(readFileSync(absolutePath));
|
|
137
|
+
hash.update("\0");
|
|
138
|
+
} else if (stat.isDirectory()) {
|
|
139
|
+
hash.update("directory\0");
|
|
140
|
+
} else {
|
|
141
|
+
hash.update("other\0");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isInternalStatePath(path) {
|
|
146
|
+
const firstSegment = path.split("/")[0];
|
|
147
|
+
return firstSegment === ".git" || firstSegment === ".cairn";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function requiredText(value, label) {
|
|
151
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${label} must be a non-empty string`);
|
|
152
|
+
return value.trim();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function combinedOutput(stdout, stderr) {
|
|
156
|
+
return [stdout, stderr].filter((value) => typeof value === "string" && value.length > 0).join("\n").trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function boundedText(value, maxLength) {
|
|
160
|
+
const text = String(value ?? "").trim();
|
|
161
|
+
if (text.length <= maxLength) return text;
|
|
162
|
+
return `…${text.slice(-(maxLength - 1))}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function successSummary(output) {
|
|
166
|
+
const text = String(output ?? "");
|
|
167
|
+
const outputBytes = Buffer.byteLength(text);
|
|
168
|
+
const outputLines = text.length === 0 ? 0 : text.split(/\r?\n/).length;
|
|
169
|
+
return `verification passed; outputBytes=${outputBytes}; outputLines=${outputLines}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sha256(value) {
|
|
173
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validTimeout(value) {
|
|
177
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error("timeoutMs must be a positive integer");
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { normalizeVerificationArgv, normalizeWatchPaths, runVerificationEvidence, workspaceFingerprint } from "./cairn-goal-evidence.mjs";
|
|
3
|
+
import { readGoalState } from "./cairn-goal-state.mjs";
|
|
4
|
+
import {
|
|
5
|
+
normalizeToolEvidence, requiredText, timestamp, validReceiptCommand, validReceiptScope,
|
|
6
|
+
validReceiptSource, validTimestamp,
|
|
7
|
+
} from "./cairn-goal-state-validation.mjs";
|
|
8
|
+
import { assertGoalMutable, mutateGoal, taskById } from "./cairn-goal-operations-mutations.mjs";
|
|
9
|
+
|
|
10
|
+
export async function recordReceipt(options = {}) {
|
|
11
|
+
if (options.source !== undefined && options.source !== "declared") throw new Error("Only goal verify can create tool evidence");
|
|
12
|
+
return recordEvidence({ ...options, source: "declared" });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function recordEvidence({
|
|
16
|
+
root = process.cwd(), taskId, kind, scope = "task", command, exitCode,
|
|
17
|
+
timestamp: receiptTimestamp, goalId, planId, source, argv, outputDigest,
|
|
18
|
+
summary, workspaceFingerprint: fingerprint, watchPaths = [], expectedIdentity,
|
|
19
|
+
expectedFingerprint,
|
|
20
|
+
} = {}) {
|
|
21
|
+
const normalizedScope = validReceiptScope(scope);
|
|
22
|
+
const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
|
|
23
|
+
const normalizedKind = requiredText(kind, "evidence kind");
|
|
24
|
+
const normalizedCommand = validReceiptCommand(command);
|
|
25
|
+
const normalizedSource = validReceiptSource(source);
|
|
26
|
+
if (exitCode !== 0) throw new Error("evidence exitCode must be 0");
|
|
27
|
+
const normalizedTimestamp = validTimestamp(receiptTimestamp ?? timestamp());
|
|
28
|
+
const toolFields = normalizedSource === "tool" ? normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths }) : {};
|
|
29
|
+
return mutateGoal(root, (state) => {
|
|
30
|
+
assertGoalMutable(state);
|
|
31
|
+
const task = normalizedScope === "task" ? taskById(state, normalizedTaskId) : null;
|
|
32
|
+
if (expectedIdentity && (state.goal.id !== expectedIdentity.goalId || state.goal.planId !== expectedIdentity.planId)) {
|
|
33
|
+
throw new Error("Goal identity changed during verification; evidence was not recorded");
|
|
34
|
+
}
|
|
35
|
+
if (expectedIdentity && normalizedScope === "task" && (
|
|
36
|
+
task?.id !== expectedIdentity.taskId
|
|
37
|
+
|| task?.status !== expectedIdentity.taskStatus
|
|
38
|
+
|| task?.assignedAgentId !== expectedIdentity.taskAgentId
|
|
39
|
+
)) throw new Error("Verification task changed during verification; evidence was not recorded");
|
|
40
|
+
if (expectedFingerprint && workspaceFingerprint(root, watchPaths) !== expectedFingerprint) {
|
|
41
|
+
throw new Error("Watched workspace changed during verification; evidence was not recorded");
|
|
42
|
+
}
|
|
43
|
+
if (goalId !== undefined && goalId !== state.goal.id) throw new Error("evidence goalId does not match the active goal");
|
|
44
|
+
if (planId !== undefined && planId !== state.goal.planId) throw new Error("evidence planId does not match the active goal");
|
|
45
|
+
const receipt = {
|
|
46
|
+
id: `receipt-${randomUUID()}`,
|
|
47
|
+
scope: normalizedScope,
|
|
48
|
+
kind: normalizedKind,
|
|
49
|
+
...(task ? { taskId: task.id } : {}),
|
|
50
|
+
goalId: state.goal.id,
|
|
51
|
+
planId: state.goal.planId,
|
|
52
|
+
command: normalizedCommand,
|
|
53
|
+
exitCode: 0,
|
|
54
|
+
timestamp: normalizedTimestamp,
|
|
55
|
+
source: normalizedSource,
|
|
56
|
+
...toolFields,
|
|
57
|
+
};
|
|
58
|
+
state.receipts.push(receipt);
|
|
59
|
+
if (task) task.updatedAt = timestamp();
|
|
60
|
+
state.goal.updatedAt = timestamp();
|
|
61
|
+
return state;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function verifyAndRecord({
|
|
66
|
+
root = process.cwd(), taskId, kind, scope = "task", argv, watchPaths = [],
|
|
67
|
+
runner, timeoutMs = 600_000,
|
|
68
|
+
} = {}) {
|
|
69
|
+
const normalizedArgv = normalizeVerificationArgv(argv);
|
|
70
|
+
const normalizedWatchPaths = normalizeWatchPaths(root, watchPaths);
|
|
71
|
+
const normalizedScope = validReceiptScope(scope);
|
|
72
|
+
const normalizedKind = requiredText(kind, "evidence kind");
|
|
73
|
+
const initialState = await readGoalState({ root });
|
|
74
|
+
if (!initialState) throw new Error("No Cairn goal state exists; start a goal first");
|
|
75
|
+
assertGoalMutable(initialState);
|
|
76
|
+
const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
|
|
77
|
+
const initialTask = normalizedScope === "task" ? taskById(initialState, normalizedTaskId) : null;
|
|
78
|
+
if (initialTask && !["active", "completed"].includes(initialTask.status)) {
|
|
79
|
+
throw new Error(`Verification task must be active or completed: ${initialTask.id}`);
|
|
80
|
+
}
|
|
81
|
+
const identity = {
|
|
82
|
+
goalId: initialState.goal.id,
|
|
83
|
+
planId: initialState.goal.planId,
|
|
84
|
+
taskId: normalizedTaskId,
|
|
85
|
+
taskStatus: initialTask?.status,
|
|
86
|
+
taskAgentId: initialTask?.assignedAgentId,
|
|
87
|
+
};
|
|
88
|
+
const evidence = runVerificationEvidence({
|
|
89
|
+
root,
|
|
90
|
+
argv: normalizedArgv,
|
|
91
|
+
watchPaths: normalizedWatchPaths,
|
|
92
|
+
runner,
|
|
93
|
+
timeoutMs,
|
|
94
|
+
});
|
|
95
|
+
const postState = await readGoalState({ root });
|
|
96
|
+
if (!postState || postState.goal.id !== identity.goalId || postState.goal.planId !== identity.planId) {
|
|
97
|
+
throw new Error("Goal identity changed during verification; evidence was not recorded");
|
|
98
|
+
}
|
|
99
|
+
if (normalizedScope === "task") {
|
|
100
|
+
const postTask = taskById(postState, normalizedTaskId);
|
|
101
|
+
if (postTask.status !== identity.taskStatus || postTask.assignedAgentId !== identity.taskAgentId) {
|
|
102
|
+
throw new Error("Verification task changed during verification; evidence was not recorded");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const state = await recordEvidence({
|
|
106
|
+
root,
|
|
107
|
+
taskId: normalizedTaskId,
|
|
108
|
+
kind: normalizedKind,
|
|
109
|
+
scope: normalizedScope,
|
|
110
|
+
command: evidence.command,
|
|
111
|
+
exitCode: 0,
|
|
112
|
+
source: "tool",
|
|
113
|
+
argv: evidence.argv,
|
|
114
|
+
outputDigest: evidence.outputDigest,
|
|
115
|
+
summary: evidence.summary,
|
|
116
|
+
workspaceFingerprint: evidence.workspaceFingerprint,
|
|
117
|
+
watchPaths: evidence.watchPaths,
|
|
118
|
+
goalId: identity.goalId,
|
|
119
|
+
planId: identity.planId,
|
|
120
|
+
expectedIdentity: identity,
|
|
121
|
+
expectedFingerprint: evidence.workspaceFingerprint,
|
|
122
|
+
});
|
|
123
|
+
return { state, evidence: state.receipts.at(-1) };
|
|
124
|
+
}
|