cairn-ai 0.2.5 → 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.
Files changed (69) hide show
  1. package/.agents/workflows/cairn-memory.md +2 -2
  2. package/.agents/workflows/cairn-plan.md +7 -3
  3. package/.agents/workflows/cairn-review.md +2 -2
  4. package/.agents/workflows/cairn-work.md +7 -3
  5. package/.claude/agents/explorer.md +2 -2
  6. package/.claude/agents/worker.md +2 -2
  7. package/.claude/commands/cairn-memory.md +1 -1
  8. package/.claude/commands/cairn-plan.md +1 -1
  9. package/.claude/commands/cairn-review.md +1 -1
  10. package/.claude/commands/cairn-work.md +1 -1
  11. package/.codex-plugin/plugin.json +4 -4
  12. package/README.de.md +3 -2
  13. package/README.es.md +3 -2
  14. package/README.fr.md +3 -2
  15. package/README.ja.md +3 -2
  16. package/README.ko.md +8 -5
  17. package/README.md +10 -7
  18. package/README.pt.md +3 -2
  19. package/README.zh.md +3 -2
  20. package/agents/explorer.md +2 -2
  21. package/agents/worker.md +6 -2
  22. package/commands/cairn-install.md +2 -1
  23. package/commands/cairn-memory.md +2 -2
  24. package/commands/cairn-plan.md +8 -4
  25. package/commands/cairn-review.md +2 -2
  26. package/commands/cairn-uninstall.md +1 -1
  27. package/commands/cairn-work.md +7 -3
  28. package/docs/model-guidance/README.md +16 -4
  29. package/docs/model-guidance/claude.md +2 -0
  30. package/docs/model-guidance/codex.md +4 -2
  31. package/package.json +5 -3
  32. package/scripts/cairn-cleanup.mjs +67 -0
  33. package/scripts/cairn-goal-cli.mjs +233 -0
  34. package/scripts/cairn-goal-evidence.mjs +179 -0
  35. package/scripts/cairn-goal-operations-evidence.mjs +124 -0
  36. package/scripts/cairn-goal-operations-mutations.mjs +63 -0
  37. package/scripts/cairn-goal-operations.mjs +177 -0
  38. package/scripts/cairn-goal-policy.mjs +33 -0
  39. package/scripts/cairn-goal-state-validation.mjs +199 -0
  40. package/scripts/cairn-goal-state.mjs +89 -0
  41. package/scripts/cairn-goal.mjs +34 -961
  42. package/scripts/cairn-lifecycle-commands.mjs +200 -0
  43. package/scripts/cairn-lifecycle-config.mjs +149 -0
  44. package/scripts/cairn-lifecycle-integrity.mjs +218 -0
  45. package/scripts/cairn-lifecycle-legacy.mjs +142 -0
  46. package/scripts/cairn-lifecycle-lock.mjs +205 -0
  47. package/scripts/cairn-lifecycle-mirror.mjs +91 -0
  48. package/scripts/cairn-lifecycle-staging.mjs +152 -0
  49. package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
  50. package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
  51. package/scripts/cairn-lifecycle-transaction.mjs +12 -0
  52. package/scripts/cairn-lifecycle.mjs +103 -1185
  53. package/scripts/cairn-paths.mjs +82 -6
  54. package/scripts/cairn-safe-fs.mjs +61 -35
  55. package/scripts/cairn-state-context.mjs +148 -0
  56. package/scripts/cairn-state-hook.mjs +24 -0
  57. package/scripts/cairn-state-init.mjs +137 -0
  58. package/scripts/cairn-state.mjs +15 -269
  59. package/scripts/cairn-toolcheck-detect.mjs +229 -0
  60. package/scripts/cairn-toolcheck-runtime.mjs +178 -0
  61. package/scripts/cairn-toolcheck.mjs +79 -424
  62. package/scripts/cairn.mjs +49 -27
  63. package/skills/cairn-memory/SKILL.md +4 -4
  64. package/skills/cairn-plan/SKILL.md +29 -10
  65. package/skills/cairn-review/SKILL.md +5 -5
  66. package/skills/cairn-work/SKILL.md +35 -22
  67. package/templates/PLAN.md +3 -1
  68. package/templates/work-plan.md +21 -0
  69. package/scripts/cairn-state.sh +0 -167
