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/workflows/cairn-work.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +8 -13
- package/README.de.md +12 -10
- package/README.es.md +12 -10
- package/README.fr.md +12 -10
- package/README.ja.md +12 -10
- package/README.ko.md +12 -10
- package/README.md +12 -10
- package/README.pt.md +12 -10
- package/README.zh.md +12 -10
- package/agents/explorer.md +2 -0
- package/agents/worker.md +2 -0
- package/commands/cairn-doctor.md +5 -3
- package/commands/cairn-install.md +5 -4
- package/commands/cairn-uninstall.md +6 -2
- package/commands/cairn-upgrade.md +6 -6
- package/commands/cairn-work.md +2 -2
- package/docs/model-guidance/README.md +11 -1
- package/docs/model-guidance/codex.md +3 -1
- package/package.json +2 -2
- package/scripts/cairn-goal.mjs +179 -49
- package/scripts/cairn-lifecycle.mjs +990 -313
- package/scripts/cairn-safe-fs.mjs +246 -0
- package/scripts/cairn-state.mjs +68 -34
- package/scripts/cairn-toolcheck.mjs +116 -34
- package/scripts/release-integrity-0.2.2.json +68 -0
- package/skills/cairn-plan/SKILL.md +4 -0
- package/skills/cairn-review/SKILL.md +2 -0
- package/skills/cairn-work/SKILL.md +6 -0
- package/templates/work-plan.md +2 -0
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:
|
package/commands/cairn-doctor.md
CHANGED
|
@@ -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
|
-
-
|
|
18
|
-
- `
|
|
19
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
- Recompute hook trust state
|
|
17
|
-
-
|
|
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
|
-
-
|
|
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.
|
package/commands/cairn-work.md
CHANGED
|
@@ -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 --
|
|
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 -- ...`;
|
|
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.
|
|
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
|
},
|
package/scripts/cairn-goal.mjs
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
98
|
+
await safeMkdir(root, ".cairn");
|
|
99
|
+
await assertNoSymlinkComponents(root, path);
|
|
86
100
|
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
87
101
|
try {
|
|
88
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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}` :
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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 {
|