cairn-ai 0.2.2 → 0.2.4

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/worker.md CHANGED
@@ -10,6 +10,8 @@ Handle narrow, focused tasks.
10
10
 
11
11
  Before doing any assigned task, read the project-root `MEMORY.md`. Treat it as the domain knowledge and repository policy index, then read only the relevant `docs/memory/*.md` notes needed for the task.
12
12
 
13
+ At the start and after compaction, restart, or handoff, restore context in this order: root `MEMORY.md` → the phase skill named by the assignment → the active plan → current-task references → model guidance recorded by that plan. The assignment must identify the plan and current task whenever persisted work is active. If a required reference is missing, unreadable, or inconsistent with the assignment or goal state, report a blocker and do not edit, delegate, or claim completion.
14
+
13
15
  When subagent tools are available, you may recursively delegate bounded sub-tasks to subagents. Every child subagent must read the project-root `MEMORY.md`, keep the assigned scope, and preserve others' edits.
14
16
 
15
17
  Use for:
@@ -8,15 +8,17 @@ Run from the published or global package:
8
8
  bunx cairn-ai@latest doctor
9
9
  ```
10
10
 
11
+ The doctor validates the custom marketplace installation and ownership manifest without repairing it. Diagnostics are read-only; rollback is performed only by lifecycle transaction recovery.
12
+
11
13
  Checks:
12
14
 
13
15
  - Source plugin manifest.
14
16
  - Codex cached installation.
15
17
  - Marketplace file.
16
18
  - Hook manifest link in the installed copy.
17
- - `features.plugins`, `features.plugin_hooks`, `features.multi_agent`.
18
- - `agents.max_depth`.
19
- - `cairn@cairn` plugin enabled.
19
+ - ownership manifest presence and managed destination digests.
20
+ - `cairn@cairn` installed/enabled/version from the actual Codex CLI.
21
+ - effective Codex plugin/hook/multi-agent feature state (reported, never forced).
20
22
  - hook trust state.
21
23
  - Claude Code commands and agents mirror files.
22
24
  - Antigravity IDE/CLI skills and workflows mirror files.
@@ -8,14 +8,15 @@ Run from the published or global package, not from the installed cache copy:
8
8
  bunx cairn-ai@latest install
9
9
  ```
10
10
 
11
- The current custom lifecycle remains in use. Do not invoke lifecycle install/upgrade from the cached plugin root because that copy is the replacement target.
11
+ The current custom marketplace lifecycle remains in use; this is not a migration to `codex plugin add`. Do not invoke lifecycle install/upgrade from the cached plugin root because that copy is the replacement target.
12
12
 
13
13
  Actions:
14
14
 
15
- - Copy the Cairn installation into the Codex marketplace cache.
15
+ - Stage and validate the Cairn installation, then transactionally commit the custom marketplace source and versioned runtime cache.
16
16
  - Add the `hooks/hooks.json` link to the installed manifest.
17
- - Register marketplace, plugin, and hook trust state in `~/.codex/config.toml`.
17
+ - Register only Cairn-owned marketplace, plugin, and hook trust sections in `~/.codex/config.toml`; do not rewrite public feature or agent settings.
18
18
  - Install `cairn-*` commands and agents mirror files for Claude Code.
19
19
  - Install `cairn-*` skills and workflows mirror files for Antigravity IDE/CLI.
20
20
  - Write runtime locators so all mirrors resolve scripts, templates, and model guidance from the installed plugin root.
21
- - Create `*.cairn-backup-*` backups before changing settings.
21
+ - Record every managed destination and digest in the ownership manifest under the custom marketplace, and rollback committed phases in reverse order on failure.
22
+ - Adopt an exact, unmodified supported legacy install only after release-integrity validation; preserve and reject modified or unknown artifacts.
@@ -8,11 +8,15 @@ Run from the published or global package:
8
8
  bunx cairn-ai@latest uninstall
9
9
  ```
10
10
 
11
- Removed items:
11
+ The custom marketplace ownership manifest is authoritative. Uninstall removes or restores only destinations whose current digest still matches the recorded installed digest; modified or unmanaged targets are preserved as conflicts. A failure rolls back the transaction.
12
+
13
+ Managed items:
12
14
 
13
15
  - Cairn marketplace, plugin, and hook trust state blocks in `~/.codex/config.toml`.
14
16
  - `~/.codex/plugins/cache/cairn`.
15
17
  - Claude Code `cairn-*` commands and agents mirror files.
16
18
  - Antigravity IDE/CLI `cairn-*` skills and workflows mirror files.
17
19
 
18
- The source work folder is not deleted.
20
+ After the transaction commits, Cairn removes empty managed scaffold directories bottom-up and removes the custom marketplace root only after releasing its own lifecycle lock. A directory containing any unmanaged child is preserved; pruning never uses recursive deletion.
21
+
22
+ The source work folder and repository `MEMORY.md`, `PLAN.md`, `docs/`, and `.cairn/state.json` are not deleted. The globally installed `cairn-ai` package, package-manager download caches, legacy backup files, and legacy shared settings not recorded by the current ownership manifest are also outside this command's scope and require a separate, explicit cleanup decision.
@@ -8,13 +8,13 @@ Run from the published or global package, not from the installed cache copy:
8
8
  bunx cairn-ai@latest upgrade
9
9
  ```