@@ -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
+ }
@@ -0,0 +1,63 @@
1
+ import { workspaceFingerprint } from "./cairn-goal-evidence.mjs";
2
+ import { readGoalStateUnlocked, withGoalStateLock, writeGoalStateUnlocked } from "./cairn-goal-state.mjs";
3
+ import { terminalGoalStatuses } from "./cairn-goal-state-validation.mjs";
4
+
5
+ export async function mutateGoal(root, mutate) {
6
+ return withGoalStateLock(root, async () => {
7
+ const state = await readGoalStateUnlocked(root);
8
+ if (!state) throw new Error("No Cairn goal state exists; start a goal first");
9
+ const next = await mutate(structuredClone(state));
10
+ next.revision += 1;
11
+ await writeGoalStateUnlocked({ root, state: next });
12
+ return next;
13
+ });
14
+ }
15
+
16
+ export function ensureTaskCanComplete(state, task, root) {
17
+ for (const kind of task.requiredEvidence) ensureCurrentEvidence({ state, root, scope: "task", task, kind });
18
+ }
19
+
20
+ export function ensureGoalCanComplete(state, root) {
21
+ const incomplete = state.tasks.filter((task) => task.status !== "completed");
22
+ if (incomplete.length > 0) throw new Error(`Goal cannot be completed while tasks remain: ${incomplete.map((task) => task.id).join(", ")}`);
23
+ for (const task of state.tasks) ensureTaskCanComplete(state, task, root);
24
+ for (const kind of state.goal.requiredEvidence) ensureCurrentEvidence({ state, root, scope: "goal", kind });
25
+ }
26
+
27
+ function ensureCurrentEvidence({ state, root, scope, task, kind }) {
28
+ const candidates = state.receipts.filter((receipt) => (
29
+ receipt.scope === scope && receipt.kind === kind && (scope === "goal" || receipt.taskId === task.id)
30
+ ));
31
+ const subject = scope === "goal" ? "Goal" : `Task ${task.id}`;
32
+ if (state.goal.evidencePolicy === "declared") {
33
+ if (candidates.length === 0) throw new Error(`${subject} cannot be completed without successful, bound evidence record: ${kind}`);
34
+ return;
35
+ }
36
+ const toolEvidence = candidates.filter((receipt) => receipt.source === "tool");
37
+ if (toolEvidence.length === 0) throw new Error(`${subject} cannot be completed without tool-bound evidence record: ${kind}`);
38
+ for (const receipt of [...toolEvidence].reverse()) {
39
+ if (workspaceFingerprint(root, receipt.watchPaths) === receipt.workspaceFingerprint) return;
40
+ }
41
+ throw new Error(`${subject} has stale evidence record: ${kind}`);
42
+ }
43
+
44
+ export function activateNextPendingTask(state) {
45
+ if (state.tasks.some((task) => task.status === "active")) return true;
46
+ const next = state.tasks.find((task) => task.status === "pending");
47
+ if (next) {
48
+ next.status = "active";
49
+ next.updatedAt = new Date().toISOString();
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export function assertGoalMutable(state) {
56
+ if (terminalGoalStatuses.has(state.goal.status)) throw new Error(`Cannot change a ${state.goal.status} goal`);
57
+ }
58
+
59
+ export function taskById(state, taskId) {
60
+ const task = state.tasks.find((item) => item.id === taskId);
61
+ if (!task) throw new Error(`Unknown task: ${taskId}`);
62
+ return task;
63
+ }
@@ -0,0 +1,177 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { rm } from "node:fs/promises";
3
+ import { cairnHome, worktreeId } from "./cairn-paths.mjs";
4
+ import { assertNoSymlinkComponents } from "./cairn-safe-fs.mjs";
5
+ import { goalStatePath, readGoalState, readGoalStateUnlocked, withGoalStateLock, writeGoalStateUnlocked } from "./cairn-goal-state.mjs";
6
+ import {
7
+ GOAL_STATE_VERSION, PLAN_ID_MAX_LENGTH, assertRecoveryReferenceBudget, boundedRequiredText,
8
+ defaultGoalEvidence, normalizeCriteria, normalizeTask, optionalText, requiredText,
9
+ terminalGoalStatuses, timestamp, validEvidencePolicy, validGoalStatus, validTaskStatus, validateState,
10
+ } from "./cairn-goal-state-validation.mjs";
11
+ import {
12
+ activateNextPendingTask, assertGoalMutable, ensureGoalCanComplete, ensureTaskCanComplete,
13
+ mutateGoal, taskById,
14
+ } from "./cairn-goal-operations-mutations.mjs";
15
+
16
+ const goalTransitions = {
17
+ active: new Set(["paused", "blocked", "cancelled", "completed"]),
18
+ paused: new Set(["active", "blocked", "cancelled"]),
19
+ blocked: new Set(["active", "paused", "cancelled"]),
20
+ cancelled: new Set(),
21
+ completed: new Set(),
22
+ };
23
+ const taskTransitions = {
24
+ pending: new Set(["active", "blocked"]),
25
+ active: new Set(["pending", "blocked", "completed"]),
26
+ blocked: new Set(["pending", "active"]),
27
+ completed: new Set(),
28
+ };
29
+
30
+ export async function startGoal({ root = process.cwd(), goal, planId, tasks, completionCriteria = [], requiredEvidence = defaultGoalEvidence, ownerSessionId = null, evidencePolicy = "tool-bound" } = {}) {
31
+ const normalizedGoal = requiredText(goal, "goal");
32
+ const normalizedPlanId = boundedRequiredText(planId, "planId", PLAN_ID_MAX_LENGTH);
33
+ if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("tasks must contain at least one task");
34
+ return withGoalStateLock(root, async () => {
35
+ let existing = await readGoalStateUnlocked(root);
36
+ const currentWorktreeId = worktreeId(root);
37
+ if (existing && existing.goal.worktreeId !== currentWorktreeId) {
38
+ const path = goalStatePath(root);
39
+ await assertNoSymlinkComponents(cairnHome(), path, { allowMissing: false });
40
+ await rm(path);
41
+ existing = null;
42
+ }
43
+ if (existing && !terminalGoalStatuses.has(existing.goal.status)) {
44
+ throw new Error(`An active Cairn goal already exists (${existing.goal.id}); pause, block, cancel, or complete it first`);
45
+ }
46
+ const now = timestamp();
47
+ const normalizedTasks = tasks.map((task, index) => normalizeTask(task, index, index === 0 ? "active" : "pending"));
48
+ for (const task of normalizedTasks) assertRecoveryReferenceBudget(normalizedPlanId, task.id);
49
+ const state = {
50
+ schemaVersion: GOAL_STATE_VERSION,
51
+ revision: 1,
52
+ goal: {
53
+ id: `goal-${randomUUID()}`,
54
+ title: normalizedGoal,
55
+ planId: normalizedPlanId,
56
+ status: "active",
57
+ completionCriteria: normalizeCriteria(completionCriteria),
58
+ requiredEvidence: normalizeCriteria(requiredEvidence),
59
+ evidencePolicy: validEvidencePolicy(evidencePolicy),
60
+ worktreeId: currentWorktreeId,
61
+ ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
62
+ blocker: null,
63
+ createdAt: now,
64
+ updatedAt: now,
65
+ },
66
+ tasks: normalizedTasks,
67
+ receipts: [],
68
+ };
69
+ await writeGoalStateUnlocked({ root, state });
70
+ return state;
71
+ });
72
+ }
73
+
74
+ export async function reconcileWorktreeState({ root = process.cwd() } = {}) {
75
+ const initial = await readGoalState({ root });
76
+ if (!initial || initial.goal.worktreeId === worktreeId(root)) return { state: initial, removed: false };
77
+ return withGoalStateLock(root, async () => {
78
+ const state = await readGoalStateUnlocked(root);
79
+ if (!state || state.goal.worktreeId === worktreeId(root)) return { state, removed: false };
80
+ const staleWorktreeId = state.goal.worktreeId;
81
+ const path = goalStatePath(root);
82
+ await assertNoSymlinkComponents(cairnHome(), path, { allowMissing: false });
83
+ await rm(path);
84
+ return { state: null, removed: true, staleWorktreeId };
85
+ });
86
+ }
87
+
88
+ export async function transitionGoal({ root = process.cwd(), status, blocker } = {}) {
89
+ const nextStatus = validGoalStatus(status);
90
+ if (terminalGoalStatuses.has(nextStatus)) {
91
+ return withGoalStateLock(root, async () => {
92
+ const state = await readGoalStateUnlocked(root);
93
+ if (!state) throw new Error("No Cairn goal state exists; start a goal first");
94
+ const next = applyGoalTransition(structuredClone(state), nextStatus, blocker, root);
95
+ next.revision += 1;
96
+ validateState(next);
97
+ const path = goalStatePath(root);
98
+ await assertNoSymlinkComponents(cairnHome(), path, { allowMissing: false });
99
+ await rm(path);
100
+ return next;
101
+ });
102
+ }
103
+ return mutateGoal(root, (state) => applyGoalTransition(state, nextStatus, blocker, root));
104
+ }
105
+
106
+ function applyGoalTransition(state, nextStatus, blocker, root) {
107
+ const currentStatus = state.goal.status;
108
+ if (currentStatus === nextStatus) return state;
109
+ if (!goalTransitions[currentStatus].has(nextStatus)) throw new Error(`Cannot transition goal from ${currentStatus} to ${nextStatus}`);
110
+ if (nextStatus === "completed") ensureGoalCanComplete(state, root);
111
+ if (nextStatus === "blocked") state.goal.blocker = requiredText(blocker, "blocker");
112
+ if (nextStatus === "active") state.goal.blocker = null;
113
+ state.goal.status = nextStatus;
114
+ state.goal.updatedAt = timestamp();
115
+ return state;
116
+ }
117
+
118
+ export async function setTaskStatus({ root = process.cwd(), taskId, status, blocker } = {}) {
119
+ const nextStatus = validTaskStatus(status);
120
+ const normalizedTaskId = requiredText(taskId, "taskId");
121
+ return mutateGoal(root, (state) => {
122
+ assertGoalMutable(state);
123
+ const task = taskById(state, normalizedTaskId);
124
+ if (task.status === nextStatus) return state;
125
+ if (!taskTransitions[task.status].has(nextStatus)) throw new Error(`Cannot transition task ${task.id} from ${task.status} to ${nextStatus}`);
126
+ if (nextStatus === "active") {
127
+ const anotherActive = state.tasks.find((item) => item.id !== task.id && item.status === "active");
128
+ if (anotherActive) throw new Error(`Task ${anotherActive.id} is already active`);
129
+ }
130
+ if (nextStatus === "completed") ensureTaskCanComplete(state, task, root);
131
+ if (nextStatus === "blocked") task.blocker = requiredText(blocker, "blocker");
132
+ if (nextStatus === "active" || nextStatus === "pending") task.blocker = null;
133
+ task.status = nextStatus;
134
+ task.updatedAt = timestamp();
135
+ if (nextStatus === "completed") activateNextPendingTask(state);
136
+ if (nextStatus === "blocked" && !activateNextPendingTask(state)) {
137
+ state.goal.status = "blocked";
138
+ state.goal.blocker = task.blocker;
139
+ }
140
+ state.goal.updatedAt = timestamp();
141
+ return state;
142
+ });
143
+ }
144
+
145
+ export async function replanGoal({ root = process.cwd(), tasks } = {}) {
146
+ if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("replan tasks must contain at least one incomplete task");
147
+ return mutateGoal(root, (state) => {
148
+ assertGoalMutable(state);
149
+ if (state.goal.status !== "active") throw new Error("Only an active goal can be replanned");
150
+ const completedTasks = state.tasks.filter((task) => task.status === "completed");
151
+ const completedIds = new Set(completedTasks.map((task) => task.id));
152
+ const replacementTasks = tasks.map((task, index) => {
153
+ const source = typeof task === "string" ? task : { ...task, status: undefined, blocker: undefined };
154
+ const normalized = normalizeTask(source, index, index === 0 ? "active" : "pending");
155
+ if (completedIds.has(normalized.id)) throw new Error(`Replanned task conflicts with completed task id: ${normalized.id}`);
156
+ return normalized;
157
+ });
158
+ state.tasks = [...completedTasks, ...replacementTasks];
159
+ state.receipts = state.receipts.filter((receipt) => receipt.scope === "task" && completedIds.has(receipt.taskId));
160
+ state.goal.blocker = null;
161
+ state.goal.updatedAt = timestamp();
162
+ return state;
163
+ });
164
+ }
165
+
166
+ export async function assignTask({ root = process.cwd(), taskId, agentId = null } = {}) {
167
+ const normalizedTaskId = requiredText(taskId, "taskId");
168
+ const normalizedAgentId = agentId === null ? null : requiredText(agentId, "agentId");
169
+ return mutateGoal(root, (state) => {
170
+ assertGoalMutable(state);
171
+ const task = taskById(state, normalizedTaskId);
172
+ task.assignedAgentId = normalizedAgentId;
173
+ task.updatedAt = timestamp();
174
+ state.goal.updatedAt = timestamp();
175
+ return state;
176
+ });
177
+ }
@@ -0,0 +1,33 @@
1
+ export function currentTask(state) {
2
+ if (!state) return null;
3
+ return state.tasks.find((task) => task.status === "active")
4
+ ?? state.tasks.find((task) => task.status === "pending")
5
+ ?? state.tasks.find((task) => task.status === "blocked")
6
+ ?? null;
7
+ }
8
+
9
+ export function isGoalOwnedBySession(state, sessionId) {
10
+ return !state?.goal?.ownerSessionId || state.goal.ownerSessionId === sessionId;
11
+ }
12
+
13
+ export function evaluateStop(state, { subagent = false, agentId } = {}) {
14
+ if (!state || state.goal.status !== "active") return { block: false };
15
+ const task = currentTask(state);
16
+ if (!task) {
17
+ return {
18
+ block: true,
19
+ reason: `Cairn goal "${state.goal.title}" has no incomplete task, but is still active. Verify completion criteria and explicitly mark the goal complete.`,
20
+ };
21
+ }
22
+ if (subagent) {
23
+ if (!agentId || task.assignedAgentId !== agentId) return { block: false };
24
+ return {
25
+ block: true,
26
+ reason: `Cairn task ${task.id} (${task.title}) is assigned to this subagent and is ${task.status}. Continue only this assigned task, record a successful evidence record, then hand off.`,
27
+ };
28
+ }
29
+ return {
30
+ block: true,
31
+ reason: `Cairn goal "${state.goal.title}" is active. Continue current task ${task.id} (${task.title}); its status is ${task.status}. Record a successful evidence record before marking it complete.`,
32
+ };
33
+ }