cairn-ai 0.2.6 → 0.2.8
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 +3 -3
- package/.agents/workflows/cairn-review.md +2 -2
- package/.agents/workflows/cairn-work.md +4 -4
- package/.claude/agents/explorer.md +1 -1
- package/.claude/agents/worker.md +1 -1
- package/.claude/commands/cairn-memory.md +1 -1
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-review.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.de.md +3 -2
- package/README.es.md +3 -2
- package/README.fr.md +3 -2
- package/README.ja.md +3 -2
- package/README.ko.md +16 -8
- package/README.md +14 -8
- package/README.pt.md +3 -2
- package/README.zh.md +3 -2
- package/agents/worker.md +3 -1
- package/commands/cairn-install.md +2 -1
- package/commands/cairn-memory.md +2 -2
- package/commands/cairn-plan.md +3 -3
- package/commands/cairn-review.md +2 -2
- package/commands/cairn-work.md +4 -4
- package/docs/model-guidance/README.md +5 -5
- package/docs/model-guidance/claude.md +1 -1
- package/docs/model-guidance/codex.md +3 -3
- package/package.json +4 -2
- package/scripts/cairn-cleanup.mjs +12 -6
- package/scripts/cairn-goal-cli.mjs +239 -0
- package/scripts/cairn-goal-evidence.mjs +186 -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 +187 -0
- package/scripts/cairn-goal-policy.mjs +36 -0
- package/scripts/cairn-goal-state-validation.mjs +199 -0
- package/scripts/cairn-goal-state.mjs +124 -0
- package/scripts/cairn-goal.mjs +33 -1078
- package/scripts/cairn-lifecycle-commands.mjs +200 -0
- package/scripts/cairn-lifecycle-config.mjs +153 -0
- package/scripts/cairn-lifecycle-integrity.mjs +218 -0
- package/scripts/cairn-lifecycle-legacy.mjs +142 -0
- package/scripts/cairn-lifecycle-lock.mjs +205 -0
- package/scripts/cairn-lifecycle-mirror.mjs +91 -0
- package/scripts/cairn-lifecycle-staging.mjs +152 -0
- package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
- package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/cairn-lifecycle-transaction.mjs +12 -0
- package/scripts/cairn-lifecycle.mjs +103 -1185
- package/scripts/cairn-paths.mjs +35 -5
- package/scripts/cairn-safe-fs.mjs +61 -35
- package/scripts/cairn-state-context.mjs +156 -0
- package/scripts/cairn-state-hook.mjs +26 -0
- package/scripts/cairn-state-init.mjs +137 -0
- package/scripts/cairn-state.mjs +32 -286
- package/scripts/cairn-toolcheck-detect.mjs +229 -0
- package/scripts/cairn-toolcheck-runtime.mjs +178 -0
- package/scripts/cairn-toolcheck.mjs +79 -424
- package/scripts/cairn.mjs +17 -3
- package/skills/cairn-memory/SKILL.md +3 -2
- package/skills/cairn-plan/SKILL.md +7 -4
- package/skills/cairn-review/SKILL.md +4 -4
- package/skills/cairn-work/SKILL.md +23 -7
- package/scripts/cairn-state.sh +0 -167
|
@@ -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,187 @@
|
|
|
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 unfinished Cairn goal already exists (${existing.goal.id}, ${existing.goal.status}); add the new work to it with goal replan (resume it first if paused or blocked), or cancel or complete it before starting another goal`);
|
|
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 claimGoal({ root = process.cwd(), sessionId } = {}) {
|
|
167
|
+
const normalizedSessionId = requiredText(sessionId, "sessionId");
|
|
168
|
+
return mutateGoal(root, (state) => {
|
|
169
|
+
assertGoalMutable(state);
|
|
170
|
+
state.goal.ownerSessionId = optionalText(normalizedSessionId, "ownerSessionId");
|
|
171
|
+
state.goal.updatedAt = timestamp();
|
|
172
|
+
return state;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function assignTask({ root = process.cwd(), taskId, agentId = null } = {}) {
|
|
177
|
+
const normalizedTaskId = requiredText(taskId, "taskId");
|
|
178
|
+
const normalizedAgentId = agentId === null ? null : requiredText(agentId, "agentId");
|
|
179
|
+
return mutateGoal(root, (state) => {
|
|
180
|
+
assertGoalMutable(state);
|
|
181
|
+
const task = taskById(state, normalizedTaskId);
|
|
182
|
+
task.assignedAgentId = normalizedAgentId;
|
|
183
|
+
task.updatedAt = timestamp();
|
|
184
|
+
state.goal.updatedAt = timestamp();
|
|
185
|
+
return state;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
if (!state?.goal?.ownerSessionId) return true;
|
|
11
|
+
// A payload without a session id cannot prove another owner; treating it as foreign would silently halt the goal.
|
|
12
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return true;
|
|
13
|
+
return state.goal.ownerSessionId === sessionId;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function evaluateStop(state, { subagent = false, agentId } = {}) {
|
|
17
|
+
if (!state || state.goal.status !== "active") return { block: false };
|
|
18
|
+
const task = currentTask(state);
|
|
19
|
+
if (!task) {
|
|
20
|
+
return {
|
|
21
|
+
block: true,
|
|
22
|
+
reason: `Cairn goal "${state.goal.title}" has no incomplete task, but is still active. Verify completion criteria and explicitly mark the goal complete.`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (subagent) {
|
|
26
|
+
if (!agentId || task.assignedAgentId !== agentId) return { block: false };
|
|
27
|
+
return {
|
|
28
|
+
block: true,
|
|
29
|
+
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.`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
block: true,
|
|
34
|
+
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.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -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
|
+
}
|