10
10
 
11
- The current custom lifecycle remains in use. The cached plugin root is a replacement target and must not upgrade itself.
11
+ The current custom marketplace lifecycle remains in use. The cached plugin root is a replacement target and must not upgrade itself.
12
12
 
13
13
  Actions:
14
14
 
15
- - Replace the Cairn installation in the Codex cache.
16
- - Recompute hook trust state and update settings.
17
- - Overwrite Claude Code mirror files with the latest content.
18
- - Overwrite Antigravity IDE/CLI skills and workflows mirror files with the latest content.
15
+ - Replace only files tracked by the ownership manifest whose current digest still matches the installed digest.
16
+ - Recompute Cairn-owned hook trust state without rewriting public feature or agent settings.
17
+ - Replace owned Claude Code and Antigravity mirror files with the latest content.
19
18
  - Refresh runtime locators for every installed surface.
20
- - Create `*.cairn-backup-*` backups before changing settings.
19
+ - Stage and validate all candidates before commit; rollback committed phases in reverse order on failure.
20
+ - Preserve and report a conflict for modified, unmanaged, or invalid legacy artifacts.
@@ -19,9 +19,9 @@ Procedure:
19
19
  9. Give implementation the test contract, failing evidence, exact file ownership, and constraints. Require minimal implementation and no repeated broad discovery. Preserve status, final-report, close-and-review, recursive bounded delegation, and main-agent fallback rules.
20
20
  10. Treat tool exit codes and bounded summaries as authoritative. Keep success output concise; expand only the failing test and related code context.
21
21
  11. For Heavy Path, run the recorded automated test command and record explicit `Tests:` evidence.
22
- 12. Run focused module acceptance, then one final surface integration check through `goal verify -- ...` so the tool exit code, bounded output, and watched-workspace fingerprint are recorded together.
22
+ 12. Run focused module acceptance, then one final surface integration check through `goal verify -- ...`. Verification defaults to 600,000 ms (10 minutes), may be shortened with `--timeout-ms`, and is capped at 3,600,000 ms (1 hour). The command runs without holding the state lock; evidence is recorded only if the starting goal/task identity and the pre/post watched fingerprint still match.
23
23
  13. Before package verification, inspect package lifecycle scripts. Run normal `npm pack --dry-run` by default. Content-producing or unknown lifecycle scripts must never use `--ignore-scripts`; only absent or proven content-neutral scripts may use it when the prior full check remains fresh.
24
- 14. Record evidence bound to goal/task/plan. Missing, failed, skipped, stale, or placeholder evidence never completes work; relevant mutation invalidates affected evidence.
24
+ 14. Record evidence at the semantic boundary that proves the claimed behavior, bound to goal/task/plan. Missing, failed, skipped, stale, or placeholder evidence never completes work; relevant mutation invalidates affected evidence.
25
25
  15. Complete the task and continue the goal. Use `--quiet` on successful state mutations; workers report only their assigned task and never select the next one.
26
26
  16. Update the plan, goal state, and `PLAN.md` only after evidence exists.
27
27
  17. Answer side questions briefly and resume unless explicitly asked to pause, stop, or switch.
@@ -14,6 +14,7 @@ The goal is not to make prompts long by model. The goal is to stabilize the same
14
14
 
15
15
  ## Common Rules
16
16
 
17
+ - On every fresh entry, compaction recovery, restart, delegation, or handoff, restore context in this order: root `MEMORY.md` → current phase skill → active plan → current-task references → model guidance recorded by the plan. If persisted state or an assignment requires a reference that is missing, unreadable, or inconsistent, fail closed: do not edit, delegate, approve, or complete; report or record a blocker.
17
18
  - Keep root `MEMORY.md` and `PLAN.md` as indexes only.
18
19
  - Keep repository-specific judgment in `docs/memory/` and `docs/plan/`. Keep Cairn's model guidance in the installed plugin and reference it with `cairn://docs/model-guidance/...`.
19
20
  - Preserve proper nouns, file names, variable names, service names, alert names, MCP tool names, and agent names exactly as written.
@@ -29,13 +30,22 @@ The goal is not to make prompts long by model. The goal is to stabilize the same
29
30
  - Treat missing, failed, skipped, stale, or placeholder evidence as failure. Task evidence records must be bound to the current goal, task, and plan.
