@cairnkeep/cli 2.3.0 → 2.4.0
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/CHANGELOG.md +78 -0
- package/README.md +221 -6
- package/bin/cairn +43 -2
- package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
- package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
- package/claude/hooks/compaction-capture.sh +34 -0
- package/claude/hooks/memory-capture.sh +47 -5
- package/claude/hooks/memory-wakeup.sh +55 -2
- package/docs/building-an-overlay.md +1 -1
- package/docs/design/meta-agent.md +439 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/operating.md +531 -4
- package/docs/privacy-and-data-flow.md +417 -0
- package/docs/storage.md +252 -0
- package/examples/eval/README.md +42 -0
- package/examples/eval/adapter.json +12 -0
- package/examples/eval/bundled-fake.json +1 -0
- package/examples/eval/task-set.json +1 -0
- package/mcp-memory-server/dist/artifact-cli.js +184 -0
- package/mcp-memory-server/dist/artifact-schema.js +218 -0
- package/mcp-memory-server/dist/artifact-store.js +812 -0
- package/mcp-memory-server/dist/capability-adapter.js +286 -0
- package/mcp-memory-server/dist/capability-cli.js +361 -0
- package/mcp-memory-server/dist/capability-config.js +310 -0
- package/mcp-memory-server/dist/capability-harness.js +496 -0
- package/mcp-memory-server/dist/capability-registry.js +123 -0
- package/mcp-memory-server/dist/capability-schema.js +65 -0
- package/mcp-memory-server/dist/capability-store.js +431 -0
- package/mcp-memory-server/dist/compaction-normalize.js +267 -0
- package/mcp-memory-server/dist/eval-cli.js +523 -0
- package/mcp-memory-server/dist/eval-plan.js +346 -0
- package/mcp-memory-server/dist/eval-process.js +273 -0
- package/mcp-memory-server/dist/eval-report.js +609 -0
- package/mcp-memory-server/dist/eval-runner.js +827 -0
- package/mcp-memory-server/dist/eval-schema.js +368 -0
- package/mcp-memory-server/dist/eval-statistics.js +161 -0
- package/mcp-memory-server/dist/eval-workspace.js +313 -0
- package/mcp-memory-server/dist/failure-signature.js +141 -0
- package/mcp-memory-server/dist/index.js +600 -55
- package/mcp-memory-server/dist/node-cli.js +84 -0
- package/mcp-memory-server/dist/node-schema.js +170 -0
- package/mcp-memory-server/dist/node-store.js +455 -0
- package/mcp-memory-server/dist/note-cli.js +235 -0
- package/mcp-memory-server/dist/note-distiller.js +177 -0
- package/mcp-memory-server/dist/note-enrichment.js +93 -0
- package/mcp-memory-server/dist/note-schema.js +4 -0
- package/mcp-memory-server/dist/note-store.js +985 -0
- package/mcp-memory-server/dist/trajectory-cli.js +141 -0
- package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
- package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
- package/mcp-memory-server/dist/trajectory-schema.js +73 -0
- package/mcp-memory-server/dist/trajectory-store.js +339 -0
- package/mcp-memory-server/package.json +24 -2
- package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
- package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
- package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
- package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
- package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
- package/opencode/capability-contract/plugins/capability-command.ts +319 -0
- package/opencode/command/graphify.md +25 -14
- package/opencode/plugins/memory-capture.ts +76 -10
- package/opencode/plugins/memory-wakeup.ts +58 -6
- package/package.json +9 -4
- package/pi/extensions/cairnkeep-trajectory.ts +55 -0
- package/schemas/artifact.schema.json +133 -0
- package/schemas/capability-callback.schema.json +68 -0
- package/schemas/capability-contract.schema.json +126 -0
- package/schemas/eval-adapter.schema.json +43 -0
- package/schemas/eval-protocol.schema.json +114 -0
- package/schemas/eval-report.schema.json +423 -0
- package/schemas/eval-task-set.schema.json +114 -0
- package/schemas/memory-node.schema.json +90 -0
- package/schemas/note.schema.json +112 -0
- package/schemas/trajectory-redaction.schema.json +25 -0
- package/schemas/trajectory.schema.json +82 -0
- package/scripts/bootstrap.sh +4 -0
- package/scripts/completion.sh +119 -5
- package/scripts/doctor.sh +284 -0
- package/scripts/fake-eval-adapter.mjs +120 -0
- package/scripts/fixtures/capability-harness-contracts.json +408 -0
- package/scripts/install-audit-timer.sh +3 -1
- package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
- package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
- package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
- package/scripts/memory-wiki-audit.sh +49 -0
- package/scripts/smoke-bash32.sh +9 -0
- package/scripts/sync-claude-assets.sh +99 -6
- package/scripts/sync-opencode-graphify-assets.sh +24 -3
- package/scripts/sync-opencode-plugin-assets.sh +31 -5
- package/scripts/sync-opencode-security-assets.sh +24 -3
- package/scripts/sync-opencode-wiki-assets.sh +24 -3
- package/scripts/sync-pi-assets.sh +72 -0
- package/scripts/test-audit-timer.sh +98 -0
- package/scripts/test-bootstrap-untracked.sh +48 -0
- package/scripts/test-cli-dispatch.sh +41 -2
- package/scripts/test-compaction-hooks.sh +495 -0
- package/scripts/test-completion.sh +77 -0
- package/scripts/test-doctor.sh +348 -1
- package/scripts/test-eval-cli.sh +175 -0
- package/scripts/test-launcher-hooks.sh +602 -3
- package/scripts/test-notes-cli.sh +114 -0
- package/scripts/test-package-install.sh +251 -0
- package/scripts/test-phase16-node-lifecycle.sh +50 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
- package/scripts/test-phase18-capability-lifecycle.sh +504 -0
- package/scripts/test-phase18-harness-boundary.sh +1060 -0
- package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
- package/scripts/test-trajectory-cli.sh +87 -0
- package/scripts/test-trajectory-hooks.sh +142 -0
- package/scripts/test-uninstall.sh +231 -5
- package/scripts/uninstall.sh +37 -5
- package/scripts/verify-dependency-security.mjs +121 -0
- package/scripts/verify-docs-parity.sh +527 -1
- package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
- package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
- package/templates/capabilities.json.template +7 -0
- package/templates/env.example.template +51 -0
- package/templates/memory-wiki-audit.service.template +1 -1
- package/templates/start-claude.sh.template +36 -0
- package/templates/start-opencode.sh.template +47 -5
- package/templates/start-pi.sh.template +30 -0
- package/templates/trajectory-redaction.json.template +4 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { constants, existsSync } from "node:fs";
|
|
2
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { canonicalDigest } from "./eval-schema.js";
|
|
6
|
+
import { EvalProcessError, runBoundedCommand, } from "./eval-process.js";
|
|
7
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
8
|
+
const GIT_OUTPUT_LIMIT = 64 * 1024;
|
|
9
|
+
const CLEANUP_TIMEOUT_MS = 120_000;
|
|
10
|
+
const BINDING_LIMIT = 64 * 1024;
|
|
11
|
+
function isContained(root, candidate) {
|
|
12
|
+
const rel = relative(root, candidate);
|
|
13
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
14
|
+
}
|
|
15
|
+
async function assertPrivateDirectory(path) {
|
|
16
|
+
const info = await lstat(path);
|
|
17
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
18
|
+
throw new Error("unsafe_workspace_root");
|
|
19
|
+
return realpath(path);
|
|
20
|
+
}
|
|
21
|
+
async function privateDirectory(path) {
|
|
22
|
+
await mkdir(path, { recursive: false, mode: 0o700 });
|
|
23
|
+
await chmod(path, 0o700);
|
|
24
|
+
}
|
|
25
|
+
async function gitCommand(repository, args, stdoutMode) {
|
|
26
|
+
return runBoundedCommand({
|
|
27
|
+
command: { program: "git", args: ["-C", repository, ...args] },
|
|
28
|
+
cwd: repository,
|
|
29
|
+
env: process.env,
|
|
30
|
+
stdout_mode: stdoutMode,
|
|
31
|
+
timeout_ms: GIT_TIMEOUT_MS,
|
|
32
|
+
max_stdout_bytes: stdoutMode === "raw" ? GIT_OUTPUT_LIMIT : 0,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function requireSuccessfulCommand(result, code) {
|
|
36
|
+
if (result.exit_code !== 0 || result.signal !== null || result.cleanup !== "closed")
|
|
37
|
+
throw new Error(code);
|
|
38
|
+
}
|
|
39
|
+
async function verifyGitWorkspace(workspace) {
|
|
40
|
+
const head = await gitCommand(workspace.source_path, ["rev-parse", "--verify", "HEAD^{commit}"], "raw");
|
|
41
|
+
requireSuccessfulCommand(head, "workspace_head_query_failed");
|
|
42
|
+
if (head.stdout?.trim() !== workspace.source_revision)
|
|
43
|
+
throw new Error("workspace_revision_mismatch");
|
|
44
|
+
const status = await gitCommand(workspace.source_path, ["status", "--porcelain", "--untracked-files=all"], "raw");
|
|
45
|
+
requireSuccessfulCommand(status, "workspace_status_query_failed");
|
|
46
|
+
if (status.stdout?.trim() !== "")
|
|
47
|
+
throw new Error("workspace_not_clean");
|
|
48
|
+
}
|
|
49
|
+
async function validateBundledBinding(plan) {
|
|
50
|
+
if (plan.source.kind !== "bundled_fake" || plan.task_set.source.kind !== "bundled_fake") {
|
|
51
|
+
throw new Error("bundled_source_mismatch");
|
|
52
|
+
}
|
|
53
|
+
if (canonicalDigest(plan.task_set) !== plan.task_set_digest)
|
|
54
|
+
throw new Error("task_set_digest_mismatch");
|
|
55
|
+
let handle;
|
|
56
|
+
let binding;
|
|
57
|
+
try {
|
|
58
|
+
handle = await open(plan.source.binding_path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
59
|
+
const info = await handle.stat();
|
|
60
|
+
if (!info.isFile() || info.size > BINDING_LIMIT)
|
|
61
|
+
throw new Error("unsafe_bundled_binding");
|
|
62
|
+
const bytes = await handle.readFile();
|
|
63
|
+
if (bytes.byteLength > BINDING_LIMIT)
|
|
64
|
+
throw new Error("unsafe_bundled_binding");
|
|
65
|
+
binding = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new Error("invalid_bundled_binding");
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await handle?.close().catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
if (!binding || typeof binding !== "object" || Array.isArray(binding))
|
|
74
|
+
throw new Error("invalid_bundled_binding");
|
|
75
|
+
const record = binding;
|
|
76
|
+
if (canonicalDigest(Object.keys(record).sort()) !== canonicalDigest([
|
|
77
|
+
"identifier", "package_version", "schema_version", "task_set_digest",
|
|
78
|
+
])
|
|
79
|
+
|| record.schema_version !== plan.schema_version
|
|
80
|
+
|| record.identifier !== plan.source.identifier
|
|
81
|
+
|| record.package_version !== plan.source.package_version
|
|
82
|
+
|| record.task_set_digest !== plan.task_set_digest) {
|
|
83
|
+
throw new Error("bundled_binding_mismatch");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function materializeBundledSource(plan, sourcePath) {
|
|
87
|
+
await validateBundledBinding(plan);
|
|
88
|
+
if (plan.task_set.source.kind !== "bundled_fake")
|
|
89
|
+
throw new Error("bundled_source_mismatch");
|
|
90
|
+
await privateDirectory(sourcePath);
|
|
91
|
+
for (const file of plan.task_set.source.files) {
|
|
92
|
+
const destination = resolve(sourcePath, file.path);
|
|
93
|
+
if (!isContained(sourcePath, destination) || destination === sourcePath)
|
|
94
|
+
throw new Error("bundled_path_escape");
|
|
95
|
+
const segments = relative(sourcePath, dirname(destination)).split(sep).filter(Boolean);
|
|
96
|
+
let parent = sourcePath;
|
|
97
|
+
for (const segment of segments) {
|
|
98
|
+
parent = join(parent, segment);
|
|
99
|
+
if (!isContained(sourcePath, parent))
|
|
100
|
+
throw new Error("bundled_path_escape");
|
|
101
|
+
if (!existsSync(parent))
|
|
102
|
+
await privateDirectory(parent);
|
|
103
|
+
else if ((await lstat(parent)).isSymbolicLink() || !(await lstat(parent)).isDirectory()) {
|
|
104
|
+
throw new Error("bundled_path_unsafe");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await writeFile(destination, Buffer.from(file.content, "utf8"), { flag: "wx", mode: 0o600 });
|
|
108
|
+
const info = await lstat(destination);
|
|
109
|
+
if (info.isSymbolicLink() || !info.isFile())
|
|
110
|
+
throw new Error("bundled_path_unsafe");
|
|
111
|
+
if (!Buffer.from(file.content, "utf8").equals(await readFile(destination)))
|
|
112
|
+
throw new Error("bundled_source_mismatch");
|
|
113
|
+
}
|
|
114
|
+
if (canonicalDigest(plan.task_set) !== plan.task_set_digest)
|
|
115
|
+
throw new Error("task_set_digest_mismatch");
|
|
116
|
+
}
|
|
117
|
+
function workspaceEnvironment(workspace, extra) {
|
|
118
|
+
return {
|
|
119
|
+
...process.env,
|
|
120
|
+
...extra,
|
|
121
|
+
HOME: workspace.home_path,
|
|
122
|
+
TMPDIR: workspace.temp_path,
|
|
123
|
+
XDG_CACHE_HOME: join(workspace.home_path, ".cache"),
|
|
124
|
+
XDG_CONFIG_HOME: join(workspace.home_path, ".config"),
|
|
125
|
+
XDG_DATA_HOME: join(workspace.home_path, ".local", "share"),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export async function createEvalWorkspace(options) {
|
|
129
|
+
const taskIndex = options.plan.task_set.tasks.findIndex((candidate) => candidate.id === options.row.task_id);
|
|
130
|
+
if (taskIndex < 0)
|
|
131
|
+
throw new Error("unknown_schedule_task");
|
|
132
|
+
const task = options.plan.task_set.tasks[taskIndex];
|
|
133
|
+
if (!task)
|
|
134
|
+
throw new Error("unknown_schedule_task");
|
|
135
|
+
const root = await assertPrivateDirectory(resolve(options.temporary_root ?? tmpdir()));
|
|
136
|
+
const parent = await mkdtemp(join(root, "cairn-eval-workspace-"));
|
|
137
|
+
await chmod(parent, 0o700);
|
|
138
|
+
const workspace = {
|
|
139
|
+
observation_id: options.row.observation_id,
|
|
140
|
+
task_id: task.id,
|
|
141
|
+
parent_path: parent,
|
|
142
|
+
source_path: join(parent, "source"),
|
|
143
|
+
workspace_path: "",
|
|
144
|
+
notes_path: join(parent, "notes"),
|
|
145
|
+
output_path: join(parent, "output"),
|
|
146
|
+
home_path: join(parent, "home"),
|
|
147
|
+
temp_path: join(parent, "tmp"),
|
|
148
|
+
source_revision: options.plan.source.kind === "git" ? options.plan.source.revision : options.plan.task_set_digest,
|
|
149
|
+
source_kind: options.plan.source.kind,
|
|
150
|
+
repository_root: options.plan.source.kind === "git" ? options.plan.source.repository_root : null,
|
|
151
|
+
worktree_registered: false,
|
|
152
|
+
task,
|
|
153
|
+
prepare_program: options.plan.resolved_programs.prepare[taskIndex] ?? task.prepare.program,
|
|
154
|
+
verify_program: options.plan.resolved_programs.verify[taskIndex] ?? task.verify.program,
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
if (options.plan.source.kind === "git") {
|
|
158
|
+
const added = await gitCommand(options.plan.source.repository_root, ["worktree", "add", "--detach", workspace.source_path, options.plan.source.revision], "exit-only");
|
|
159
|
+
requireSuccessfulCommand(added, "workspace_add_failed");
|
|
160
|
+
workspace.worktree_registered = true;
|
|
161
|
+
await verifyGitWorkspace(workspace);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
await materializeBundledSource(options.plan, workspace.source_path);
|
|
165
|
+
}
|
|
166
|
+
workspace.workspace_path = resolve(workspace.source_path, task.workspace.path);
|
|
167
|
+
if (!isContained(workspace.source_path, workspace.workspace_path))
|
|
168
|
+
throw new Error("workspace_path_escape");
|
|
169
|
+
const realTaskRoot = await assertPrivateDirectory(workspace.workspace_path);
|
|
170
|
+
if (!isContained(await realpath(workspace.source_path), realTaskRoot))
|
|
171
|
+
throw new Error("workspace_path_escape");
|
|
172
|
+
workspace.workspace_path = realTaskRoot;
|
|
173
|
+
for (const directory of [workspace.notes_path, workspace.output_path, workspace.home_path, workspace.temp_path]) {
|
|
174
|
+
await privateDirectory(directory);
|
|
175
|
+
}
|
|
176
|
+
return workspace;
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
await cleanupEvalWorkspace(workspace).catch(() => undefined);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export async function runTaskPreparation(workspace, options = {}) {
|
|
184
|
+
return runBoundedCommand({
|
|
185
|
+
command: { program: workspace.prepare_program, args: workspace.task.prepare.args },
|
|
186
|
+
cwd: workspace.workspace_path,
|
|
187
|
+
env: workspaceEnvironment(workspace, options.env),
|
|
188
|
+
stdout_mode: "exit-only",
|
|
189
|
+
timeout_ms: workspace.task.limits.elapsed_ms,
|
|
190
|
+
max_stdout_bytes: 0,
|
|
191
|
+
kill_grace_ms: options.kill_grace_ms,
|
|
192
|
+
signal: options.signal,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function verifierReason(code) {
|
|
196
|
+
return `verifier_${code}`;
|
|
197
|
+
}
|
|
198
|
+
export async function runTaskVerifier(workspace, options) {
|
|
199
|
+
if (!options.adapter_completed) {
|
|
200
|
+
return {
|
|
201
|
+
pass_state: "unknown",
|
|
202
|
+
terminal_state: "completed",
|
|
203
|
+
verifier_state: "not_run",
|
|
204
|
+
reason: "adapter_not_completed",
|
|
205
|
+
process: null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const result = await runBoundedCommand({
|
|
210
|
+
command: { program: workspace.verify_program, args: workspace.task.verify.args },
|
|
211
|
+
cwd: workspace.workspace_path,
|
|
212
|
+
env: workspaceEnvironment(workspace, options.env),
|
|
213
|
+
stdout_mode: "exit-only",
|
|
214
|
+
timeout_ms: workspace.task.limits.elapsed_ms,
|
|
215
|
+
max_stdout_bytes: 0,
|
|
216
|
+
kill_grace_ms: options.kill_grace_ms,
|
|
217
|
+
signal: options.signal,
|
|
218
|
+
});
|
|
219
|
+
if (result.signal !== null || result.cleanup !== "closed" || result.exit_code === null) {
|
|
220
|
+
return {
|
|
221
|
+
pass_state: "unknown",
|
|
222
|
+
terminal_state: "completed",
|
|
223
|
+
verifier_state: "error",
|
|
224
|
+
reason: result.signal !== null ? "verifier_signal" : "verifier_cleanup_failed",
|
|
225
|
+
process: result,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const passed = result.exit_code === 0;
|
|
229
|
+
return {
|
|
230
|
+
pass_state: passed ? "passed" : "failed",
|
|
231
|
+
terminal_state: passed ? "completed" : "verifier_failed",
|
|
232
|
+
verifier_state: "completed",
|
|
233
|
+
reason: passed ? null : "verifier_nonzero",
|
|
234
|
+
process: result,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (!(error instanceof EvalProcessError))
|
|
239
|
+
throw error;
|
|
240
|
+
if (error.code === "cancelled") {
|
|
241
|
+
return {
|
|
242
|
+
pass_state: "unknown",
|
|
243
|
+
terminal_state: "cancelled",
|
|
244
|
+
verifier_state: "error",
|
|
245
|
+
reason: "verifier_cancelled",
|
|
246
|
+
process: {
|
|
247
|
+
exit_code: error.exit_code,
|
|
248
|
+
signal: error.signal,
|
|
249
|
+
cleanup: error.cleanup,
|
|
250
|
+
termination_scope: error.termination_scope,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
pass_state: "unknown",
|
|
256
|
+
terminal_state: "completed",
|
|
257
|
+
verifier_state: "error",
|
|
258
|
+
reason: verifierReason(error.code),
|
|
259
|
+
process: null,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function removeParent(path) {
|
|
264
|
+
try {
|
|
265
|
+
const result = await runBoundedCommand({
|
|
266
|
+
command: {
|
|
267
|
+
program: process.execPath,
|
|
268
|
+
args: ["-e", "import('node:fs/promises').then(fs=>fs.rm(process.argv[1],{recursive:true,force:true}))", path],
|
|
269
|
+
},
|
|
270
|
+
stdout_mode: "exit-only",
|
|
271
|
+
timeout_ms: CLEANUP_TIMEOUT_MS,
|
|
272
|
+
max_stdout_bytes: 0,
|
|
273
|
+
});
|
|
274
|
+
return result.exit_code === 0 && !existsSync(path);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
export async function cleanupEvalWorkspace(workspace) {
|
|
281
|
+
let registration = "not_applicable";
|
|
282
|
+
if (workspace.worktree_registered && workspace.repository_root) {
|
|
283
|
+
try {
|
|
284
|
+
const removed = await gitCommand(workspace.repository_root, ["worktree", "remove", "--force", workspace.source_path], "exit-only");
|
|
285
|
+
registration = removed.exit_code === 0 ? "removed" : "failed";
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
registration = "failed";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const parentRemoved = await removeParent(workspace.parent_path);
|
|
292
|
+
if (workspace.worktree_registered && workspace.repository_root && registration === "failed") {
|
|
293
|
+
try {
|
|
294
|
+
await gitCommand(workspace.repository_root, ["worktree", "prune", "--expire", "now"], "exit-only");
|
|
295
|
+
const listed = await gitCommand(workspace.repository_root, ["worktree", "list", "--porcelain"], "raw");
|
|
296
|
+
if (listed.exit_code === 0 && !listed.stdout?.includes(workspace.source_path))
|
|
297
|
+
registration = "removed";
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
registration = "failed";
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
status: parentRemoved && registration !== "failed" ? "closed" : "failed",
|
|
305
|
+
worktree_registration: registration,
|
|
306
|
+
parent: parentRemoved ? "removed" : "failed",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
export function evalWorkspaceCommand(workspace, kind) {
|
|
310
|
+
return kind === "prepare"
|
|
311
|
+
? { program: workspace.prepare_program, args: [...workspace.task.prepare.args] }
|
|
312
|
+
: { program: workspace.verify_program, args: [...workspace.task.verify.args] };
|
|
313
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { failureSignatureSchema } from "./note-schema.js";
|
|
4
|
+
const ANSI = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))/g;
|
|
5
|
+
const INTERNAL_FRAME = /(?:node:internal|internal\/process|processTicksAndRejections|__node_internal|<anonymous>)/i;
|
|
6
|
+
function hash(domain, value) {
|
|
7
|
+
return createHash("sha256")
|
|
8
|
+
.update(`cairnkeep:hindsight:v1:${domain}\0`)
|
|
9
|
+
.update(JSON.stringify(value))
|
|
10
|
+
.digest("hex");
|
|
11
|
+
}
|
|
12
|
+
function safePath(value, root) {
|
|
13
|
+
let path = value.trim().replace(/^['"(]|['"),]$/g, "").replaceAll("\\", "/");
|
|
14
|
+
path = path.replace(/:\d+(?::\d+)?$/, "");
|
|
15
|
+
if (root) {
|
|
16
|
+
const normalizedRoot = resolve(root).replaceAll("\\", "/").replace(/\/$/, "");
|
|
17
|
+
if (path === normalizedRoot)
|
|
18
|
+
return "";
|
|
19
|
+
if (path.startsWith(`${normalizedRoot}/`))
|
|
20
|
+
path = path.slice(normalizedRoot.length + 1);
|
|
21
|
+
else {
|
|
22
|
+
const candidate = relative(normalizedRoot, path).replaceAll("\\", "/");
|
|
23
|
+
if (!candidate.startsWith("../") && candidate !== "..")
|
|
24
|
+
path = candidate;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const projectTail = path.match(/(?:^|\/)((?:src|pkg|lib|test|tests|app)\/[^\s:()]+)$/i);
|
|
28
|
+
if (projectTail)
|
|
29
|
+
path = projectTail[1];
|
|
30
|
+
path = path.replace(/^\.?\//, "").replace(/^\/+/, "");
|
|
31
|
+
return path.slice(0, 1024);
|
|
32
|
+
}
|
|
33
|
+
function normalizeVolatile(value, root) {
|
|
34
|
+
let result = value.replace(ANSI, "").replaceAll("\r", "");
|
|
35
|
+
if (root) {
|
|
36
|
+
const normalizedRoot = resolve(root).replaceAll("\\", "/").replace(/\/$/, "");
|
|
37
|
+
result = result.replaceAll(normalizedRoot, "<root>");
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/gi, "<timestamp>")
|
|
41
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, "<uuid>")
|
|
42
|
+
.replace(/\breq_[a-z0-9_-]{16,}\b/gi, "req_<id>")
|
|
43
|
+
.replace(/\bpid\s*[=:]?\s*\d+\b/gi, "pid <pid>")
|
|
44
|
+
.replace(/\b0x[0-9a-f]+\b/gi, "<address>")
|
|
45
|
+
.replace(/:(\d+)(?::\d+)?(?=\b|\))/g, ":<line>")
|
|
46
|
+
.replace(/[\t\u00a0 ]+/g, " ")
|
|
47
|
+
.trim();
|
|
48
|
+
}
|
|
49
|
+
function parseFrames(text, root) {
|
|
50
|
+
const frames = [];
|
|
51
|
+
const add = (fn, file) => {
|
|
52
|
+
const normalizedFunction = fn.replace(/^async\s+/, "").trim();
|
|
53
|
+
const normalizedFile = safePath(file, root);
|
|
54
|
+
if (!normalizedFunction || !normalizedFile || INTERNAL_FRAME.test(`${normalizedFunction} ${normalizedFile}`))
|
|
55
|
+
return;
|
|
56
|
+
if (!frames.some((frame) => frame.function === normalizedFunction && frame.file === normalizedFile)) {
|
|
57
|
+
frames.push({ function: normalizedFunction.slice(0, 512), file: normalizedFile });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
for (const rawLine of text.replace(ANSI, "").replaceAll("\r", "").split("\n")) {
|
|
61
|
+
const line = rawLine.trim();
|
|
62
|
+
let match = line.match(/^at\s+(?:async\s+)?(.+?)\s+\((.+?):\d+(?::\d+)?\)$/);
|
|
63
|
+
if (match) {
|
|
64
|
+
add(match[1], match[2]);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
match = line.match(/^at\s+(.+?):\d+(?::\d+)?$/);
|
|
68
|
+
if (match) {
|
|
69
|
+
add("<module>", match[1]);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
match = line.match(/^File\s+["'](.+?)["'],\s+line\s+\d+,\s+in\s+(.+)$/);
|
|
73
|
+
if (match) {
|
|
74
|
+
add(match[2], match[1]);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
match = line.match(/^at\s+([\w.$<>]+)\(([^():]+\.java):\d+\)$/);
|
|
78
|
+
if (match) {
|
|
79
|
+
add(match[1], match[2]);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
match = line.match(/^\d+:\s+(?:0x[0-9a-f]+\s+-\s+)?(.+)$/i);
|
|
83
|
+
if (match) {
|
|
84
|
+
const source = text.match(/(?:^|[\s,])((?:\/[^\s,:]+)*\/(?:src|pkg|lib|test|tests)\/[^\s,:]+):\d+(?::\d+)?/im);
|
|
85
|
+
if (source)
|
|
86
|
+
add(match[1], source[1]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return frames.slice(0, 16);
|
|
90
|
+
}
|
|
91
|
+
function selectMessage(text, root) {
|
|
92
|
+
const lines = normalizeVolatile(text, root).split("\n").map((line) => line.trim()).filter(Boolean);
|
|
93
|
+
const python = [...lines].reverse().find((line) => /^[\w.]+(?:Error|Exception):/.test(line) || /^[A-Z][\w.]*Error:/.test(line));
|
|
94
|
+
if (lines.some((line) => /^Traceback \(/.test(line)) && python)
|
|
95
|
+
return python;
|
|
96
|
+
const first = lines.find((line) => !/^(?:at\s+|File\s+|stack backtrace:|\d+:\s+(?:<address>|0x))/i.test(line));
|
|
97
|
+
if (!first)
|
|
98
|
+
return "unknown failure";
|
|
99
|
+
return first
|
|
100
|
+
.replace(/,\s*(?:<root>|\/?(?:[^\s,]+\/)*(?:src|pkg|lib|test|tests)\/[^\s,:]+):<line>$/i, "")
|
|
101
|
+
.replace(/\s+/g, " ")
|
|
102
|
+
.trim()
|
|
103
|
+
.slice(0, 4096);
|
|
104
|
+
}
|
|
105
|
+
function familyFor(message) {
|
|
106
|
+
if (/\bpanicked at\b/i.test(message))
|
|
107
|
+
return "panic";
|
|
108
|
+
const diagnostic = message.match(/^([A-Z]{2,}\d{3,}):/);
|
|
109
|
+
if (diagnostic)
|
|
110
|
+
return diagnostic[1].toLowerCase();
|
|
111
|
+
const typed = message.match(/^([A-Za-z_$][\w.$-]*(?:Error|Exception))\s*:/);
|
|
112
|
+
const raw = typed?.[1].split(".").at(-1) ?? message.split(":", 1)[0] ?? "failure";
|
|
113
|
+
return raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 128) || "failure";
|
|
114
|
+
}
|
|
115
|
+
export function buildFailureSignature(text, options = {}) {
|
|
116
|
+
const normalizedMessage = selectMessage(text, options.root);
|
|
117
|
+
const frames = parseFrames(text, options.root);
|
|
118
|
+
const explicitComponent = options.component ? safePath(options.component, options.root) : "";
|
|
119
|
+
const sourceInMessage = text.match(/(?:^|[\s,])((?:\/[^\s,:]+)*\/(?:src|pkg|lib|test|tests)\/[^\s,:]+):\d+(?::\d+)?/im);
|
|
120
|
+
const component = explicitComponent || frames[0]?.file || (sourceInMessage ? safePath(sourceInMessage[1], options.root) : "");
|
|
121
|
+
const family = familyFor(normalizedMessage);
|
|
122
|
+
const normalizedForHash = normalizedMessage.toLocaleLowerCase("en-US");
|
|
123
|
+
const stackDigest = frames.length > 0 ? hash("stack", frames) : "";
|
|
124
|
+
const fullDigest = hash("full", { family, message: normalizedForHash, stack_digest: stackDigest, component });
|
|
125
|
+
const signature = {
|
|
126
|
+
signature_version: 1,
|
|
127
|
+
family,
|
|
128
|
+
normalized_message: normalizedMessage,
|
|
129
|
+
stack_digest: stackDigest,
|
|
130
|
+
component,
|
|
131
|
+
frames,
|
|
132
|
+
lookup_keys: {
|
|
133
|
+
full: `v1:full:${fullDigest}`,
|
|
134
|
+
message_stack: `v1:message-stack:${hash("message-stack", { family, message: normalizedForHash, stack_digest: stackDigest })}`,
|
|
135
|
+
message_component: `v1:message-component:${hash("message-component", { family, message: normalizedForHash, component })}`,
|
|
136
|
+
message: `v1:message:${hash("message", { family, message: normalizedForHash })}`,
|
|
137
|
+
},
|
|
138
|
+
fingerprint: fullDigest,
|
|
139
|
+
};
|
|
140
|
+
return failureSignatureSchema.parse(signature);
|
|
141
|
+
}
|