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.
- package/.agents/workflows/cairn-memory.md +2 -2
- package/.agents/workflows/cairn-plan.md +7 -3
- package/.agents/workflows/cairn-review.md +2 -2
- package/.agents/workflows/cairn-work.md +7 -3
- package/.claude/agents/explorer.md +2 -2
- package/.claude/agents/worker.md +2 -2
- package/.claude/commands/cairn-memory.md +1 -1
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-review.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/README.de.md +3 -2
- package/README.es.md +3 -2
- package/README.fr.md +3 -2
- package/README.ja.md +3 -2
- package/README.ko.md +8 -5
- package/README.md +10 -7
- package/README.pt.md +3 -2
- package/README.zh.md +3 -2
- package/agents/explorer.md +2 -2
- package/agents/worker.md +6 -2
- package/commands/cairn-install.md +2 -1
- package/commands/cairn-memory.md +2 -2
- package/commands/cairn-plan.md +8 -4
- package/commands/cairn-review.md +2 -2
- package/commands/cairn-uninstall.md +1 -1
- package/commands/cairn-work.md +7 -3
- package/docs/model-guidance/README.md +16 -4
- package/docs/model-guidance/claude.md +2 -0
- package/docs/model-guidance/codex.md +4 -2
- package/package.json +5 -3
- package/scripts/cairn-cleanup.mjs +67 -0
- package/scripts/cairn-goal-cli.mjs +233 -0
- package/scripts/cairn-goal-evidence.mjs +179 -0
- package/scripts/cairn-goal-operations-evidence.mjs +124 -0
- package/scripts/cairn-goal-operations-mutations.mjs +63 -0
- package/scripts/cairn-goal-operations.mjs +177 -0
- package/scripts/cairn-goal-policy.mjs +33 -0
- package/scripts/cairn-goal-state-validation.mjs +199 -0
- package/scripts/cairn-goal-state.mjs +89 -0
- package/scripts/cairn-goal.mjs +34 -961
- package/scripts/cairn-lifecycle-commands.mjs +200 -0
- package/scripts/cairn-lifecycle-config.mjs +149 -0
- package/scripts/cairn-lifecycle-integrity.mjs +218 -0
- package/scripts/cairn-lifecycle-legacy.mjs +142 -0
- package/scripts/cairn-lifecycle-lock.mjs +205 -0
- package/scripts/cairn-lifecycle-mirror.mjs +91 -0
- package/scripts/cairn-lifecycle-staging.mjs +152 -0
- package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
- package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/cairn-lifecycle-transaction.mjs +12 -0
- package/scripts/cairn-lifecycle.mjs +103 -1185
- package/scripts/cairn-paths.mjs +82 -6
- package/scripts/cairn-safe-fs.mjs +61 -35
- package/scripts/cairn-state-context.mjs +148 -0
- package/scripts/cairn-state-hook.mjs +24 -0
- package/scripts/cairn-state-init.mjs +137 -0
- package/scripts/cairn-state.mjs +15 -269
- package/scripts/cairn-toolcheck-detect.mjs +229 -0
- package/scripts/cairn-toolcheck-runtime.mjs +178 -0
- package/scripts/cairn-toolcheck.mjs +79 -424
- package/scripts/cairn.mjs +49 -27
- package/skills/cairn-memory/SKILL.md +4 -4
- package/skills/cairn-plan/SKILL.md +29 -10
- package/skills/cairn-review/SKILL.md +5 -5
- package/skills/cairn-work/SKILL.md +35 -22
- package/templates/PLAN.md +3 -1
- package/templates/work-plan.md +21 -0
- package/scripts/cairn-state.sh +0 -167
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { normalizeVerificationArgv } from "./cairn-goal-evidence.mjs";
|
|
2
|
+
|
|
3
|
+
export const GOAL_STATE_VERSION = 2;
|
|
4
|
+
export const GOAL_STATUSES = new Set(["active", "paused", "blocked", "cancelled", "completed"]);
|
|
5
|
+
export const TASK_STATUSES = new Set(["pending", "active", "blocked", "completed"]);
|
|
6
|
+
export const PLAN_ID_MAX_LENGTH = 128;
|
|
7
|
+
export const TASK_ID_MAX_LENGTH = 64;
|
|
8
|
+
export const RECOVERY_REFERENCE_MAX_LENGTH = 160;
|
|
9
|
+
export const GOAL_STATE_CONTRACT = Object.freeze({
|
|
10
|
+
completionCriteria: Object.freeze({ behavior: "advisory", enforced: false }),
|
|
11
|
+
terminal: Object.freeze({
|
|
12
|
+
statuses: Object.freeze(["cancelled", "completed"]),
|
|
13
|
+
persistence: "deleted",
|
|
14
|
+
readAfterTransition: null,
|
|
15
|
+
transitionResult: "terminal-snapshot",
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
export const terminalGoalStatuses = new Set(["cancelled", "completed"]);
|
|
19
|
+
export const defaultTaskEvidence = ["moduleAcceptance", "surfaceIntegration"];
|
|
20
|
+
export const defaultGoalEvidence = ["finalReview"];
|
|
21
|
+
|
|
22
|
+
const evidencePolicies = new Set(["declared", "tool-bound"]);
|
|
23
|
+
|
|
24
|
+
export function validateState(value) {
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Cairn goal state must be an object");
|
|
26
|
+
if (value.schemaVersion !== GOAL_STATE_VERSION) throw new Error(`Unsupported Cairn goal state schema version: ${value.schemaVersion}`);
|
|
27
|
+
if (!Number.isSafeInteger(value.revision) || value.revision < 1) throw new Error("Cairn goal state revision must be a positive integer");
|
|
28
|
+
if (!value.goal || typeof value.goal !== "object") throw new Error("Cairn goal state is missing goal");
|
|
29
|
+
const goal = value.goal;
|
|
30
|
+
requiredText(goal.id, "goal.id");
|
|
31
|
+
requiredText(goal.title, "goal.title");
|
|
32
|
+
const planId = boundedRequiredText(goal.planId, "goal.planId", PLAN_ID_MAX_LENGTH);
|
|
33
|
+
validGoalStatus(goal.status);
|
|
34
|
+
validEvidencePolicy(goal.evidencePolicy);
|
|
35
|
+
validTimestamp(goal.createdAt);
|
|
36
|
+
validTimestamp(goal.updatedAt);
|
|
37
|
+
if (!Array.isArray(goal.completionCriteria)) throw new Error("goal.completionCriteria must be an array");
|
|
38
|
+
if (!Array.isArray(goal.requiredEvidence) || goal.requiredEvidence.length === 0) throw new Error("goal.requiredEvidence must be a non-empty array");
|
|
39
|
+
goal.requiredEvidence.forEach((kind, index) => requiredText(kind, `goal.requiredEvidence[${index}]`));
|
|
40
|
+
if (goal.ownerSessionId !== null && goal.ownerSessionId !== undefined) requiredText(goal.ownerSessionId, "goal.ownerSessionId");
|
|
41
|
+
if (goal.worktreeId !== null && goal.worktreeId !== undefined) requiredText(goal.worktreeId, "goal.worktreeId");
|
|
42
|
+
if (goal.blocker !== null && goal.blocker !== undefined) requiredText(goal.blocker, "goal.blocker");
|
|
43
|
+
if (!Array.isArray(value.tasks) || value.tasks.length === 0) throw new Error("Cairn goal state must contain tasks");
|
|
44
|
+
if (!Array.isArray(value.receipts)) throw new Error("Cairn goal state evidence records (receipts) must be an array");
|
|
45
|
+
const ids = new Set();
|
|
46
|
+
let activeTasks = 0;
|
|
47
|
+
for (const task of value.tasks) {
|
|
48
|
+
if (!task || typeof task !== "object") throw new Error("Each task must be an object");
|
|
49
|
+
const taskId = boundedRequiredText(task.id, "task.id", TASK_ID_MAX_LENGTH);
|
|
50
|
+
assertRecoveryReferenceBudget(planId, taskId);
|
|
51
|
+
if (ids.has(task.id)) throw new Error(`Duplicate task id: ${task.id}`);
|
|
52
|
+
ids.add(task.id);
|
|
53
|
+
requiredText(task.title, "task.title");
|
|
54
|
+
validTaskStatus(task.status);
|
|
55
|
+
if (task.status === "active") activeTasks += 1;
|
|
56
|
+
if (task.assignedAgentId !== null && task.assignedAgentId !== undefined) requiredText(task.assignedAgentId, "task.assignedAgentId");
|
|
57
|
+
if (!Array.isArray(task.requiredEvidence) || task.requiredEvidence.length === 0) throw new Error(`task ${task.id} requiredEvidence must be a non-empty array`);
|
|
58
|
+
task.requiredEvidence.forEach((kind, index) => requiredText(kind, `task ${task.id} requiredEvidence[${index}]`));
|
|
59
|
+
if (task.blocker !== null && task.blocker !== undefined) requiredText(task.blocker, `task ${task.id} blocker`);
|
|
60
|
+
validTimestamp(task.createdAt);
|
|
61
|
+
validTimestamp(task.updatedAt);
|
|
62
|
+
}
|
|
63
|
+
if (activeTasks > 1) throw new Error("Only one task may be active");
|
|
64
|
+
for (const receipt of value.receipts) validateReceipt(receipt, value);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateReceipt(receipt, state) {
|
|
69
|
+
if (!receipt || typeof receipt !== "object") throw new Error("Each evidence record must be an object");
|
|
70
|
+
requiredText(receipt.id, "evidence.id");
|
|
71
|
+
const scope = validReceiptScope(receipt.scope);
|
|
72
|
+
requiredText(receipt.kind, "evidence.kind");
|
|
73
|
+
if (scope === "task") {
|
|
74
|
+
requiredText(receipt.taskId, "evidence.taskId");
|
|
75
|
+
if (!state.tasks.some((task) => task.id === receipt.taskId)) throw new Error(`Evidence record references unknown task: ${receipt.taskId}`);
|
|
76
|
+
} else if (receipt.taskId !== undefined && receipt.taskId !== null) {
|
|
77
|
+
throw new Error("goal-scope evidence record must not contain taskId");
|
|
78
|
+
}
|
|
79
|
+
if (receipt.goalId !== state.goal.id) throw new Error("evidence goalId does not match goal");
|
|
80
|
+
if (receipt.planId !== state.goal.planId) throw new Error("evidence planId does not match goal");
|
|
81
|
+
validReceiptCommand(receipt.command);
|
|
82
|
+
if (receipt.exitCode !== 0) throw new Error("evidence exitCode must be 0");
|
|
83
|
+
validTimestamp(receipt.timestamp);
|
|
84
|
+
if (validReceiptSource(receipt.source) === "tool") {
|
|
85
|
+
normalizeToolEvidence({ argv: receipt.argv, outputDigest: receipt.outputDigest, summary: receipt.summary, fingerprint: receipt.workspaceFingerprint, watchPaths: receipt.watchPaths });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function normalizeTask(task, index, defaultStatus) {
|
|
90
|
+
const source = typeof task === "string" ? { title: task } : task;
|
|
91
|
+
if (!source || typeof source !== "object") throw new Error(`tasks[${index}] must be a string or object`);
|
|
92
|
+
const id = source.id === undefined ? `task-${index + 1}` : boundedRequiredText(source.id, `tasks[${index}].id`, TASK_ID_MAX_LENGTH);
|
|
93
|
+
const title = requiredText(source.title, `tasks[${index}].title`);
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
title,
|
|
97
|
+
status: source.status === undefined ? defaultStatus : validTaskStatus(source.status),
|
|
98
|
+
assignedAgentId: source.assignedAgentId === undefined || source.assignedAgentId === null ? null : requiredText(source.assignedAgentId, `tasks[${index}].assignedAgentId`),
|
|
99
|
+
requiredEvidence: normalizeCriteria(source.requiredEvidence ?? defaultTaskEvidence),
|
|
100
|
+
blocker: null,
|
|
101
|
+
createdAt: timestamp(),
|
|
102
|
+
updatedAt: timestamp(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function migrateState(value) {
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1) return value;
|
|
108
|
+
return {
|
|
109
|
+
...value,
|
|
110
|
+
schemaVersion: GOAL_STATE_VERSION,
|
|
111
|
+
goal: { ...value.goal, evidencePolicy: value.goal?.evidencePolicy ?? "tool-bound" },
|
|
112
|
+
receipts: Array.isArray(value.receipts) ? value.receipts.map((receipt) => ({ ...receipt, source: "declared" })) : value.receipts,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function requiredText(value, label) {
|
|
117
|
+
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${label} must be a non-empty string`);
|
|
118
|
+
return value.trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function boundedRequiredText(value, label, maxLength) {
|
|
122
|
+
const text = requiredText(value, label);
|
|
123
|
+
if (value !== text) throw new Error(`${label} must not have leading or trailing whitespace`);
|
|
124
|
+
if (text.length > maxLength) throw new Error(`${label} must be at most ${maxLength} characters`);
|
|
125
|
+
return text;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function assertRecoveryReferenceBudget(planId, taskId) {
|
|
129
|
+
if (planId.length + taskId.length > RECOVERY_REFERENCE_MAX_LENGTH) throw new Error(`planId and taskId must total at most ${RECOVERY_REFERENCE_MAX_LENGTH} characters for exact recovery context`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function validGoalStatus(value) {
|
|
133
|
+
if (!GOAL_STATUSES.has(value)) throw new Error(`Invalid goal status: ${value}`);
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function validEvidencePolicy(value) {
|
|
138
|
+
if (!evidencePolicies.has(value)) throw new Error(`Invalid evidence policy: ${value}`);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function validTaskStatus(value) {
|
|
143
|
+
if (!TASK_STATUSES.has(value)) throw new Error(`Invalid task status: ${value}`);
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function validReceiptScope(value) {
|
|
148
|
+
if (value !== "task" && value !== "goal") throw new Error(`Invalid evidence scope: ${value}`);
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function validReceiptSource(value) {
|
|
153
|
+
if (value !== "declared" && value !== "tool") throw new Error(`Invalid evidence source: ${value}`);
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function validReceiptCommand(value) {
|
|
158
|
+
const command = requiredText(value, "evidence command");
|
|
159
|
+
if (/\b(skip(?:ped)?|todo|tbd|n\/?a|not[ -]?run|not[ -]?applicable|placeholder)\b/i.test(command)) throw new Error("evidence command cannot be skipped, placeholder, or incomplete");
|
|
160
|
+
return command;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function validTimestamp(value) {
|
|
164
|
+
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) throw new Error("timestamp must be an ISO-8601 date string");
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function normalizeCriteria(criteria) {
|
|
169
|
+
if (!Array.isArray(criteria)) throw new Error("completionCriteria must be an array");
|
|
170
|
+
return criteria.map((item, index) => requiredText(item, `completionCriteria[${index}]`));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths }) {
|
|
174
|
+
const normalizedArgv = normalizeVerificationArgv(argv);
|
|
175
|
+
const normalizedOutputDigest = validSha256(outputDigest, "evidence outputDigest");
|
|
176
|
+
const normalizedSummary = requiredText(summary, "evidence summary");
|
|
177
|
+
const normalizedFingerprint = validSha256(fingerprint, "evidence workspaceFingerprint");
|
|
178
|
+
if (!Array.isArray(watchPaths)) throw new Error("evidence watchPaths must be an array");
|
|
179
|
+
return {
|
|
180
|
+
argv: normalizedArgv,
|
|
181
|
+
outputDigest: normalizedOutputDigest,
|
|
182
|
+
summary: normalizedSummary,
|
|
183
|
+
workspaceFingerprint: normalizedFingerprint,
|
|
184
|
+
watchPaths: watchPaths.map((item, index) => requiredText(item, `evidence watchPaths[${index}]`)),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validSha256(value, label) {
|
|
189
|
+
if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value)) throw new Error(`${label} must be a sha256 digest`);
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function optionalText(value, label) {
|
|
194
|
+
return value === undefined || value === null ? null : requiredText(value, label);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function timestamp() {
|
|
198
|
+
return new Date().toISOString();
|
|
199
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { cairnHome, goalStateDirectory } from "./cairn-paths.mjs";
|
|
5
|
+
import { assertNoSymlinkComponents, safeMkdir, safeWriteFile, withStateLock } from "./cairn-safe-fs.mjs";
|
|
6
|
+
import { migrateState, validateState } from "./cairn-goal-state-validation.mjs";
|
|
7
|
+
|
|
8
|
+
export function goalStatePath(root = process.cwd()) {
|
|
9
|
+
return join(goalStateDirectory(root), "state.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readGoalState({ root = process.cwd() } = {}) {
|
|
13
|
+
const state = await readCurrentGoalState(root);
|
|
14
|
+
if (state) return state;
|
|
15
|
+
return withGoalStateLock(root, () => readGoalStateUnlocked(root));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function writeGoalState({ root = process.cwd(), state } = {}) {
|
|
19
|
+
return withGoalStateLock(root, () => writeGoalStateUnlocked({ root, state }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeGoalStateUnlocked({ root = process.cwd(), state } = {}) {
|
|
23
|
+
const validated = validateState(state);
|
|
24
|
+
const path = goalStatePath(root);
|
|
25
|
+
await ensureGoalStateDirectory(root);
|
|
26
|
+
await assertNoSymlinkComponents(cairnHome(), path);
|
|
27
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
28
|
+
try {
|
|
29
|
+
await safeWriteFile(cairnHome(), temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
|
|
30
|
+
await rename(temporaryPath, path);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readGoalStateUnlocked(root) {
|
|
38
|
+
return await readCurrentGoalState(root) ?? migrateLegacyGoalState(root);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readCurrentGoalState(root) {
|
|
42
|
+
try {
|
|
43
|
+
const path = goalStatePath(root);
|
|
44
|
+
await assertNoSymlinkComponents(cairnHome(), path);
|
|
45
|
+
return validateState(migrateState(JSON.parse(await readFile(path, "utf8"))));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error?.code === "ENOENT") return null;
|
|
48
|
+
if (error instanceof SyntaxError) throw new Error(`Cairn goal state is invalid JSON: ${error.message}`);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function migrateLegacyGoalState(root) {
|
|
54
|
+
const legacyPath = join(resolve(root), ".cairn", "state.json");
|
|
55
|
+
try {
|
|
56
|
+
await assertNoSymlinkComponents(root, legacyPath, { allowMissing: false });
|
|
57
|
+
const state = validateState(migrateState(JSON.parse(await readFile(legacyPath, "utf8"))));
|
|
58
|
+
await writeGoalStateUnlocked({ root, state });
|
|
59
|
+
await rm(legacyPath);
|
|
60
|
+
return state;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code === "ENOENT") return null;
|
|
63
|
+
if (error instanceof SyntaxError) throw new Error(`Cairn goal state is invalid JSON: ${error.message}`);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function withGoalStateLock(root, operation) {
|
|
69
|
+
await ensureGoalStateDirectory(root);
|
|
70
|
+
return withStateLock(goalStateDirectory(root), operation);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ensureGoalStateDirectory(root) {
|
|
74
|
+
const home = cairnHome();
|
|
75
|
+
let existingAncestor = home;
|
|
76
|
+
while (true) {
|
|
77
|
+
try {
|
|
78
|
+
const stat = await lstat(existingAncestor);
|
|
79
|
+
if (!stat.isDirectory()) throw new Error(`Cairn home ancestor is not a directory: ${existingAncestor}`);
|
|
80
|
+
break;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.code !== "ENOENT") throw error;
|
|
83
|
+
const parent = dirname(existingAncestor);
|
|
84
|
+
if (parent === existingAncestor) throw error;
|
|
85
|
+
existingAncestor = parent;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await safeMkdir(existingAncestor, relative(existingAncestor, goalStateDirectory(root)));
|
|
89
|
+
}
|