30
31
  - Treat the repository Cairn state as the fail-closed transition authority. Advance the matching Codex UI plan step only after the evidence-gated repository task transition succeeds.
31
32
  - Spend reasoning on a focused executable test contract before implementation: requirements, invariants, boundaries, and failure modes. Give implementation only that contract, failing evidence, file scope, and constraints; require the minimum passing change.
32
- - Run verification through `goal verify -- ...`; treat tool exit codes and bounded machine summaries as authoritative. Compress success output and expand context only for failing tests and related code. The exact argv, output digest, and watched-workspace fingerprint make stale evidence fail closed.
33
+ - Run verification through `goal verify -- ...`; its default timeout is 600,000 ms and its maximum is 3,600,000 ms. Treat tool exit codes and bounded machine summaries as authoritative. Record evidence only when the starting goal/task identity and pre/post watched fingerprint still match, and choose the watch set and command at the semantic boundary that proves the claim. Compress success output and expand context only for failing tests and related code.
33
34
  - Use a verification ladder: focused contract tests, one full check after the final change, then package dry-run while the full check remains fresh. Inspect package lifecycle scripts first and run normal `npm pack --dry-run` by default. Content-producing or unknown lifecycle scripts must never use `--ignore-scripts`; only absent or proven content-neutral scripts may use it.
34
35
  - Every implementation task must pass module acceptance verification and surface integration verification.
35
36
  - Before mutating external state, run the closest available dry-run, check, plan, diff, validate, or equivalent command.
36
37
  - Limit each task to two verification passes by default. After two failed passes, record the blocker or split it into sub-tasks instead of continuing a repeated loop.
37
38
  - User-visible responses and generated or updated documentation, plans, and memory artifacts must follow the OS locale unless the user asks for another language.
38
39
 
40
+ ## Prompt Cache Shape
41
+
42
+ - Keep `.codex-plugin/plugin.json` `defaultPrompt` as a small, stable policy and recovery kernel. Put detailed bootstrap, delegation, test, package, and verification procedures in the phase skills and this installed guidance.
43
+ - Put static instructions before dynamic goal data. Hook capsules must be deterministic for the same locale, event, and persisted state; prompt text and turn IDs must not change their content.
44
+ - Idle capsules reference root `MEMORY.md` and `cairn-plan`; active-work capsules reference the active plan, current task, and `cairn-work`; all-tasks-complete capsules reference `cairn-review`.
45
+ - A foreign session receives only a generic ownership-conflict capsule on session/prompt events. It must not expose goal, plan, or task details, and it must not make stop hooks block work owned by another session.
46
+ - Character budgets are regression proxies for cache-friendly prompt shape, not measurements of provider cache hits or cost. Cairn does not require provider cache keys, breakpoints, or live API telemetry.
47
+ - Do not add read receipts that claim model attention. Restore references on every re-entry and use readable state plus fresh bound evidence as the enforceable contract.
48
+
39
49
  ## Delegation Defaults
40
50
 
41
51
  - `explorer`: use for read-only codebase discovery, impact analysis, pattern searches, and read-only verification when available.
@@ -17,6 +17,7 @@ Codex-family models are strong at small implementation tasks, explicit tool use,
17
17
 
18
18
  ## Adjustment Rules
19
19
 
20
+ - On every fresh entry, compaction recovery, restart, delegation, or handoff, restore root `MEMORY.md` → current phase skill → active plan → current-task references → recorded model guidance. If persisted state or the assignment requires a missing, unreadable, or inconsistent reference, fail closed and report a blocker before editing, delegating, approving, or completing.
20
21
  - Do not start implementation without a plan.
21
22
  - Do not stop before local implementation solely because subagent tools are unavailable; the main agent takes over implementation directly and records that takeover in evidence.
22
23
  - Keep work units small. Split large changes into smaller plan tasks.
@@ -26,6 +27,7 @@ Codex-family models are strong at small implementation tasks, explicit tool use,
26
27
  - Do not skip LSP, typecheck, lint, dry-run, or verification because a tool is missing. Try repository-local installation or record the failed install attempt as a blocker.
27
28
  - Do not continue an open-ended verification loop. Use at most two verification passes per task unless the plan records a risk-based exception.
28
29
  - Write user-visible responses and generated or updated documentation, plans, and memory artifacts in the OS locale unless the user asks for another language.
30
+ - Preserve the stable prompt prefix: keep detailed workflow in phase skills/model guidance, and do not vary a hook capsule with prompt text or turn ID when persisted state is unchanged.
29
31
 
30
32
  ## Path Guidance
31
33
 
@@ -42,7 +44,7 @@ Codex-family models are strong at small implementation tasks, explicit tool use,
42
44
  ### Implementation
