cairn-ai 0.5.0 → 0.6.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/.agents/workflows/cairn-memory.md +2 -2
- package/.agents/workflows/cairn-plan.md +2 -2
- package/.agents/workflows/cairn-review.md +1 -1
- package/.agents/workflows/cairn-work.md +2 -2
- package/.claude/agents/explorer.md +1 -1
- package/.claude/agents/worker.md +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/README.de.md +14 -3
- package/README.es.md +14 -3
- package/README.fr.md +14 -3
- package/README.ja.md +14 -3
- package/README.ko.md +17 -7
- package/README.md +17 -7
- package/README.pt.md +14 -3
- package/README.zh.md +14 -3
- package/agents/explorer.md +1 -1
- package/agents/worker.md +1 -1
- package/commands/cairn-memory.md +2 -2
- package/commands/cairn-plan.md +2 -2
- package/commands/cairn-review.md +1 -1
- package/commands/cairn-work.md +2 -2
- package/package.json +1 -1
- package/scripts/cairn-cleanup.mjs +142 -15
- package/scripts/cairn-goal-cli.mjs +50 -4
- package/scripts/cairn-goal-history.mjs +316 -0
- package/scripts/cairn-goal-operations-evidence.mjs +2 -2
- package/scripts/cairn-goal-operations.mjs +11 -3
- package/scripts/cairn-goal.mjs +3 -0
- package/scripts/cairn-index.mjs +1052 -0
- package/scripts/cairn-safe-fs.mjs +7 -2
- package/scripts/cairn-state-context.mjs +101 -10
- package/scripts/cairn-state-init.mjs +11 -68
- package/scripts/cairn-state.mjs +14 -3
- package/scripts/cairn.mjs +55 -18
- package/skills/cairn-memory/SKILL.md +4 -4
- package/skills/cairn-plan/SKILL.md +2 -2
- package/skills/cairn-plan/delegation.md +2 -1
- package/skills/cairn-plan/planning-rules.md +10 -0
- package/skills/cairn-review/SKILL.md +3 -1
- package/skills/cairn-work/SKILL.md +1 -1
- package/skills/cairn-work/delegation.md +2 -1
- package/skills/cairn-work/verification.md +15 -1
- package/templates/MEMORY.md +4 -6
- package/templates/PLAN.md +2 -15
- package/templates/domain-memory.md +2 -0
- package/templates/work-plan.md +3 -1
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { constants, readFileSync } from "node:fs";
|
|
3
|
+
import { lstat, open, readdir, rm, rmdir } from "node:fs/promises";
|
|
4
|
+
import { hostname } from "node:os";
|
|
3
5
|
import { join, resolve } from "node:path";
|
|
4
6
|
import { pathToFileURL } from "node:url";
|
|
5
7
|
import { reconcileWorktreeState } from "./cairn-goal.mjs";
|
|
6
|
-
import {
|
|
8
|
+
import { resolvePluginRoot, resolveRepoRoot } from "./cairn-paths.mjs";
|
|
9
|
+
import { lockOwnerIsDead, parseLockRecord } from "./cairn-safe-fs.mjs";
|
|
7
10
|
|
|
8
|
-
const
|
|
11
|
+
const staleLockPrefix = "state.lock.stale.";
|
|
12
|
+
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}$/;
|
|
13
|
+
const MAX_LOCK_RECORD_BYTES = 4096;
|
|
14
|
+
const usage = "Usage: cairn cleanup [--root PATH] [--check | --yes]";
|
|
9
15
|
|
|
10
16
|
export async function cleanupProjectCairn({ root = process.cwd(), apply = false } = {}) {
|
|
11
17
|
const repoRoot = resolve(root);
|
|
@@ -14,7 +20,9 @@ export async function cleanupProjectCairn({ root = process.cwd(), apply = false
|
|
|
14
20
|
try {
|
|
15
21
|
rootStat = await lstat(legacyRoot);
|
|
16
22
|
} catch (error) {
|
|
17
|
-
if (error?.code === "ENOENT")
|
|
23
|
+
if (error?.code === "ENOENT") {
|
|
24
|
+
return { root: repoRoot, apply, known: [], unknown: [], removed: [], projectDirectoryRemoved: false, preservedReasons: {} };
|
|
25
|
+
}
|
|
18
26
|
throw error;
|
|
19
27
|
}
|
|
20
28
|
if (rootStat.isSymbolicLink()) throw new Error(`Legacy Cairn root must not be a symbolic link: ${legacyRoot}`);
|
|
@@ -23,21 +31,42 @@ export async function cleanupProjectCairn({ root = process.cwd(), apply = false
|
|
|
23
31
|
const entries = await readdir(legacyRoot, { withFileTypes: true });
|
|
24
32
|
const known = [];
|
|
25
33
|
const unknown = [];
|
|
34
|
+
const preservedReasons = {};
|
|
26
35
|
for (const entry of entries) {
|
|
27
|
-
|
|
36
|
+
if (entry.isFile() && isLockFileName(entry.name)) {
|
|
37
|
+
// Lock files are Cairn-owned only when their content is a lock record that may be removed.
|
|
38
|
+
const reason = await lockFileReason(join(legacyRoot, entry.name), entry.name);
|
|
39
|
+
if (reason === null) known.push(entry.name);
|
|
40
|
+
else {
|
|
41
|
+
unknown.push(entry.name);
|
|
42
|
+
preservedReasons[entry.name] = reason;
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const isKnownFile = entry.isFile() && entry.name === "state.json";
|
|
28
47
|
const isKnownDirectory = entry.isDirectory() && entry.name === "tools";
|
|
29
48
|
(isKnownFile || isKnownDirectory ? known : unknown).push(entry.name);
|
|
30
49
|
}
|
|
31
|
-
if (!apply) return {
|
|
50
|
+
if (!apply) return cleanupResult({ repoRoot, apply, known, unknown, removed: [], projectDirectoryRemoved: false, preservedReasons });
|
|
32
51
|
|
|
33
52
|
if (known.includes("state.json")) await reconcileWorktreeState({ root: repoRoot });
|
|
34
53
|
const removed = [];
|
|
35
|
-
for (const name of known) {
|
|
54
|
+
for (const name of [...known]) {
|
|
36
55
|
const path = join(legacyRoot, name);
|
|
37
56
|
try {
|
|
38
57
|
const stat = await lstat(path);
|
|
39
58
|
const expectedType = name === "tools" ? stat.isDirectory() : stat.isFile();
|
|
40
59
|
if (!expectedType) continue;
|
|
60
|
+
if (isLockFileName(name)) {
|
|
61
|
+
// Judge the lock again right before removal; a lock that changed owner is preserved.
|
|
62
|
+
const reason = await lockFileReason(path, name);
|
|
63
|
+
if (reason !== null) {
|
|
64
|
+
known.splice(known.indexOf(name), 1);
|
|
65
|
+
unknown.push(name);
|
|
66
|
+
preservedReasons[name] = reason;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
41
70
|
await rm(path, { recursive: name === "tools", force: true });
|
|
42
71
|
removed.push(name);
|
|
43
72
|
} catch (error) {
|
|
@@ -51,17 +80,115 @@ export async function cleanupProjectCairn({ root = process.cwd(), apply = false
|
|
|
51
80
|
} catch (error) {
|
|
52
81
|
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
|
|
53
82
|
}
|
|
54
|
-
return {
|
|
83
|
+
return cleanupResult({ repoRoot, apply, known, unknown, removed, projectDirectoryRemoved, preservedReasons });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cleanupResult({ repoRoot, apply, known, unknown, removed, projectDirectoryRemoved, preservedReasons }) {
|
|
87
|
+
return {
|
|
88
|
+
root: repoRoot,
|
|
89
|
+
apply,
|
|
90
|
+
known: known.sort(),
|
|
91
|
+
unknown: unknown.sort(),
|
|
92
|
+
removed: removed.sort(),
|
|
93
|
+
projectDirectoryRemoved,
|
|
94
|
+
preservedReasons: Object.fromEntries(Object.keys(preservedReasons).sort().map((name) => [name, preservedReasons[name]])),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isLockFileName(name) {
|
|
99
|
+
return name === "state.lock" || name.startsWith(staleLockPrefix);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Returns null when the lock file may be removed, otherwise the reason it is preserved.
|
|
103
|
+
async function lockFileReason(path, name) {
|
|
104
|
+
let nonce = null;
|
|
105
|
+
if (name !== "state.lock") {
|
|
106
|
+
const match = staleLockName.exec(name);
|
|
107
|
+
if (!match) return "invalid-lock-record";
|
|
108
|
+
nonce = match[1];
|
|
109
|
+
}
|
|
110
|
+
const record = await readLockRecord(path);
|
|
111
|
+
if (!record) return "invalid-lock-record";
|
|
112
|
+
if (nonce !== null) return record.nonce === nonce ? null : "invalid-lock-record";
|
|
113
|
+
if (lockOwnerIsDead(record)) return null;
|
|
114
|
+
return record.hostname === hostname() ? "live-lock-owner" : "foreign-host-lock";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readLockRecord(path) {
|
|
118
|
+
let handle;
|
|
119
|
+
try {
|
|
120
|
+
// Never follow a link or block on a special file that replaced the inspected entry.
|
|
121
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
122
|
+
const stat = await handle.stat();
|
|
123
|
+
if (!stat.isFile() || stat.size > MAX_LOCK_RECORD_BYTES) return null;
|
|
124
|
+
const buffer = Buffer.alloc(MAX_LOCK_RECORD_BYTES + 1);
|
|
125
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
126
|
+
if (bytesRead > MAX_LOCK_RECORD_BYTES) return null;
|
|
127
|
+
return parseLockRecord(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
} finally {
|
|
131
|
+
await handle?.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class CleanupUsageError extends Error {}
|
|
136
|
+
|
|
137
|
+
function parseCleanupArgs(args) {
|
|
138
|
+
const provided = new Set();
|
|
139
|
+
let explicitRoot = null;
|
|
140
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
141
|
+
const argument = args[index];
|
|
142
|
+
if (["--help", "--version", "--check", "--yes"].includes(argument)) {
|
|
143
|
+
if (provided.has(argument)) throw new CleanupUsageError(`${argument} may only be provided once`);
|
|
144
|
+
provided.add(argument);
|
|
145
|
+
} else if (argument === "--root" || argument.startsWith("--root=")) {
|
|
146
|
+
if (explicitRoot !== null) throw new CleanupUsageError("--root may only be provided once");
|
|
147
|
+
const value = argument === "--root" ? args[index + 1] : argument.slice("--root=".length);
|
|
148
|
+
if (!value || value.startsWith("--")) throw new CleanupUsageError("--root requires a path");
|
|
149
|
+
explicitRoot = value;
|
|
150
|
+
if (argument === "--root") index += 1;
|
|
151
|
+
} else {
|
|
152
|
+
throw new CleanupUsageError(`unknown option: ${argument}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (provided.has("--help") && provided.has("--version")) throw new CleanupUsageError("--help and --version cannot be used together");
|
|
156
|
+
if (provided.has("--check") && provided.has("--yes")) throw new CleanupUsageError("--check and --yes cannot be used together");
|
|
157
|
+
const action = provided.has("--help") ? "help" : provided.has("--version") ? "version" : "cleanup";
|
|
158
|
+
return { action, explicitRoot, apply: provided.has("--yes") };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function main(args) {
|
|
162
|
+
let options;
|
|
163
|
+
try {
|
|
164
|
+
options = parseCleanupArgs(args);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error(`Cairn cleanup error: ${firstLine(error)}`);
|
|
167
|
+
process.exitCode = 2;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (options.action === "help") {
|
|
171
|
+
console.log(usage);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (options.action === "version") {
|
|
175
|
+
const { version } = JSON.parse(readFileSync(join(resolvePluginRoot(import.meta.url), "package.json"), "utf8"));
|
|
176
|
+
console.log(`cairn ${version}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const repoRoot = resolveRepoRoot({ explicitRoot: options.explicitRoot });
|
|
181
|
+
console.log(JSON.stringify(await cleanupProjectCairn({ root: repoRoot, apply: options.apply }), null, 2));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error(`Cairn cleanup error: ${firstLine(error)}`);
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
}
|
|
55
186
|
}
|
|
56
187
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const apply = args.includes("--yes");
|
|
60
|
-
const unsupported = args.filter((value) => value !== "--yes" && value !== "--check");
|
|
61
|
-
if (unsupported.length > 0) throw new Error(`Unknown cleanup option: ${unsupported[0]}`);
|
|
62
|
-
console.log(JSON.stringify(await cleanupProjectCairn({ root: repoRoot, apply }), null, 2));
|
|
188
|
+
function firstLine(error) {
|
|
189
|
+
return String(error instanceof Error ? error.message : error).split(/\r?\n/)[0];
|
|
63
190
|
}
|
|
64
191
|
|
|
65
192
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
66
|
-
await main();
|
|
193
|
+
await main(process.argv.slice(2));
|
|
67
194
|
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { memoryPromotionReminder } from "./cairn-goal-history.mjs";
|
|
4
|
+
import { resolvePluginRoot, resolveRepoRoot } from "./cairn-paths.mjs";
|
|
5
|
+
|
|
6
|
+
const GOAL_COMMANDS = ["start", "status", "ready", "stats", "task", "replan", "claim", "assign", "receipt", "verify", "finish", "pause", "resume", "block", "cancel", "complete"];
|
|
7
|
+
const GOAL_USAGE = [
|
|
8
|
+
`Usage: cairn goal <${GOAL_COMMANDS.join("|")}> [options] [--root PATH]`,
|
|
9
|
+
"Note: --criteria (completionCriteria) is advisory and is not enforced; completion is gated only by required evidence.",
|
|
10
|
+
].join("\n");
|
|
2
11
|
|
|
3
12
|
export async function runGoalCli(args, {
|
|
4
13
|
stdout = console.log,
|
|
@@ -8,10 +17,31 @@ export async function runGoalCli(args, {
|
|
|
8
17
|
}) {
|
|
9
18
|
const [firstCommand = "help", ...remaining] = args;
|
|
10
19
|
const [command = "help", ...rest] = firstCommand === "goal" ? remaining : [firstCommand, ...remaining];
|
|
20
|
+
// Help and version are answered before parsing options or touching state; arguments after -- are passed through.
|
|
21
|
+
const leading = [command, ...rest];
|
|
22
|
+
const separator = leading.indexOf("--");
|
|
23
|
+
const beforeSeparator = separator === -1 ? leading : leading.slice(0, separator);
|
|
24
|
+
const help = command === "help" || beforeSeparator.includes("--help");
|
|
25
|
+
const version = beforeSeparator.includes("--version");
|
|
26
|
+
if (help && version) throw new Error("--help and --version cannot be used together");
|
|
27
|
+
if (help) {
|
|
28
|
+
stdout(GOAL_USAGE);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (version) {
|
|
32
|
+
stdout(`cairn ${JSON.parse(readFileSync(join(resolvePluginRoot(import.meta.url), "package.json"), "utf8")).version}`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!GOAL_COMMANDS.includes(command)) throw new Error(unknownCommandMessage(command));
|
|
11
36
|
const { options, positional, passthrough } = parseArgs(rest, command);
|
|
12
37
|
// Without --root, resolve the repository (HARNESS_REPO_ROOT or the nearest .git ancestor), not a subdirectory cwd.
|
|
13
38
|
const root = resolveRepoRoot({ explicitRoot: options.root ?? null });
|
|
14
39
|
const emit = options.quiet ? () => {} : stdout;
|
|
40
|
+
// Terminal notices go to stderr only; stdout and the exit code stay those of the transition.
|
|
41
|
+
const onTerminal = (record, { historyError } = {}) => {
|
|
42
|
+
if (historyError) stderr(`Cairn warning: goal history was not recorded: ${firstLine(historyError)}`);
|
|
43
|
+
if (record?.status === "completed" && record.memoryPromotion === "missing") stderr(memoryPromotionReminder(record.planId));
|
|
44
|
+
};
|
|
15
45
|
if (command === "start") {
|
|
16
46
|
const tasks = parseTasks(options.tasks ?? positional.slice(0));
|
|
17
47
|
const state = await operations.startGoal({
|
|
@@ -137,6 +167,7 @@ export async function runGoalCli(args, {
|
|
|
137
167
|
if (command === "finish") {
|
|
138
168
|
const state = await operations.finishGoal({
|
|
139
169
|
root,
|
|
170
|
+
onTerminal,
|
|
140
171
|
watchPaths: splitValues(options.watch),
|
|
141
172
|
argv: passthrough,
|
|
142
173
|
timeoutMs: options["timeout-ms"] === undefined
|
|
@@ -154,6 +185,12 @@ export async function runGoalCli(args, {
|
|
|
154
185
|
return summary;
|
|
155
186
|
}
|
|
156
187
|
if (command === "stats") {
|
|
188
|
+
if (options.limit !== undefined && !options.history) throw new Error("--limit requires --history");
|
|
189
|
+
if (options.history) {
|
|
190
|
+
const history = await operations.goalHistoryStats({ root, limit: options.limit });
|
|
191
|
+
stdout(JSON.stringify(history));
|
|
192
|
+
return history;
|
|
193
|
+
}
|
|
157
194
|
const stats = operations.goalStats(await operations.readGoalState({ root }), root);
|
|
158
195
|
stdout(JSON.stringify(stats));
|
|
159
196
|
return stats;
|
|
@@ -163,18 +200,23 @@ export async function runGoalCli(args, {
|
|
|
163
200
|
root,
|
|
164
201
|
status: { pause: "paused", resume: "active", block: "blocked", cancel: "cancelled", complete: "completed" }[command],
|
|
165
202
|
blocker: options.reason,
|
|
203
|
+
onTerminal,
|
|
166
204
|
});
|
|
167
205
|
emit(JSON.stringify(state));
|
|
168
206
|
return state;
|
|
169
207
|
}
|
|
170
|
-
throw new Error(
|
|
208
|
+
throw new Error(unknownCommandMessage(command));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function unknownCommandMessage(command) {
|
|
212
|
+
return `unknown goal command: ${command} (run cairn goal --help)`;
|
|
171
213
|
}
|
|
172
214
|
|
|
173
215
|
function parseArgs(args, command) {
|
|
174
216
|
const options = {};
|
|
175
217
|
const positional = [];
|
|
176
218
|
const passthrough = [];
|
|
177
|
-
const booleanOptions = new Set(["quiet"]);
|
|
219
|
+
const booleanOptions = new Set(["quiet", "history"]);
|
|
178
220
|
const allowed = allowedOptions(command);
|
|
179
221
|
const seenCanonicalOptions = new Set();
|
|
180
222
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -232,7 +274,7 @@ function allowedOptions(command) {
|
|
|
232
274
|
receipt: ["task", "kind", "scope", "command", "exitCode", "exit-code", "exit", "timestamp", "goalId", "goal-id", "plan"],
|
|
233
275
|
verify: ["task", "kind", "scope", "watch", "timeout-ms"],
|
|
234
276
|
finish: ["watch", "timeout-ms"],
|
|
235
|
-
stats: [],
|
|
277
|
+
stats: ["history", "limit"],
|
|
236
278
|
ready: [],
|
|
237
279
|
pause: [],
|
|
238
280
|
resume: [],
|
|
@@ -257,6 +299,10 @@ function parseTasks(value) {
|
|
|
257
299
|
}
|
|
258
300
|
}
|
|
259
301
|
|
|
302
|
+
function firstLine(error) {
|
|
303
|
+
return String(error?.message ?? error).split(/\r?\n/)[0];
|
|
304
|
+
}
|
|
305
|
+
|
|
260
306
|
function splitValues(value) {
|
|
261
307
|
if (!value) return [];
|
|
262
308
|
return String(value).split("|").map((item) => item.trim()).filter(Boolean);
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
3
|
+
import { lstat, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { cairnHome, goalStateDirectory, repositoryId } from "./cairn-paths.mjs";
|
|
6
|
+
import { assertNoSymlinkComponents, safeWriteFile } from "./cairn-safe-fs.mjs";
|
|
7
|
+
|
|
8
|
+
export const GOAL_HISTORY_SCHEMA_VERSION = 1;
|
|
9
|
+
export const GOAL_HISTORY_MAX_RECORDS = 200;
|
|
10
|
+
export const GOAL_HISTORY_LIMIT_MAX = 200;
|
|
11
|
+
const TITLE_MAX_LENGTH = 120;
|
|
12
|
+
const PLAN_FILE_MAX_BYTES = 512 * 1024;
|
|
13
|
+
const RECENT_MAX = 5;
|
|
14
|
+
const TREND_MIN_COMPLETED = 4;
|
|
15
|
+
const historyStatuses = new Set(["completed", "cancelled"]);
|
|
16
|
+
const memoryPromotionValues = ["note", "none", "missing", "unknown"];
|
|
17
|
+
const memoryPromotionLine = /^\s*(?:[-*]\s*)?Memory promotion\s*:\s*(.*)$/gim;
|
|
18
|
+
|
|
19
|
+
export function goalHistoryPath(root = process.cwd()) {
|
|
20
|
+
return join(goalStateDirectory(root), "history.jsonl");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function memoryPromotionReminder(planId) {
|
|
24
|
+
return `Cairn memory: plan ${planId} has no "Memory promotion:" line. If this goal produced reusable repository knowledge, write it to docs/memory/<domain>.md and run cairn memory index; otherwise record "Memory promotion: none: <reason>" in the plan.`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Classifies the plan's memory promotion decision. Anything that is not a regular repository file of at most
|
|
28
|
+
// 512 KiB (an id such as plan-status, a symlink, a directory, a path outside root) is "unknown"; never throws.
|
|
29
|
+
export function memoryPromotionStatus(root, planId) {
|
|
30
|
+
try {
|
|
31
|
+
const workspaceRoot = resolve(root);
|
|
32
|
+
const target = resolve(workspaceRoot, String(planId));
|
|
33
|
+
const relation = relative(workspaceRoot, target);
|
|
34
|
+
if (relation === "" || relation === ".." || relation.startsWith(`..${sep}`) || isAbsolute(relation)) return "unknown";
|
|
35
|
+
let current = workspaceRoot;
|
|
36
|
+
for (const component of relation.split(sep)) {
|
|
37
|
+
current = join(current, component);
|
|
38
|
+
if (lstatSync(current).isSymbolicLink()) return "unknown";
|
|
39
|
+
}
|
|
40
|
+
const stat = lstatSync(target);
|
|
41
|
+
if (!stat.isFile() || stat.size > PLAN_FILE_MAX_BYTES) return "unknown";
|
|
42
|
+
const text = readFileSync(target, "utf8");
|
|
43
|
+
for (const match of text.matchAll(memoryPromotionLine)) {
|
|
44
|
+
const value = match[1].trim();
|
|
45
|
+
if (value.length === 0 || /^<.*>$/.test(value)) continue;
|
|
46
|
+
return /^(?:none|없음)/i.test(value) ? "none" : "note";
|
|
47
|
+
}
|
|
48
|
+
return "missing";
|
|
49
|
+
} catch {
|
|
50
|
+
return "unknown";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Builds the schemaVersion 1 summary of a terminal goal. It carries ids and counts only, never absolute paths.
|
|
55
|
+
export function buildGoalHistoryRecord(state, { root = process.cwd(), recordedBy, endedAt = state.goal.updatedAt } = {}) {
|
|
56
|
+
const { goal, tasks, receipts } = state;
|
|
57
|
+
const createdMs = Date.parse(goal.createdAt);
|
|
58
|
+
const endedMs = Date.parse(endedAt);
|
|
59
|
+
const durationMs = Number.isFinite(createdMs) && Number.isFinite(endedMs) ? Math.max(0, endedMs - createdMs) : 0;
|
|
60
|
+
const byKind = {};
|
|
61
|
+
const latest = new Set();
|
|
62
|
+
let reruns = 0;
|
|
63
|
+
// Same rerun definition as goalStats: a later receipt for an already-seen scope, task, and kind.
|
|
64
|
+
for (const receipt of receipts) {
|
|
65
|
+
byKind[receipt.kind] = (byKind[receipt.kind] ?? 0) + 1;
|
|
66
|
+
const key = `${receipt.scope}\0${receipt.taskId ?? ""}\0${receipt.kind}`;
|
|
67
|
+
if (latest.has(key)) reruns += 1;
|
|
68
|
+
latest.add(key);
|
|
69
|
+
}
|
|
70
|
+
const models = {};
|
|
71
|
+
const efforts = {};
|
|
72
|
+
for (const task of tasks) {
|
|
73
|
+
if (task.model) models[task.model] = (models[task.model] ?? 0) + 1;
|
|
74
|
+
if (task.effort) efforts[task.effort] = (efforts[task.effort] ?? 0) + 1;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
schemaVersion: GOAL_HISTORY_SCHEMA_VERSION,
|
|
78
|
+
goalId: goal.id,
|
|
79
|
+
planId: goal.planId,
|
|
80
|
+
title: Array.from(goal.title).slice(0, TITLE_MAX_LENGTH).join(""),
|
|
81
|
+
status: goal.status,
|
|
82
|
+
worktreeId: goal.worktreeId ?? null,
|
|
83
|
+
createdAt: goal.createdAt,
|
|
84
|
+
endedAt,
|
|
85
|
+
durationMs,
|
|
86
|
+
revision: state.revision,
|
|
87
|
+
evidencePolicy: goal.evidencePolicy,
|
|
88
|
+
tasks: {
|
|
89
|
+
total: tasks.length,
|
|
90
|
+
completed: tasks.filter((task) => task.status === "completed").length,
|
|
91
|
+
heavy: tasks.filter((task) => task.route === "heavy").length,
|
|
92
|
+
light: tasks.filter((task) => task.route === "light").length,
|
|
93
|
+
delegated: tasks.filter((task) => task.assignedAgentId).length,
|
|
94
|
+
design: tasks.filter((task) => task.kind === "design").length,
|
|
95
|
+
},
|
|
96
|
+
receipts: {
|
|
97
|
+
total: receipts.length,
|
|
98
|
+
tool: receipts.filter((receipt) => receipt.source === "tool").length,
|
|
99
|
+
reruns,
|
|
100
|
+
byKind,
|
|
101
|
+
},
|
|
102
|
+
waivers: {
|
|
103
|
+
effort: tasks.filter((task) => task.effortWaiver).length,
|
|
104
|
+
design: tasks.filter((task) => task.designWaiver).length,
|
|
105
|
+
},
|
|
106
|
+
models,
|
|
107
|
+
efforts,
|
|
108
|
+
memoryPromotion: memoryPromotionStatus(root, goal.planId),
|
|
109
|
+
recordedBy,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Records a terminal goal while the caller holds the goal state lock. History is best effort: it never throws, and
|
|
114
|
+
// the caller reports a returned error instead of blocking the transition.
|
|
115
|
+
export async function recordGoalHistoryUnlocked(root, state, options) {
|
|
116
|
+
let record = null;
|
|
117
|
+
try {
|
|
118
|
+
record = buildGoalHistoryRecord(state, { ...options, root });
|
|
119
|
+
await writeGoalHistoryRecordUnlocked(root, record);
|
|
120
|
+
return { record, error: null };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return { record, error };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Keeps valid lines only, replaces a record with the same goalId, appends, and keeps the newest 200 records.
|
|
127
|
+
// The state lock serializes writers per worktree; temporary file + rename keeps readers on a whole file.
|
|
128
|
+
export async function writeGoalHistoryRecordUnlocked(root, record) {
|
|
129
|
+
const path = goalHistoryPath(root);
|
|
130
|
+
await assertNoSymlinkComponents(cairnHome(), path);
|
|
131
|
+
const { records } = await readHistoryFile(path);
|
|
132
|
+
const kept = records.filter((item) => item.goalId !== record.goalId);
|
|
133
|
+
kept.push(record);
|
|
134
|
+
const text = kept.slice(-GOAL_HISTORY_MAX_RECORDS).map((item) => JSON.stringify(item)).join("\n");
|
|
135
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
136
|
+
try {
|
|
137
|
+
await safeWriteFile(cairnHome(), temporaryPath, `${text}\n`, "utf8");
|
|
138
|
+
await rename(temporaryPath, path);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readHistoryFile(path) {
|
|
146
|
+
let text;
|
|
147
|
+
try {
|
|
148
|
+
text = await readFile(path, "utf8");
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error?.code === "ENOENT") return { records: [], skippedLines: 0 };
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
const records = [];
|
|
154
|
+
let skippedLines = 0;
|
|
155
|
+
for (const line of text.split("\n")) {
|
|
156
|
+
if (line.trim().length === 0) continue;
|
|
157
|
+
let value;
|
|
158
|
+
try {
|
|
159
|
+
value = JSON.parse(line);
|
|
160
|
+
} catch {
|
|
161
|
+
value = null;
|
|
162
|
+
}
|
|
163
|
+
if (isHistoryRecord(value)) records.push(value);
|
|
164
|
+
else skippedLines += 1;
|
|
165
|
+
}
|
|
166
|
+
return { records, skippedLines };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isHistoryRecord(value) {
|
|
170
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
171
|
+
&& value.schemaVersion === GOAL_HISTORY_SCHEMA_VERSION
|
|
172
|
+
&& typeof value.goalId === "string" && value.goalId.length > 0
|
|
173
|
+
&& historyStatuses.has(value.status)
|
|
174
|
+
&& typeof value.endedAt === "string" && Number.isFinite(Date.parse(value.endedAt));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Reads every worktree history of this repository without taking locks or creating directories.
|
|
178
|
+
async function readRepositoryHistory(root) {
|
|
179
|
+
const worktreesDirectory = join(cairnHome(), "projects", repositoryId(root), "worktrees");
|
|
180
|
+
let entries;
|
|
181
|
+
try {
|
|
182
|
+
await assertNoSymlinkComponents(cairnHome(), worktreesDirectory);
|
|
183
|
+
entries = await readdir(worktreesDirectory, { withFileTypes: true });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return { records: [], skippedLines: 0 };
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
const byGoal = new Map();
|
|
189
|
+
let skippedLines = 0;
|
|
190
|
+
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
191
|
+
const path = join(worktreesDirectory, entry.name, "history.jsonl");
|
|
192
|
+
try {
|
|
193
|
+
if (!(await lstat(path)).isFile()) continue;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error?.code === "ENOENT") continue;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const file = await readHistoryFile(path);
|
|
199
|
+
skippedLines += file.skippedLines;
|
|
200
|
+
for (const record of file.records) {
|
|
201
|
+
const existing = byGoal.get(record.goalId);
|
|
202
|
+
if (!existing || Date.parse(record.endedAt) > Date.parse(existing.endedAt)) byGoal.set(record.goalId, record);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { records: [...byGoal.values()], skippedLines };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function validHistoryLimit(value) {
|
|
209
|
+
if (value === undefined || value === null) return null;
|
|
210
|
+
const text = String(value);
|
|
211
|
+
const result = Number(text);
|
|
212
|
+
if (!/^\d+$/.test(text) || !Number.isSafeInteger(result) || result < 1 || result > GOAL_HISTORY_LIMIT_MAX) {
|
|
213
|
+
throw new Error(`--limit must be an integer between 1 and ${GOAL_HISTORY_LIMIT_MAX}`);
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Repository-wide summary across goals. Medians, p90, rates, and trend use completed goals only; limit keeps the
|
|
219
|
+
// newest N records by endedAt.
|
|
220
|
+
export async function goalHistoryStats({ root = process.cwd(), limit } = {}) {
|
|
221
|
+
const normalizedLimit = validHistoryLimit(limit);
|
|
222
|
+
const history = await readRepositoryHistory(root);
|
|
223
|
+
const ordered = history.records.sort((a, b) => Date.parse(a.endedAt) - Date.parse(b.endedAt) || a.goalId.localeCompare(b.goalId));
|
|
224
|
+
const records = normalizedLimit === null ? ordered : ordered.slice(-normalizedLimit);
|
|
225
|
+
if (records.length === 0) {
|
|
226
|
+
return {
|
|
227
|
+
schemaVersion: GOAL_HISTORY_SCHEMA_VERSION,
|
|
228
|
+
scope: "repository",
|
|
229
|
+
goals: 0,
|
|
230
|
+
...(history.skippedLines > 0 ? { skippedLines: history.skippedLines } : {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const completed = records.filter((record) => record.status === "completed");
|
|
234
|
+
const memoryPromotion = Object.fromEntries(memoryPromotionValues.map((value) => [value, 0]));
|
|
235
|
+
for (const record of records) {
|
|
236
|
+
const value = memoryPromotionValues.includes(record.memoryPromotion) ? record.memoryPromotion : "unknown";
|
|
237
|
+
memoryPromotion[value] += 1;
|
|
238
|
+
}
|
|
239
|
+
const durations = completed.map((record) => count(record.durationMs)).sort((a, b) => a - b);
|
|
240
|
+
const receiptTotals = completed.map((record) => count(record.receipts?.total));
|
|
241
|
+
return {
|
|
242
|
+
schemaVersion: GOAL_HISTORY_SCHEMA_VERSION,
|
|
243
|
+
scope: "repository",
|
|
244
|
+
goals: records.length,
|
|
245
|
+
completed: completed.length,
|
|
246
|
+
cancelled: records.length - completed.length,
|
|
247
|
+
skippedLines: history.skippedLines,
|
|
248
|
+
window: { from: records[0].endedAt, to: records.at(-1).endedAt },
|
|
249
|
+
durationMs: { median: median(durations), p90: durations.length === 0 ? 0 : durations[Math.ceil(0.9 * durations.length) - 1] },
|
|
250
|
+
tasks: {
|
|
251
|
+
total: sum(completed, (record) => record.tasks?.total),
|
|
252
|
+
median: median(completed.map((record) => count(record.tasks?.total))),
|
|
253
|
+
heavy: sum(completed, (record) => record.tasks?.heavy),
|
|
254
|
+
light: sum(completed, (record) => record.tasks?.light),
|
|
255
|
+
delegated: sum(completed, (record) => record.tasks?.delegated),
|
|
256
|
+
},
|
|
257
|
+
receipts: {
|
|
258
|
+
total: sum(completed, (record) => record.receipts?.total),
|
|
259
|
+
tool: sum(completed, (record) => record.receipts?.tool),
|
|
260
|
+
reruns: sum(completed, (record) => record.receipts?.reruns),
|
|
261
|
+
rerunRate: rerunRate(completed),
|
|
262
|
+
median: median(receiptTotals),
|
|
263
|
+
},
|
|
264
|
+
waivers: {
|
|
265
|
+
effort: sum(records, (record) => record.waivers?.effort),
|
|
266
|
+
design: sum(records, (record) => record.waivers?.design),
|
|
267
|
+
},
|
|
268
|
+
memoryPromotion,
|
|
269
|
+
trend: trend(completed),
|
|
270
|
+
recent: records.slice(-RECENT_MAX).reverse().map((record) => ({
|
|
271
|
+
goalId: record.goalId,
|
|
272
|
+
planId: record.planId,
|
|
273
|
+
status: record.status,
|
|
274
|
+
endedAt: record.endedAt,
|
|
275
|
+
durationMs: count(record.durationMs),
|
|
276
|
+
tasks: count(record.tasks?.total),
|
|
277
|
+
receipts: count(record.receipts?.total),
|
|
278
|
+
reruns: count(record.receipts?.reruns),
|
|
279
|
+
memoryPromotion: record.memoryPromotion,
|
|
280
|
+
})),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Completed goals in endedAt order split in half; an odd count gives newer the extra goal.
|
|
285
|
+
function trend(completed) {
|
|
286
|
+
if (completed.length < TREND_MIN_COMPLETED) return null;
|
|
287
|
+
const middle = Math.floor(completed.length / 2);
|
|
288
|
+
const summarize = (records) => ({
|
|
289
|
+
goals: records.length,
|
|
290
|
+
durationMsMedian: median(records.map((record) => count(record.durationMs))),
|
|
291
|
+
receiptsMedian: median(records.map((record) => count(record.receipts?.total))),
|
|
292
|
+
rerunRate: rerunRate(records),
|
|
293
|
+
});
|
|
294
|
+
return { older: summarize(completed.slice(0, middle)), newer: summarize(completed.slice(middle)) };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function rerunRate(records) {
|
|
298
|
+
const total = sum(records, (record) => record.receipts?.total);
|
|
299
|
+
const reruns = sum(records, (record) => record.receipts?.reruns);
|
|
300
|
+
return Number((reruns / Math.max(1, total)).toFixed(3));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function median(values) {
|
|
304
|
+
if (values.length === 0) return 0;
|
|
305
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
306
|
+
const middle = Math.floor(sorted.length / 2);
|
|
307
|
+
return sorted.length % 2 === 1 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function sum(records, pick) {
|
|
311
|
+
return records.reduce((total, record) => total + count(pick(record)), 0);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function count(value) {
|
|
315
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
316
|
+
}
|
|
@@ -133,7 +133,7 @@ export async function verifyAndRecord({
|
|
|
133
133
|
|
|
134
134
|
// Completes the active task, proves every goal-level evidence kind with one run, and completes the goal.
|
|
135
135
|
export async function finishGoal({
|
|
136
|
-
root = process.cwd(), argv = [], watchPaths = [], runner, timeoutMs = 600_000,
|
|
136
|
+
root = process.cwd(), argv = [], watchPaths = [], runner, timeoutMs = 600_000, onTerminal,
|
|
137
137
|
} = {}) {
|
|
138
138
|
const initialState = await readGoalState({ root });
|
|
139
139
|
if (!initialState) throw new Error("No Cairn goal state exists; start a goal first");
|
|
@@ -149,7 +149,7 @@ export async function finishGoal({
|
|
|
149
149
|
if (goalKinds.length > 0) {
|
|
150
150
|
await verifyAndRecord({ root, scope: "goal", kind: goalKinds, argv, watchPaths, runner, timeoutMs });
|
|
151
151
|
}
|
|
152
|
-
return transitionGoal({ root, status: "completed" });
|
|
152
|
+
return transitionGoal({ root, status: "completed", onTerminal });
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
// Overhead measurement: how many verifications ran, how many were reruns, and which latest receipts are stale.
|