43
45
 
44
46
  - Invest Codex reasoning in executable test design first, then hand the bounded failing contract to implementation and require the minimum passing patch.
45
- - Run verification through `goal verify -- ...`. On success, retain exact argv, tool exit code, pass count, output digest, and watched-workspace fingerprint. Expand diagnostics only for the failing test and its related file/symbol scope; reject stale evidence after related changes.
47
+ - Run verification through `goal verify -- ...`. Its default timeout is 600,000 ms (10 minutes), capped at 3,600,000 ms (1 hour). On success, retain exact argv, tool exit code, pass count, output digest, and watched-workspace fingerprint; commit evidence only when the starting goal/task identity and pre/post watched fingerprint match. Choose the watch set and command at the semantic boundary that proves the claim. Expand diagnostics only for the failing test and its related file/symbol scope; reject stale evidence after related changes.
46
48
  - If acting as the user-called/main agent, orchestrate the work and delegate actual implementation edits to `worker` whenever subagent tools are available, regardless of Light Path or Heavy Path.
47
49
  - If acting as the user-called/main agent, immediately relay received subagent status events to the user. If no mid-run reporting channel exists, relay observable events such as assignment, waiting, and final completion.
48
50
  - If acting as the user-called/main agent, close or release completed subagents after capturing their final report and evidence, then review the final report and evidence before marking the work complete.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairn-ai",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Token-efficient agent harness with tool readiness for Codex, Claude Code, and Antigravity",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -39,7 +39,7 @@
39
39
  "README.*.md"
40
40
  ],
41
41
  "scripts": {
42
- "check": "node --check scripts/cairn.mjs && node --check scripts/cairn-paths.mjs && node --check scripts/cairn-goal.mjs && node --check scripts/cairn-state.mjs && node --check scripts/cairn-lifecycle.mjs && node --check scripts/cairn-toolcheck.mjs && node -e \"JSON.parse(require('node:fs').readFileSync('package.json','utf8')); JSON.parse(require('node:fs').readFileSync('.codex-plugin/plugin.json','utf8')); JSON.parse(require('node:fs').readFileSync('hooks/hooks.json','utf8'))\" && npm test",
42
+ "check": "node --check scripts/cairn.mjs && node --check scripts/cairn-paths.mjs && node --check scripts/cairn-safe-fs.mjs && node --check scripts/cairn-goal.mjs && node --check scripts/cairn-state.mjs && node --check scripts/cairn-lifecycle.mjs && node --check scripts/cairn-toolcheck.mjs && node -e \"JSON.parse(require('node:fs').readFileSync('package.json','utf8')); JSON.parse(require('node:fs').readFileSync('.codex-plugin/plugin.json','utf8')); JSON.parse(require('node:fs').readFileSync('hooks/hooks.json','utf8')); JSON.parse(require('node:fs').readFileSync('scripts/release-integrity-0.2.2.json','utf8'))\" && npm test",
43
43
  "test": "node --test",
44
44
  "prepack": "npm run check"
45
45
  },
@@ -2,13 +2,17 @@
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { lstatSync, readFileSync, readlinkSync, realpathSync, readdirSync } from "node:fs";
5
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
- import { dirname, join, relative, resolve, sep } from "node:path";
5
+ import { readFile, rename, rm } from "node:fs/promises";
6
+ import { join, relative, resolve, sep } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { assertNoSymlinkComponents, safeMkdir, safeWriteFile, withStateLock } from "./cairn-safe-fs.mjs";
8
9
 
9
10
  export const GOAL_STATE_VERSION = 2;
10
11
  export const GOAL_STATUSES = new Set(["active", "paused", "blocked", "cancelled", "completed"]);
11
12
  export const TASK_STATUSES = new Set(["pending", "active", "blocked", "completed"]);
13
+ export const PLAN_ID_MAX_LENGTH = 128;
14
+ export const TASK_ID_MAX_LENGTH = 64;
15
+ export const RECOVERY_REFERENCE_MAX_LENGTH = 160;
12
16
 
13
17
  const terminalGoalStatuses = new Set(["cancelled", "completed"]);
14
18
  const evidencePolicies = new Set(["declared", "tool-bound"]);
@@ -34,7 +38,9 @@ export function goalStatePath(root = process.cwd()) {
34
38
 
35
39
  export async function readGoalState({ root = process.cwd() } = {}) {
36
40
  try {
37
- const text = await readFile(goalStatePath(root), "utf8");
41
+ const path = goalStatePath(root);
42
+ await assertNoSymlinkComponents(root, path);
43
+ const text = await readFile(path, "utf8");
38
44
  return validateState(migrateState(JSON.parse(text)));
39
45
  } catch (error) {
40
46
  if (error?.code === "ENOENT") return null;
@@ -45,47 +51,55 @@ export async function readGoalState({ root = process.cwd() } = {}) {
45
51
 
46
52
  export async function startGoal({ root = process.cwd(), goal, planId, tasks, completionCriteria = [], requiredEvidence = defaultGoalEvidence, ownerSessionId = null, evidencePolicy = "tool-bound" } = {}) {
47
53
  const normalizedGoal = requiredText(goal, "goal");
48
- const normalizedPlanId = requiredText(planId, "planId");
54
+ const normalizedPlanId = boundedRequiredText(planId, "planId", PLAN_ID_MAX_LENGTH);
49
55
  if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("tasks must contain at least one task");
50
56
 
51
- const existing = await readGoalState({ root });
52
- if (existing && !terminalGoalStatuses.has(existing.goal.status)) {
53
- throw new Error(`An active Cairn goal already exists (${existing.goal.id}); pause, block, cancel, or complete it first`);
54
- }
57
+ return withStateLock(root, async () => {
58
+ const existing = await readGoalState({ root });
59
+ if (existing && !terminalGoalStatuses.has(existing.goal.status)) {
60
+ throw new Error(`An active Cairn goal already exists (${existing.goal.id}); pause, block, cancel, or complete it first`);
61
+ }
55
62
 
56
- const now = timestamp();
57
- const goalId = `goal-${randomUUID()}`;
58
- const normalizedTasks = tasks.map((task, index) => normalizeTask(task, index, index === 0 ? "active" : "pending"));
59
- const state = {
60
- schemaVersion: GOAL_STATE_VERSION,
61
- revision: 1,
62
- goal: {
63
- id: goalId,
64
- title: normalizedGoal,
65
- planId: normalizedPlanId,
66
- status: "active",
67
- completionCriteria: normalizeCriteria(completionCriteria),
68
- requiredEvidence: normalizeCriteria(requiredEvidence),
69
- evidencePolicy: validEvidencePolicy(evidencePolicy),
70
- ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
71
- blocker: null,
72
- createdAt: now,
73
- updatedAt: now,
74
- },
75
- tasks: normalizedTasks,
76
- receipts: [],
77
- };
78
- await writeGoalState({ root, state });
79
- return state;
63
+ const now = timestamp();
64
+ const goalId = `goal-${randomUUID()}`;
65
+ const normalizedTasks = tasks.map((task, index) => normalizeTask(task, index, index === 0 ? "active" : "pending"));
66
+ for (const task of normalizedTasks) assertRecoveryReferenceBudget(normalizedPlanId, task.id);
67
+ const state = {
68
+ schemaVersion: GOAL_STATE_VERSION,
69
+ revision: 1,
70
+ goal: {
71
+ id: goalId,
72
+ title: normalizedGoal,
73
+ planId: normalizedPlanId,
74
+ status: "active",
75
+ completionCriteria: normalizeCriteria(completionCriteria),
76
+ requiredEvidence: normalizeCriteria(requiredEvidence),
77
+ evidencePolicy: validEvidencePolicy(evidencePolicy),
78
+ ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
79
+ blocker: null,
80
+ createdAt: now,
81
+ updatedAt: now,
82
+ },
83
+ tasks: normalizedTasks,
84
+ receipts: [],
85
+ };
86
+ await writeGoalStateUnlocked({ root, state });
87
+ return state;
88
+ });
80
89
  }
81
90
 
82
91
  export async function writeGoalState({ root = process.cwd(), state } = {}) {
92
+ return withStateLock(root, () => writeGoalStateUnlocked({ root, state }));
93
+ }
94
+
95
+ async function writeGoalStateUnlocked({ root = process.cwd(), state } = {}) {
83
96
  const validated = validateState(state);
84
97
  const path = goalStatePath(root);
85
- await mkdir(dirname(path), { recursive: true });
98
+ await safeMkdir(root, ".cairn");
99
+ await assertNoSymlinkComponents(root, path);
86
100
  const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
87
101
  try {
88
- await writeFile(temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
102
+ await safeWriteFile(root, temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
89
103
  await rename(temporaryPath, path);
90
104
  } catch (error) {
91
105
  await rm(temporaryPath, { force: true }).catch(() => {});
@@ -175,6 +189,8 @@ async function recordEvidence({
175
189
  summary,
176
190
  workspaceFingerprint: fingerprint,
177
191
  watchPaths = [],
192
+ expectedIdentity,
193
+ expectedFingerprint,
178
194
  } = {}) {
179
195
  const normalizedScope = validReceiptScope(scope);
180
196
  const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
@@ -189,6 +205,18 @@ async function recordEvidence({
189
205
  return mutateGoal(root, (state) => {
190
206
  assertGoalMutable(state);
191
207
  const task = normalizedScope === "task" ? taskById(state, normalizedTaskId) : null;
208
+ if (expectedIdentity && (
209
+ state.goal.id !== expectedIdentity.goalId
210
+ || state.goal.planId !== expectedIdentity.planId
211
+ )) throw new Error("Goal identity changed during verification; evidence was not recorded");
212
+ if (expectedIdentity && normalizedScope === "task" && (
213
+ task?.id !== expectedIdentity.taskId
214
+ || task?.status !== expectedIdentity.taskStatus
215
+ || task?.assignedAgentId !== expectedIdentity.taskAgentId
216
+ )) throw new Error("Verification task changed during verification; evidence was not recorded");
217
+ if (expectedFingerprint && workspaceFingerprint(root, watchPaths) !== expectedFingerprint) {
218
+ throw new Error("Watched workspace changed during verification; evidence was not recorded");
219
+ }
192
220
  if (goalId !== undefined && goalId !== state.goal.id) throw new Error("evidence goalId does not match the active goal");
193
221
  if (planId !== undefined && planId !== state.goal.planId) throw new Error("evidence planId does not match the active goal");
194
222
  const receipt = {
@@ -219,6 +247,7 @@ export async function verifyAndRecord({
219
247
  argv,
220
248
  watchPaths = [],
221
249
  runner = spawnSync,
250
+ timeoutMs = 600_000,
222
251
  } = {}) {
223
252
  const normalizedArgv = validArgv(argv);
224
253
  const normalizedWatchPaths = normalizeWatchPaths(root, watchPaths);
@@ -228,13 +257,26 @@ export async function verifyAndRecord({
228
257
  if (!initialState) throw new Error("No Cairn goal state exists; start a goal first");
229
258
  assertGoalMutable(initialState);
230
259
  const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
231
- if (normalizedScope === "task") taskById(initialState, normalizedTaskId);
260
+ const initialTask = normalizedScope === "task" ? taskById(initialState, normalizedTaskId) : null;
261
+ if (initialTask && !["active", "completed"].includes(initialTask.status)) {
262
+ throw new Error(`Verification task must be active or completed: ${initialTask.id}`);
263
+ }
264
+ const identity = {
265
+ goalId: initialState.goal.id,
266
+ planId: initialState.goal.planId,
267
+ taskId: normalizedTaskId,
268
+ taskStatus: initialTask?.status,
269
+ taskAgentId: initialTask?.assignedAgentId,
270
+ };
271
+ const initialFingerprint = workspaceFingerprint(root, normalizedWatchPaths);
232
272
  const result = runner(normalizedArgv[0], normalizedArgv.slice(1), {
233
273
  cwd: resolve(root),
234
274
  encoding: "utf8",
235
275
  maxBuffer: 16 * 1024 * 1024,
236
276
  shell: false,
277
+ timeout: validTimeout(timeoutMs),
237
278
  });
279
+ if (result.error?.code === "ETIMEDOUT") throw new Error(`Verification timed out after ${timeoutMs}ms`);
238
280
  if (result.error) throw new Error(`Verification could not start: ${result.error.message}`);
239
281
  const exitCode = Number.isInteger(result.status) ? result.status : 1;
240
282
  const output = combinedOutput(result.stdout, result.stderr);
@@ -245,6 +287,17 @@ export async function verifyAndRecord({
245
287
 
246
288
  const outputDigest = sha256(output);
247
289
  const fingerprint = workspaceFingerprint(root, normalizedWatchPaths);
290
+ if (fingerprint !== initialFingerprint) throw new Error("Watched workspace changed during verification; evidence was not recorded");
291
+ const postState = await readGoalState({ root });
292
+ if (!postState || postState.goal.id !== identity.goalId || postState.goal.planId !== identity.planId) {
293
+ throw new Error("Goal identity changed during verification; evidence was not recorded");
294
+ }
295
+ if (normalizedScope === "task") {
296
+ const postTask = taskById(postState, normalizedTaskId);
297
+ if (postTask.status !== identity.taskStatus || postTask.assignedAgentId !== identity.taskAgentId) {
298
+ throw new Error("Verification task changed during verification; evidence was not recorded");
299
+ }
300
+ }
248
301
  const command = normalizedArgv.map((value) => JSON.stringify(value)).join(" ");
249
302
  const state = await recordEvidence({
250
303
  root,
@@ -259,6 +312,10 @@ export async function verifyAndRecord({
259
312
  summary: successSummary(output),
260
313
  workspaceFingerprint: fingerprint,
261
314
  watchPaths: normalizedWatchPaths,
315
+ goalId: identity.goalId,
316
+ planId: identity.planId,
317
+ expectedIdentity: identity,
318
+ expectedFingerprint: fingerprint,
262
319
  });
263
320
  return { state, evidence: state.receipts.at(-1) };
264
321
  }
@@ -312,7 +369,7 @@ export function evaluateStop(state, { subagent = false, agentId } = {}) {
312
369
  export async function handleGoalCli(args = process.argv.slice(2), { stdout = console.log } = {}) {
313
370
  const [firstCommand = "help", ...remaining] = args;
314
371
  const [command = "help", ...rest] = firstCommand === "goal" ? remaining : [firstCommand, ...remaining];
315
- const { options, positional, passthrough } = parseArgs(rest);
372
+ const { options, positional, passthrough } = parseArgs(rest, command);
316
373
  const root = options.root ?? process.cwd();
317
374
  const emit = options.quiet ? () => {} : stdout;
318
375
  if (command === "start") {
@@ -360,7 +417,7 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
360
417
  command: options.command,
361
418
  exitCode: numberOption(options.exitCode ?? options["exit-code"] ?? options.exit),
362
419
  timestamp: options.timestamp,
363
- goalId: options.goalId,
420
+ goalId: options.goalId ?? options["goal-id"],
364
421
  planId: options.plan,
365
422
  });
366
423
  emit(JSON.stringify(state));
@@ -374,6 +431,7 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
374
431
  scope: options.scope,
375
432
  watchPaths: splitValues(options.watch),
376
433
  argv: passthrough,
434
+ timeoutMs: options["timeout-ms"] === undefined ? 600_000 : positiveIntegerOption(options["timeout-ms"], "timeout-ms"),
377
435
  });
378
436
  emit(JSON.stringify({
379
437
  id: result.evidence.id,
@@ -398,18 +456,20 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
398
456
  }
399
457
 
400
458
  async function mutateGoal(root, mutate) {
401
- const state = await readGoalState({ root });
402
- if (!state) throw new Error("No Cairn goal state exists; start a goal first");
403
- const next = await mutate(structuredClone(state));
404
- next.revision += 1;
405
- await writeGoalState({ root, state: next });
406
- return next;
459
+ return withStateLock(root, async () => {
460
+ const state = await readGoalState({ root });
461
+ if (!state) throw new Error("No Cairn goal state exists; start a goal first");
462
+ const next = await mutate(structuredClone(state));
463
+ next.revision += 1;
464
+ await writeGoalStateUnlocked({ root, state: next });
465
+ return next;
466
+ });
407
467
  }
408
468
 
409
469
  function normalizeTask(task, index, defaultStatus) {
410
470
  const source = typeof task === "string" ? { title: task } : task;
411
471
  if (!source || typeof source !== "object") throw new Error(`tasks[${index}] must be a string or object`);
412
- const id = source.id === undefined ? `task-${index + 1}` : requiredText(source.id, `tasks[${index}].id`);
472
+ const id = source.id === undefined ? `task-${index + 1}` : boundedRequiredText(source.id, `tasks[${index}].id`, TASK_ID_MAX_LENGTH);
413
473
  const title = requiredText(source.title, `tasks[${index}].title`);
414
474
  return {
415
475
  id,
@@ -447,7 +507,7 @@ function validateState(value) {
447
507
  const goal = value.goal;
448
508
  requiredText(goal.id, "goal.id");
449
509
  requiredText(goal.title, "goal.title");
450
- requiredText(goal.planId, "goal.planId");
510
+ const planId = boundedRequiredText(goal.planId, "goal.planId", PLAN_ID_MAX_LENGTH);
451
511
  validGoalStatus(goal.status);
452
512
  validEvidencePolicy(goal.evidencePolicy);
453
513
  validTimestamp(goal.createdAt);
@@ -463,7 +523,8 @@ function validateState(value) {
463
523
  let activeTasks = 0;
464
524
  for (const task of value.tasks) {
465
525
  if (!task || typeof task !== "object") throw new Error("Each task must be an object");
466
- requiredText(task.id, "task.id");
526
+ const taskId = boundedRequiredText(task.id, "task.id", TASK_ID_MAX_LENGTH);
527
+ assertRecoveryReferenceBudget(planId, taskId);
467
528
  if (ids.has(task.id)) throw new Error(`Duplicate task id: ${task.id}`);
468
529
  ids.add(task.id);
469
530
  requiredText(task.title, "task.title");
@@ -574,6 +635,19 @@ function requiredText(value, label) {
574
635
  return value.trim();
575
636
  }
576
637
 
638
+ function boundedRequiredText(value, label, maxLength) {
639
+ const text = requiredText(value, label);
640
+ if (value !== text) throw new Error(`${label} must not have leading or trailing whitespace`);
641
+ if (text.length > maxLength) throw new Error(`${label} must be at most ${maxLength} characters`);
642
+ return text;
643
+ }
644
+
645
+ function assertRecoveryReferenceBudget(planId, taskId) {
646
+ if (planId.length + taskId.length > RECOVERY_REFERENCE_MAX_LENGTH) {
647
+ throw new Error(`planId and taskId must total at most ${RECOVERY_REFERENCE_MAX_LENGTH} characters for exact recovery context`);
648
+ }
649
+ }
650
+
577
651
  function validGoalStatus(value) {
578
652
  if (!GOAL_STATUSES.has(value)) throw new Error(`Invalid goal status: ${value}`);
579
653
  return value;
@@ -786,11 +860,13 @@ function timestamp() {
786
860
  return new Date().toISOString();
787
861
  }
788
862
 
789
- function parseArgs(args) {
863
+ function parseArgs(args, command) {
790
864
  const options = {};
791
865
  const positional = [];
792
866
  const passthrough = [];
793
867
  const booleanOptions = new Set(["quiet"]);
868
+ const allowed = allowedOptions(command);
869
+ const seenCanonicalOptions = new Set();
794
870
  for (let index = 0; index < args.length; index += 1) {
795
871
  const value = args[index];
796
872
  if (value === "--") {
@@ -805,13 +881,54 @@ function parseArgs(args) {
805
881
  const equalsIndex = option.indexOf("=");
806
882
  const key = equalsIndex === -1 ? option : option.slice(0, equalsIndex);
807
883
  const inline = equalsIndex === -1 ? undefined : option.slice(equalsIndex + 1);
808
- if (inline !== undefined) options[key] = booleanOptions.has(key) ? inline !== "false" : inline;
884
+ if (!allowed.has(key)) throw new Error(`Unknown option for ${command}: --${key}`);
885
+ const canonicalKey = canonicalOptionKey(key);
886
+ if (seenCanonicalOptions.has(canonicalKey)) throw new Error(`--${canonicalKey} may only be provided once`);
887
+ seenCanonicalOptions.add(canonicalKey);
888
+ if (inline !== undefined) {
889
+ if (!booleanOptions.has(key) && inline.length === 0) throw new Error(`--${key} requires a value`);
890
+ if (booleanOptions.has(key) && !["true", "false"].includes(inline)) throw new Error(`--${key} must be true or false`);
891
+ options[key] = booleanOptions.has(key) ? inline !== "false" : inline;
892
+ }
809
893
  else if (booleanOptions.has(key)) options[key] = true;
810
- else options[key] = args[++index];
894
+ else {
895
+ const next = args[index + 1];
896
+ if (next === undefined || next === "--" || next.startsWith("--")) throw new Error(`--${key} requires a value`);
897
+ options[key] = next;
898
+ index += 1;
899
+ }
811
900
  }
812
901
  return { options, positional, passthrough };
813
902
  }
814
903
 
904
+ function canonicalOptionKey(key) {
905
+ return {
906
+ exitCode: "exit-code",
907
+ exit: "exit-code",
908
+ goalId: "goal-id",
909
+ evidencePolicy: "evidence-policy",
910
+ }[key] ?? key;
911
+ }
912
+
913
+ function allowedOptions(command) {
914
+ const common = ["root", "quiet"];
915
+ const byCommand = {
916
+ start: ["goal", "plan", "tasks", "criteria", "requiredEvidence", "session", "evidencePolicy", "evidence-policy"],
917
+ status: [],
918
+ task: ["task", "status", "reason"],
919
+ assign: ["task", "agent"],
920
+ receipt: ["task", "kind", "scope", "command", "exitCode", "exit-code", "exit", "timestamp", "goalId", "goal-id", "plan"],
921
+ verify: ["task", "kind", "scope", "watch", "timeout-ms"],
922
+ pause: [],
923
+ resume: [],
924
+ block: ["reason"],
925
+ cancel: [],
926
+ complete: [],
927
+ help: [],
928
+ };
929
+ return new Set([...common, ...(byCommand[command] ?? [])]);
930
+ }
931
+
815
932
  function parseTasks(value) {
816
933
  if (Array.isArray(value)) return value;
817
934
  if (typeof value !== "string" || value.trim().length === 0) throw new Error("Provide tasks with --tasks JSON or positional task titles");
@@ -837,6 +954,19 @@ function numberOption(value) {
837
954
  return result;
838
955
  }
839
956
 
957
+ function positiveIntegerOption(value, label) {
958
+ const result = Number(value);
959
+ if (!Number.isSafeInteger(result) || result <= 0 || result > 3_600_000) {
960
+ throw new Error(`${label} must be an integer between 1 and 3600000`);
961
+ }
962
+ return result;
963
+ }
964
+
965
+ function validTimeout(value) {
966
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error("timeoutMs must be a positive integer");
967
+ return value;
968
+ }
969
+
840
970
  function isCliEntry() {
841
971
  if (!process.argv[1]) return false;
842
972
  try {