cairn-ai 0.2.3 → 0.2.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairn",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Token-efficient agent harness with tool readiness for Codex, Claude Code, and Antigravity.",
5
5
  "author": {
6
6
  "name": "WkNam"
@@ -21,18 +21,13 @@
21
21
  "Tool Readiness"
22
22
  ],
23
23
  "defaultPrompt": [
24
- "Use Cairn to create MEMORY.md and PLAN.md for this repository.",
25
- "Every agent must read the project-root MEMORY.md before planning, implementation, review, or delegation. Keep MEMORY.md and PLAN.md as short indexes; open only current-task details.",
26
- "Resolve Cairn runtime resources only through the installed locator, keep repoRoot separate, and run toolcheck before implementation planning. Tool installation requires explicit approval.",
27
- "Run cairn-plan before non-trivial changes. Understand the whole work, split it into executable tasks/sub-tasks, record Light/Heavy Path triage, and finalize a decision-complete implementation plan before editing.",
28
- "The user-called/main agent is the orchestrator. Delegate bounded implementation edits to worker subagents when allowed; otherwise the main agent implements and records takeover evidence.",
29
- "With a progress-reporting channel, subagents report start, direction, periodic progress, and finish; relay those events immediately. Otherwise relay assignment, waiting, and final completion.",
30
- "Delegated agents read MEMORY.md, preserve others' edits, return a final report, and may recursively delegate only bounded work. Capture evidence, close/release the agent, then review its report before completion.",
31
- "Implementation/continue requests authorize an active Cairn goal even without the word goal. After MEMORY.md, write an initial plan with triage-plan active; before exploration call update_plan, then create_goal, then start/attach the repository goal. Triage finalizes the repository and UI plans before implementation. Keep roadmap/current-task recovery context; consultation, explanation, and plan-only requests stay goal-free.",
32
- "Use a test contract first: spend reasoning on requirements, invariants, boundaries, and failure modes; design executable tests before implementation; make the minimum implementation that passes. Treat tool exit codes and machine summaries as authoritative, and expand context only for failing tests.",
33
- "Missing, failed, skipped, stale, or placeholder evidence never completes work. Advance a UI step only after the matching repository task passes bound evidence; workers report only assigned tasks; active is not complete.",
34
- "Use goal verify -- <argv>, quiet state mutations, and concise success summaries. Run focused tests and one final full check. Inspect package lifecycle scripts before packaging: run normal npm pack --dry-run by default; never skip content-producing or unknown scripts with --ignore-scripts, and use it only for absent or proven content-neutral scripts while evidence is fresh.",
35
- "If blocked, pause/block/cancel explicitly. Answer side questions briefly, then resume the current task unless asked to pause, stop, or switch."
24
+ "Use Cairn's repository-local MEMORY.md, PLAN.md, and persisted goal state as the recovery spine; every agent reads root MEMORY.md before planning, implementation, review, or delegation.",
25
+ "For non-trivial work, load the phase skill (cairn-plan, cairn-work, or cairn-review), then restore the active plan and current-task references before acting.",
26
+ "After compaction, restart, handoff, or delegation, restore MEMORY.md → phase skill → active plan → current task references; do not rely on conversation recall.",
27
+ "If state, skill, plan, or a required reference is missing, unreadable, or inconsistent, fail closed: do not edit, delegate, or complete work; report or block the discrepancy.",
28
+ "Missing, failed, skipped, stale, or placeholder evidence never completes work; advance UI state only after bound repository evidence passes.",
29
+ "Before external-state changes, run dry-run/check and bind the resulting evidence to the current task.",
30
+ "Answer side questions briefly, then resume the current task unless asked to pause, stop, or switch."
36
31
  ]
37
32
  }
38
33
  }
package/README.de.md CHANGED
@@ -108,6 +108,8 @@ cairn toolcheck
108
108
 
109
109
  Der custom marketplace lifecycle hält source und versioned runtime getrennt. Staged validation geht jedem commit voraus, das ownership manifest bindet alle managed digests, und ein Fehler führt zu reverse-order rollback. Modified oder unmanaged artifacts werden preserved und als conflict gemeldet. Cairn ändert nur eigene TOML sections und erzwingt keine öffentlichen feature/agent settings.
110
110
 
111
+ Clean uninstall entfernt den Cairn marketplace cache root nur, wenn alle managed scaffold directories leer sind; unmanaged Inhalte bleiben erhalten. Source repository und repository state, das globale `cairn-ai` package, package-manager caches sowie legacy backups/settings außerhalb des aktuellen ownership manifest werden nicht automatisch gelöscht.
112
+
111
113
  Codex verwendet `skills/` und `commands/`. Claude Code verwendet gespiegelte commands und agent definitions unter `.claude/`. Antigravity verwendet `.agents/workflows` und globale skills mirrors.
112
114
 
113
115
  ## Antigravity Compatibility
package/README.es.md CHANGED
@@ -108,6 +108,8 @@ cairn toolcheck
108
108
 
109
109
  El custom marketplace lifecycle mantiene separados source y versioned runtime. Staged validation precede cada commit, el ownership manifest vincula todos los managed digests y un fallo aplica reverse-order rollback. Los artifacts modified o unmanaged se preservan y se reportan como conflict. Cairn modifica solo sus propias TOML sections y no fuerza feature/agent settings públicos.
110
110
 
111
+ Clean uninstall elimina el Cairn marketplace cache root solo cuando todos los managed scaffold directories están vacíos; cualquier contenido unmanaged se conserva. No elimina el source repository ni su state, el paquete global `cairn-ai`, los package-manager caches ni legacy backups/settings fuera del ownership manifest actual.
112
+
111
113
  Codex usa `skills/` y `commands/`. Claude Code usa comandos reflejados y definiciones de agentes bajo `.claude/`. Antigravity usa `.agents/workflows` y mirrors globales de skills.
112
114
 
113
115
  ## Antigravity Compatibility
package/README.fr.md CHANGED
@@ -108,6 +108,8 @@ cairn toolcheck
108
108
 
109
109
  Le custom marketplace lifecycle sépare source et versioned runtime. Staged validation précède chaque commit, le ownership manifest lie tous les managed digests et un échec déclenche un reverse-order rollback. Les artifacts modified ou unmanaged sont preserved et signalés comme conflict. Cairn ne modifie que ses propres TOML sections et ne force aucun feature/agent setting public.
110
110
 
111
+ Clean uninstall supprime le Cairn marketplace cache root uniquement lorsque tous les managed scaffold directories sont vides ; tout contenu unmanaged est conservé. Il ne supprime ni le source repository et son state, ni le package global `cairn-ai`, les package-manager caches ou les legacy backups/settings hors du ownership manifest courant.
112
+
111
113
  Codex utilise `skills/` et `commands/`. Claude Code utilise les commandes miroir et définitions d'agents sous `.claude/`. Antigravity utilise `.agents/workflows` et les global skills mirrors.
112
114
 
113
115
  ## Antigravity Compatibility
package/README.ja.md CHANGED
@@ -108,6 +108,8 @@ cairn toolcheck
108
108
 
109
109
  custom marketplace lifecycle は source と versioned runtime を分離します。各 commit 前に staged validation を行い、ownership manifest が全 managed digest を結び、失敗時は reverse-order rollback します。modified または unmanaged artifacts は preserve して conflict として報告します。Cairn-owned TOML sections だけを変更し、public feature/agent settings は強制しません。
110
110
 
111
+ Clean uninstall は、すべての managed scaffold directory が空の場合に限り Cairn marketplace cache root を削除し、unmanaged content は保持します。source repository と repository state、global `cairn-ai` package、package-manager cache、現在の ownership manifest 外の legacy backup/settings は自動削除しません。
112
+
111
113
  Codex は `skills/` と `commands/` を使います。Claude Code は `.claude/` 配下の mirrored commands と agent definitions を使います。Antigravity は `.agents/workflows` と global skills mirrors を使います。
112
114
 
113
115
  ## Antigravity Compatibility
package/README.ko.md CHANGED
@@ -103,7 +103,7 @@ cairn toolcheck
103
103
  - `cairn install`: custom marketplace source, versioned Codex runtime cache, Cairn-owned config section, Claude Code mirror, 현재 Antigravity IDE/CLI skill 표면을 transaction으로 설치합니다.
104
104
  - `cairn upgrade`: ownership manifest의 installed digest와 현재 값이 같은 항목만 교체하며, staged validation과 역순 rollback을 적용합니다.
105
105
  - `cairn doctor`: ownership manifest, managed digest, 유효 Codex feature, 실제 plugin installed/enabled/version, mirror/runtime locator를 수정 없이 진단합니다.
106
- - `cairn uninstall`: 수정되지 않은 ownership manifest 항목만 제거·복원하고, modified/unmanaged target은 conflict로 보존하며 실패 시 rollback합니다.
106
+ - `cairn uninstall`: 수정되지 않은 ownership manifest 항목만 제거·복원한 뒤 빈 managed cache scaffold를 정리합니다. modified/unmanaged target과 그 상위 디렉터리는 보존하고 commit 전 실패는 rollback합니다.
107
107
  - `cairn toolcheck`: 저장소 스택과 필요한 LSP·검증 도구를 확인하며, 승인된 지원 installer만 실행합니다.
108
108
  - `cairn goal ...`: 저장소의 영속 goal을 시작·조회·일시정지·재개·차단·취소·완료합니다. 기본 tool-bound 정책은 `goal verify -- <argv>`가 명령을 직접 실행해 성공 증거를 기록합니다. `goal receipt`는 기존 선언 증거를 가져오는 호환 명령입니다.
109
109
  - `cairn-memory`: 도메인 지식을 탐색하고 `MEMORY.md`를 갱신합니다.
@@ -117,6 +117,8 @@ cairn toolcheck
117
117
 
118
118
  `install`과 `upgrade`는 custom marketplace lifecycle을 유지하며 `codex plugin add`를 호출하지 않습니다. candidate를 staging에서 검증한 뒤 commit하고, 모든 managed destination과 digest를 ownership manifest에 기록하며 실패 시 완료된 phase를 역순 rollback합니다. 수정되지 않은 지원 legacy 설치만 release-integrity 검증 뒤 인수하고, 수정되거나 알 수 없는 artifact는 보존한 채 거부합니다. Cairn-owned TOML section만 편집하고 public feature/agent 설정은 강제하지 않습니다. lifecycle command는 교체 대상 cache copy가 아니라 게시/전역 package에서 실행해야 합니다.
119
119
 
120
+ Clean uninstall은 모든 managed scaffold가 비었을 때만 recursive 삭제 없이 빈 디렉터리 제거로 Cairn marketplace cache root를 정리합니다. 소스 저장소와 저장소의 `MEMORY.md`/`PLAN.md`/`.cairn` 상태, 전역 `cairn-ai` package, package-manager download cache, legacy backup, 현재 ownership manifest 밖의 legacy shared setting은 지우지 않으며 별도의 명시적 정리 판단이 필요합니다.
121
+
120
122
  Codex는 `skills/`와 `commands/`를 사용합니다. Claude Code는 `.claude/` 아래의 mirror command와 agent definition을 사용합니다. Antigravity는 `.agents/workflows`와 global skills mirror를 사용합니다.
121
123
 
122
124
  ## Antigravity Compatibility
package/README.md CHANGED
@@ -111,7 +111,7 @@ cairn toolcheck
111
111
  - `cairn install`: transactionally installs the custom marketplace source, versioned Codex runtime cache, Cairn-owned config sections, Claude Code mirrors, and current Antigravity IDE/CLI skill surfaces.
112
112
  - `cairn upgrade`: replaces only ownership-manifest entries whose installed digests still match, with staged validation and reverse-order rollback on failure.
113
113
  - `cairn doctor`: validates the ownership manifest, managed digests, effective Codex features, actual installed/enabled/version plugin state, and mirror/runtime locators without repairing them.
114
- - `cairn uninstall`: removes or restores only ownership-manifest entries that remain unmodified; modified or unmanaged targets are preserved as conflicts and failures roll back.
114
+ - `cairn uninstall`: removes or restores only ownership-manifest entries that remain unmodified, then prunes empty managed cache scaffolds; modified or unmanaged targets and their parent directories are preserved, and pre-commit failures roll back.
115
115
  - `cairn toolcheck`: detects repository stacks and checks required LSP and verification tools; installation runs only for an approved supported installer.
116
116
  - `cairn goal ...`: starts, inspects, pauses, resumes, blocks, cancels, and completes a persisted repository goal; the default tool-bound policy records successful evidence by executing `goal verify -- <argv>`. The legacy `goal receipt` command imports declared evidence only.
117
117
  - `cairn-memory`: explores domain knowledge and updates `MEMORY.md`.
@@ -125,6 +125,8 @@ For token-efficient execution, Cairn puts reasoning into a focused executable te
125
125
 
126
126
  Install and upgrade keep the custom marketplace lifecycle; they do not invoke `codex plugin add`. Candidates are staged and validated before commit, every managed destination and digest is recorded in an ownership manifest, and committed phases roll back in reverse order on failure. An exact unmodified supported legacy installation can be adopted after release-integrity validation; modified or unknown artifacts are preserved and rejected. Cairn edits only its own TOML sections and never forces public feature or agent settings. The source plugin manifest stays validator-friendly; only the installed cache copy gets a `hooks` field. Lifecycle commands must run from the published/global package, not from the cached copy that they replace.
127
127
 
128
+ Clean uninstall removes the Cairn marketplace cache root only when every managed scaffold is empty, using empty-directory removal rather than recursive deletion. It does not delete the source repository or repository `MEMORY.md`/`PLAN.md`/`.cairn` state, the global `cairn-ai` package, package-manager download caches, legacy backups, or legacy shared settings outside the current ownership manifest; those require a separate explicit cleanup decision.
129
+
128
130
  Codex uses `skills/` and `commands/`. Claude Code uses mirrored commands and agent definitions under `.claude/`. Antigravity uses `.agents/workflows` and global skills mirrors.
129
131
 
130
132
  ## Antigravity Compatibility
package/README.pt.md CHANGED
@@ -108,6 +108,8 @@ cairn toolcheck
108
108
 
109
109
  O custom marketplace lifecycle separa source e versioned runtime. Staged validation precede cada commit, o ownership manifest vincula todos os managed digests e uma falha aciona reverse-order rollback. Artifacts modified ou unmanaged sao preserved e relatados como conflict. O Cairn altera apenas suas TOML sections e nao forca feature/agent settings publicos.
110
110
 
111
+ Clean uninstall remove o Cairn marketplace cache root somente quando todos os managed scaffold directories estao vazios; qualquer conteudo unmanaged e preservado. O source repository e seu state, o package global `cairn-ai`, package-manager caches e legacy backups/settings fora do ownership manifest atual nao sao removidos automaticamente.
112
+
111
113
  Codex usa `skills/` e `commands/`. Claude Code usa comandos espelhados e agent definitions em `.claude/`. Antigravity usa `.agents/workflows` e global skills mirrors.
112
114
 
113
115
  ## Antigravity Compatibility
package/README.zh.md CHANGED
@@ -108,6 +108,8 @@ cairn toolcheck
108
108
 
109
109
  custom marketplace lifecycle 将 source 与 versioned runtime 分离。每次 commit 前执行 staged validation,ownership manifest 绑定全部 managed digests,失败时执行 reverse-order rollback。modified 或 unmanaged artifacts 会 preserve 并报告 conflict。Cairn 只修改自身 TOML sections,不强制 public feature/agent settings。
110
110
 
111
+ Clean uninstall 仅在所有 managed scaffold directory 均为空时删除 Cairn marketplace cache root,并保留任何 unmanaged content。它不会自动删除 source repository 及其 state、全局 `cairn-ai` package、package-manager cache,或当前 ownership manifest 之外的 legacy backup/settings。
112
+
111
113
  Codex 使用 `skills/` 和 `commands/`。Claude Code 使用 `.claude/` 下的 mirrored commands 和 agent definitions。Antigravity 使用 `.agents/workflows` 和 global skills mirrors。
112
114
 
113
115
  ## Antigravity Compatibility
@@ -10,6 +10,8 @@ Handle read-only discovery and verification 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 perform the assigned discovery or delegate it.
14
+
13
15
  When subagent tools are available, you may recursively delegate bounded read-only 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/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:
@@ -17,4 +17,6 @@ Managed items:
17
17
  - Claude Code `cairn-*` commands and agents mirror files.
18
18
  - Antigravity IDE/CLI `cairn-*` skills and workflows mirror files.
19
19
 
20
- The source work folder is not deleted. The custom marketplace root is removed last, after external mirrors and config have committed successfully.
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.
@@ -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.
@@ -36,6 +37,15 @@ The goal is not to make prompts long by model. The goal is to stabilize the same
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairn-ai",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Token-efficient agent harness with tool readiness for Codex, Claude Code, and Antigravity",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -10,6 +10,9 @@ import { assertNoSymlinkComponents, safeMkdir, safeWriteFile, withStateLock } fr
10
10
  export const GOAL_STATE_VERSION = 2;
11
11
  export const GOAL_STATUSES = new Set(["active", "paused", "blocked", "cancelled", "completed"]);
12
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;
13
16
 
14
17
  const terminalGoalStatuses = new Set(["cancelled", "completed"]);
15
18
  const evidencePolicies = new Set(["declared", "tool-bound"]);
@@ -48,7 +51,7 @@ export async function readGoalState({ root = process.cwd() } = {}) {
48
51
 
49
52
  export async function startGoal({ root = process.cwd(), goal, planId, tasks, completionCriteria = [], requiredEvidence = defaultGoalEvidence, ownerSessionId = null, evidencePolicy = "tool-bound" } = {}) {
50
53
  const normalizedGoal = requiredText(goal, "goal");
51
- const normalizedPlanId = requiredText(planId, "planId");
54
+ const normalizedPlanId = boundedRequiredText(planId, "planId", PLAN_ID_MAX_LENGTH);
52
55
  if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("tasks must contain at least one task");
53
56
 
54
57
  return withStateLock(root, async () => {
@@ -60,6 +63,7 @@ export async function startGoal({ root = process.cwd(), goal, planId, tasks, com
60
63
  const now = timestamp();
61
64
  const goalId = `goal-${randomUUID()}`;
62
65
  const normalizedTasks = tasks.map((task, index) => normalizeTask(task, index, index === 0 ? "active" : "pending"));
66
+ for (const task of normalizedTasks) assertRecoveryReferenceBudget(normalizedPlanId, task.id);
63
67
  const state = {
64
68
  schemaVersion: GOAL_STATE_VERSION,
65
69
  revision: 1,
@@ -206,7 +210,9 @@ async function recordEvidence({
206
210
  || state.goal.planId !== expectedIdentity.planId
207
211
  )) throw new Error("Goal identity changed during verification; evidence was not recorded");
208
212
  if (expectedIdentity && normalizedScope === "task" && (
209
- task?.id !== expectedIdentity.taskId || task?.status !== expectedIdentity.taskStatus
213
+ task?.id !== expectedIdentity.taskId
214
+ || task?.status !== expectedIdentity.taskStatus
215
+ || task?.assignedAgentId !== expectedIdentity.taskAgentId
210
216
  )) throw new Error("Verification task changed during verification; evidence was not recorded");
211
217
  if (expectedFingerprint && workspaceFingerprint(root, watchPaths) !== expectedFingerprint) {
212
218
  throw new Error("Watched workspace changed during verification; evidence was not recorded");
@@ -252,12 +258,15 @@ export async function verifyAndRecord({
252
258
  assertGoalMutable(initialState);
253
259
  const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
254
260
  const initialTask = normalizedScope === "task" ? taskById(initialState, normalizedTaskId) : null;
255
- if (initialTask && initialTask.status !== "active") throw new Error(`Verification task must be active: ${initialTask.id}`);
261
+ if (initialTask && !["active", "completed"].includes(initialTask.status)) {
262
+ throw new Error(`Verification task must be active or completed: ${initialTask.id}`);
263
+ }
256
264
  const identity = {
257
265
  goalId: initialState.goal.id,
258
266
  planId: initialState.goal.planId,
259
267
  taskId: normalizedTaskId,
260
268
  taskStatus: initialTask?.status,
269
+ taskAgentId: initialTask?.assignedAgentId,
261
270
  };
262
271
  const initialFingerprint = workspaceFingerprint(root, normalizedWatchPaths);
263
272
  const result = runner(normalizedArgv[0], normalizedArgv.slice(1), {
@@ -283,8 +292,11 @@ export async function verifyAndRecord({
283
292
  if (!postState || postState.goal.id !== identity.goalId || postState.goal.planId !== identity.planId) {
284
293
  throw new Error("Goal identity changed during verification; evidence was not recorded");
285
294
  }
286
- if (normalizedScope === "task" && taskById(postState, normalizedTaskId).status !== identity.taskStatus) {
287
- throw new Error("Verification task is no longer active; evidence was not recorded");
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
+ }
288
300
  }
289
301
  const command = normalizedArgv.map((value) => JSON.stringify(value)).join(" ");
290
302
  const state = await recordEvidence({
@@ -457,7 +469,7 @@ async function mutateGoal(root, mutate) {
457
469
  function normalizeTask(task, index, defaultStatus) {
458
470
  const source = typeof task === "string" ? { title: task } : task;
459
471
  if (!source || typeof source !== "object") throw new Error(`tasks[${index}] must be a string or object`);
460
- 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);
461
473
  const title = requiredText(source.title, `tasks[${index}].title`);
462
474
  return {
463
475
  id,
@@ -495,7 +507,7 @@ function validateState(value) {
495
507
  const goal = value.goal;
496
508
  requiredText(goal.id, "goal.id");
497
509
  requiredText(goal.title, "goal.title");
498
- requiredText(goal.planId, "goal.planId");
510
+ const planId = boundedRequiredText(goal.planId, "goal.planId", PLAN_ID_MAX_LENGTH);
499
511
  validGoalStatus(goal.status);
500
512
  validEvidencePolicy(goal.evidencePolicy);
501
513
  validTimestamp(goal.createdAt);
@@ -511,7 +523,8 @@ function validateState(value) {
511
523
  let activeTasks = 0;
512
524
  for (const task of value.tasks) {
513
525
  if (!task || typeof task !== "object") throw new Error("Each task must be an object");
514
- requiredText(task.id, "task.id");
526
+ const taskId = boundedRequiredText(task.id, "task.id", TASK_ID_MAX_LENGTH);
527
+ assertRecoveryReferenceBudget(planId, taskId);
515
528
  if (ids.has(task.id)) throw new Error(`Duplicate task id: ${task.id}`);
516
529
  ids.add(task.id);
517
530
  requiredText(task.title, "task.title");
@@ -622,6 +635,19 @@ function requiredText(value, label) {
622
635
  return value.trim();
623
636
  }
624
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
+
625
651
  function validGoalStatus(value) {
626
652
  if (!GOAL_STATUSES.has(value)) throw new Error(`Invalid goal status: ${value}`);
627
653
  return value;
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
- import { realpathSync } from "node:fs";
4
+ import { constants as fsConstants, realpathSync } from "node:fs";
5
5
  import {
6
- copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, writeFile,
6
+ copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, rmdir, writeFile,
7
7
  } from "node:fs/promises";
8
8
  import { homedir, hostname, tmpdir } from "node:os";
9
9
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
@@ -47,7 +47,7 @@ if (isCliEntry()) {
47
47
  const command = process.argv[2] ?? "help";
48
48
  if (command === "install" || command === "upgrade") await withLifecycleLock(() => install(command));
49
49
  else if (command === "doctor") await doctor();
50
- else if (command === "uninstall") await withLifecycleLock(uninstall);
50
+ else if (command === "uninstall") await withLifecycleLock(uninstall, { afterRelease: pruneUninstallRoots });
51
51
  else help();
52
52
  } catch (error) {
53
53
  console.error(`Cairn lifecycle error: ${error.message}`);
@@ -107,7 +107,7 @@ async function install(mode) {
107
107
  console.log(`Codex versioned runtime: ${versionedPluginRoot}`);
108
108
  console.log(`Ownership manifest: ${ownershipPath}`);
109
109
  } catch (error) {
110
- await rollback(transaction);
110
+ if (transaction.state !== "committed") await rollback(transaction);
111
111
  throw error;
112
112
  }
113
113
  }
@@ -119,17 +119,18 @@ async function uninstall() {
119
119
  const conflicts = [];
120
120
  for (const record of ownership.targets) {
121
121
  const current = await targetDigest(record.path, record.type);
122
- if (current !== record.installedDigest) conflicts.push(record.path);
122
+ if (record.type !== "config" && current !== record.installedDigest) conflicts.push(record.path);
123
123
  }
124
124
  if (conflicts.length > 0) throw new Error(`Refusing to uninstall modified managed artifacts: ${conflicts.join(", ")}`);
125
125
  const transaction = await createTransaction();
126
126
  try {
127
127
  const configRecord = ownership.targets.find((record) => record.type === "config");
128
- if (configRecord) {
128
+ if (configRecord && await exists(configRecord.path)) {
129
+ const snapshot = await readSharedConfigSnapshot(configRecord.path);
129
130
  const staged = join(transaction.stageRoot, "uninstall-config.toml");
130
131
  await mkdir(dirname(staged), { recursive: true });
131
- await writeFile(staged, removeCairnConfig(await readText(configRecord.path)));
132
- await replaceTarget({ ...configRecord, staged }, transaction);
132
+ await writeFile(staged, removeCairnConfig(snapshot.text));
133
+ await replaceTarget({ ...configRecord, staged, configSourceDigest: snapshot.digest }, transaction);
133
134
  }
134
135
  injectFailure("uninstall-after-config");
135
136
  for (const phase of ["antigravity", "claude"]) {
@@ -141,11 +142,13 @@ async function uninstall() {
141
142
  await removeManagedTarget({ id: "ownership", phase: "manifest", path: ownershipPath, type: "file" }, transaction);
142
143
  injectFailure("uninstall-after-ownership");
143
144
  await finishTransaction(transaction);
144
- console.log(t("uninstallComplete"));
145
+ injectFailure("uninstall-after-commit");
145
146
  } catch (error) {
146
- await rollback(transaction);
147
+ if (transaction.state !== "committed") await rollback(transaction);
147
148
  throw error;
148
149
  }
150
+ await pruneUninstallScaffolds();
151
+ console.log(t("uninstallComplete"));
149
152
  }
150
153
 
151
154
  async function doctor() {
@@ -164,7 +167,7 @@ async function doctor() {
164
167
  checks.push(["installed CLI script", await exists(join(versionedPluginRoot, "scripts", "cairn.mjs"))]);
165
168
  checks.push(["installed plan template", await exists(join(versionedPluginRoot, "templates", "work-plan.md"))]);
166
169
  checks.push(["installed model guidance", await exists(join(versionedPluginRoot, "docs", "model-guidance", "codex.md"))]);
167
- const config = await readText(configPath);
170
+ const config = (await readSharedConfigSnapshot(configPath)).text;
168
171
  checks.push(["config marketplace", splitSections(config).some((section) => section.header === "marketplaces.cairn")]);
169
172
  checks.push(["config plugin enabled", hasSetting(config, 'plugins."cairn@cairn"', "enabled", "true")]);
170
173
  checks.push(["Claude runtime locator", await validRuntimeLocator(claudeRuntimeLocatorPath)]);
@@ -202,7 +205,7 @@ async function ownershipDigestsValid(ownership) {
202
205
 
203
206
  async function stageInstall(stageRoot) {
204
207
  const targets = [];
205
- const add = (id, phase, destination, staged, type = "tree") => targets.push({ id, phase, path: destination, staged, type });
208
+ const add = (id, phase, destination, staged, type = "tree", details = {}) => targets.push({ id, phase, path: destination, staged, type, ...details });
206
209
  const sourceStage = join(stageRoot, "codex-source");
207
210
  const runtimeStage = join(stageRoot, "codex-runtime");
208
211
  await copyPluginCandidate(sourceStage, versionedPluginRoot);
@@ -237,8 +240,9 @@ async function stageInstall(stageRoot) {
237
240
 
238
241
  const configStage = join(stageRoot, "config.toml");
239
242
  const hookStates = await trustedHookStates(runtimeStage);
240
- await writeFile(configStage, updateConfig(await readText(configPath), hookStates));
241
- add("codex-config", "config", configPath, configStage, "config");
243
+ const configSnapshot = await readSharedConfigSnapshot(configPath);
244
+ await writeFile(configStage, updateConfig(configSnapshot.text, hookStates));
245
+ add("codex-config", "config", configPath, configStage, "config", { configSourceDigest: configSnapshot.digest });
242
246
  for (const target of targets) await assertRegularTree(target.staged);
243
247
  return targets;
244
248
  }
@@ -328,8 +332,10 @@ async function withLifecycleLock(operation, {
328
332
  timeoutMs = Number(process.env.CAIRN_LIFECYCLE_LOCK_TIMEOUT_MS ?? DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS),
329
333
  retryMs = 25,
330
334
  malformedStaleMs = 30_000,
335
+ afterRelease,
331
336
  } = {}) {
332
337
  await mkdir(dirname(lifecycleLockPath), { recursive: true });
338
+ if (process.env.CAIRN_TEST_PRUNE_LOCK_PARENT_BEFORE_ACQUIRE === "1") await rmdir(dirname(lifecycleLockPath));
333
339
  const owner = { schemaVersion: 1, pid: process.pid, hostname: hostname(), nonce: randomUUID(), acquiredAt: new Date().toISOString() };
334
340
  const deadline = Date.now() + timeoutMs;
335
341
  while (true) {
@@ -342,21 +348,76 @@ async function withLifecycleLock(operation, {
342
348
  if (acquired?.nonce !== owner.nonce) throw new Error("Cairn lifecycle lock ownership changed after acquisition");
343
349
  break;
344
350
  } catch (error) {
351
+ if (error?.code === "ENOENT") {
352
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn lifecycle lock after ${timeoutMs}ms`);
353
+ await mkdir(dirname(lifecycleLockPath), { recursive: true });
354
+ continue;
355
+ }
345
356
  if (error?.code !== "EEXIST") throw error;
346
357
  if (await reclaimLifecycleLock(malformedStaleMs)) continue;
347
358
  if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn lifecycle lock after ${timeoutMs}ms`);
348
359
  await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
349
360
  }
350
361
  }
362
+ let operationSucceeded = false;
351
363
  try {
352
- return await operation();
364
+ const result = await operation();
365
+ operationSucceeded = true;
366
+ return result;
353
367
  } finally {
354
- try {
355
- const current = parseLifecycleLock(await readFile(lifecycleLockPath, "utf8"));
356
- if (current?.nonce === owner.nonce) await rm(lifecycleLockPath, { force: true });
357
- } catch (error) {
358
- if (error?.code !== "ENOENT") throw error;
368
+ if (process.env.CAIRN_TEST_REPLACE_LOCK_BEFORE_RELEASE === "1") {
369
+ await writeFile(lifecycleLockPath, `${JSON.stringify({ ...owner, nonce: randomUUID() })}\n`);
359
370
  }
371
+ const released = await releaseLifecycleLock(owner);
372
+ if (operationSucceeded && released && afterRelease) await afterRelease();
373
+ }
374
+ }
375
+
376
+ async function releaseLifecycleLock(owner) {
377
+ let current;
378
+ try {
379
+ current = parseLifecycleLock(await readFile(lifecycleLockPath, "utf8"));
380
+ } catch (error) {
381
+ if (error?.code === "ENOENT") return false;
382
+ throw error;
383
+ }
384
+ if (current?.nonce !== owner.nonce) return false;
385
+ const releasedPath = `${lifecycleLockPath}.released.${owner.nonce}`;
386
+ try {
387
+ await rename(lifecycleLockPath, releasedPath);
388
+ } catch (error) {
389
+ if (error?.code === "ENOENT") return false;
390
+ throw error;
391
+ }
392
+ const released = parseLifecycleLock(await readFile(releasedPath, "utf8"));
393
+ if (released?.nonce !== owner.nonce) {
394
+ if (!(await exists(lifecycleLockPath))) await rename(releasedPath, lifecycleLockPath);
395
+ return false;
396
+ }
397
+ await rm(releasedPath, { force: true });
398
+ return true;
399
+ }
400
+
401
+ async function pruneUninstallScaffolds() {
402
+ for (const path of [
403
+ join(marketplaceRoot, "plugins"),
404
+ join(marketplaceRoot, pluginName),
405
+ join(marketplaceRoot, ".agents", "plugins"),
406
+ join(marketplaceRoot, ".agents"),
407
+ transactionRoot,
408
+ ]) await removeEmptyDirectory(path);
409
+ }
410
+
411
+ async function pruneUninstallRoots() {
412
+ await removeEmptyDirectory(dirname(lifecycleLockPath));
413
+ await removeEmptyDirectory(marketplaceRoot);
414
+ }
415
+
416
+ async function removeEmptyDirectory(path) {
417
+ try {
418
+ await rmdir(path);
419
+ } catch (error) {
420
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
360
421
  }
361
422
  }
362
423
 
@@ -406,6 +467,15 @@ function pidIsAlive(pid) {
406
467
  function delay(ms) { return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); }
407
468
 
408
469
  async function replaceTarget(target, transaction) {
470
+ let configProjection = null;
471
+ if (target.type === "config") {
472
+ const snapshot = await readSharedConfigSnapshot(target.path);
473
+ configProjection = cairnConfigProjection((await readSharedConfigSnapshot(target.staged)).text);
474
+ if (snapshot.digest !== target.configSourceDigest) {
475
+ await writeFile(target.staged, `${removeCairnConfig(snapshot.text)}${configProjection}`);
476
+ target.configSourceDigest = snapshot.digest;
477
+ }
478
+ }
409
479
  const existed = await exists(target.path);
410
480
  const backup = join(transaction.backupRoot, String(transaction.entries.length));
411
481
  await mkdir(dirname(backup), { recursive: true });
@@ -423,7 +493,30 @@ async function replaceTarget(target, transaction) {
423
493
  };
424
494
  transaction.entries.push(entry);
425
495
  await writeTransactionJournal(transaction);
426
- if (existed) await rename(target.path, backup);
496
+ if (target.type === "config" && process.env.CAIRN_TEST_APPEND_CONFIG_BEFORE_REPLACE) {
497
+ const snapshot = await readSharedConfigSnapshot(target.path);
498
+ await writeFile(target.path, `${snapshot.text}${process.env.CAIRN_TEST_APPEND_CONFIG_BEFORE_REPLACE}`);
499
+ }
500
+ if (target.type === "config" && process.env.CAIRN_TEST_REMOVE_CONFIG_BEFORE_REPLACE === "1") {
501
+ await rm(target.path, { force: true });
502
+ }
503
+ if (target.type === "config") {
504
+ let capturedExists = true;
505
+ try {
506
+ await rename(target.path, backup);
507
+ } catch (error) {
508
+ if (error?.code !== "ENOENT") throw error;
509
+ capturedExists = false;
510
+ }
511
+ if (process.env.CAIRN_TEST_CRASH_AFTER_CONFIG_CAPTURE === "1") process.exit(86);
512
+ const captured = await readSharedConfigSnapshot(backup);
513
+ if (capturedExists) await writeFile(target.staged, `${removeCairnConfig(captured.text)}${configProjection}`);
514
+ else await writeFile(target.staged, configProjection);
515
+ entry.existed = capturedExists;
516
+ entry.previousDigest = capturedExists ? await targetDigest(backup, target.type) : "missing";
517
+ entry.expectedNewDigest = await targetDigest(target.staged, target.type);
518
+ await writeTransactionJournal(transaction);
519
+ } else if (existed) await rename(target.path, backup);
427
520
  await mkdir(dirname(target.path), { recursive: true });
428
521
  await rename(target.staged, target.path);
429
522
  entry.status = "applied";
@@ -462,7 +555,12 @@ async function rollback(transaction) {
462
555
 
463
556
  async function finishTransaction(transaction) {
464
557
  transaction.state = "committed";
465
- await writeTransactionJournal(transaction);
558
+ try {
559
+ await writeTransactionJournal(transaction);
560
+ } catch (error) {
561
+ transaction.state = "active";
562
+ throw error;
563
+ }
466
564
  await rm(transaction.root, { recursive: true, force: true });
467
565
  await rm(join(transactionRoot, transaction.id), { recursive: true, force: true });
468
566
  await rm(transactionPath, { force: true });
@@ -568,6 +666,18 @@ async function rollbackEntries(transaction) {
568
666
  const destinationExists = await exists(entry.path);
569
667
  const backupExists = await exists(entry.backup);
570
668
  if (backupExists) {
669
+ if (entry.type === "config" && entry.status === "prepared") {
670
+ const previousConfig = (await readSharedConfigSnapshot(entry.backup)).text;
671
+ if (destinationExists) {
672
+ const currentConfig = (await readSharedConfigSnapshot(entry.path)).text;
673
+ await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
674
+ await rm(entry.backup, { force: true });
675
+ } else {
676
+ await mkdir(dirname(entry.path), { recursive: true });
677
+ await rename(entry.backup, entry.path);
678
+ }
679
+ continue;
680
+ }
571
681
  if (!entry.existed) throw new Error(`Cannot recover transaction because an unexpected backup exists: ${entry.path}`);
572
682
  if (await targetDigest(entry.backup, entry.type) !== entry.previousDigest) {
573
683
  throw new Error(`Cannot recover transaction because its durable backup changed: ${entry.path}`);
@@ -576,8 +686,8 @@ async function rollbackEntries(transaction) {
576
686
  const current = await targetDigest(entry.path, entry.type);
577
687
  if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover transaction because destination changed: ${entry.path}`);
578
688
  if (entry.type === "config") {
579
- const currentConfig = await readText(entry.path);
580
- const previousConfig = await readText(entry.backup);
689
+ const currentConfig = (await readSharedConfigSnapshot(entry.path)).text;
690
+ const previousConfig = (await readSharedConfigSnapshot(entry.backup)).text;
581
691
  await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
582
692
  await rm(entry.backup, { force: true });
583
693
  continue;
@@ -586,12 +696,14 @@ async function rollbackEntries(transaction) {
586
696
  }
587
697
  await mkdir(dirname(entry.path), { recursive: true });
588
698
  await rename(entry.backup, entry.path);
699
+ } else if (entry.type === "config" && entry.status === "prepared" && !destinationExists) {
700
+ continue;
589
701
  } else if (!entry.existed) {
590
702
  if (destinationExists) {
591
703
  const current = await targetDigest(entry.path, entry.type);
592
704
  if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover new transaction target because it changed: ${entry.path}`);
593
705
  if (entry.type === "config") {
594
- const preserved = removeCairnConfig(await readText(entry.path));
706
+ const preserved = removeCairnConfig((await readSharedConfigSnapshot(entry.path)).text);
595
707
  if (preserved.length > 0) await writeFile(entry.path, preserved);
596
708
  else await rm(entry.path, { force: true });
597
709
  } else await rm(entry.path, { recursive: true, force: true });
@@ -624,7 +736,7 @@ async function assertTargetsReplaceable(targets, ownership) {
624
736
  }
625
737
  const record = records.get(target.id);
626
738
  if (!record) {
627
- if (target.type === "config" && cairnConfigProjection(await readText(target.path)).length === 0) continue;
739
+ if (target.type === "config" && cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length === 0) continue;
628
740
  throw new Error(`Refusing to overwrite unmanaged artifact: ${target.path}`);
629
741
  }
630
742
  if (target.id === "codex-runtime" && ownership?.version !== releaseVersion) {
@@ -632,13 +744,14 @@ async function assertTargetsReplaceable(targets, ownership) {
632
744
  }
633
745
  if (record.path !== target.path || record.type !== target.type) throw new Error(`Ownership manifest target mismatch: ${target.id}`);
634
746
  const current = await targetDigest(target.path, target.type);
635
- if (current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
747
+ if (target.type !== "config" && current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
636
748
  }
637
749
  }
638
750
 
639
751
  async function assertOwnershipDigests(ownership) {
640
752
  for (const record of ownership.targets) {
641
- if (await targetDigest(record.path, record.type) !== record.installedDigest) {
753
+ const current = await targetDigest(record.path, record.type);
754
+ if (record.type !== "config" && current !== record.installedDigest) {
642
755
  throw new Error(`Managed artifact was modified: ${record.path}`);
643
756
  }
644
757
  }
@@ -742,7 +855,7 @@ function versionedRuntimeRoot(version) {
742
855
  async function anyManagedArtifactExists(targets) {
743
856
  for (const target of targets) {
744
857
  if (!(await exists(target.path))) continue;
745
- if (target.type !== "config" || cairnConfigProjection(await readText(target.path)).length > 0) return true;
858
+ if (target.type !== "config" || cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length > 0) return true;
746
859
  }
747
860
  return false;
748
861
  }
@@ -762,7 +875,7 @@ async function adoptLegacy(targets, transaction) {
762
875
  }
763
876
  }
764
877
  if (target.type === "config") {
765
- const projection = cairnConfigProjection(await readText(target.path));
878
+ const projection = cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text);
766
879
  if (!projection.includes("[marketplaces.cairn]") || !projection.includes('[plugins."cairn@cairn"]')) {
767
880
  throw new Error(`Legacy Cairn config cannot be identified: ${target.path}`);
768
881
  }
@@ -859,7 +972,7 @@ async function verifyLegacyMirror(target) {
859
972
 
860
973
  export async function targetDigest(path, type) {
861
974
  if (!(await exists(path))) return "missing";
862
- if (type === "config") return sha(cairnConfigProjection(await readText(path)));
975
+ if (type === "config") return sha(cairnConfigProjection((await readSharedConfigSnapshot(path)).text));
863
976
  const info = await lstat(path);
864
977
  if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) throw new Error(`Managed artifact is not a regular file/tree: ${path}`);
865
978
  if (info.isFile()) return sha(await readFile(path));
@@ -905,19 +1018,60 @@ export function updateConfig(config, hookStates, { marketplacePath = marketplace
905
1018
  }
906
1019
 
907
1020
  export function removeCairnConfig(config) {
908
- return splitSections(config).filter((section) => {
909
- if (section.header === null) return true;
910
- if (section.header === "marketplaces.cairn") return false;
911
- if (section.header === 'plugins."cairn@cairn"') return false;
912
- return !(section.header.startsWith("hooks.state.") && section.header.includes("cairn@cairn:"));
913
- }).map((section) => section.text).join("");
1021
+ return splitSections(config).map((section) => isCairnConfigSection(section)
1022
+ ? splitTrailingTomlTrivia(section.text).trivia
1023
+ : section.text).join("");
914
1024
  }
915
1025
 
916
1026
  function cairnConfigProjection(config) {
917
- return splitSections(config).filter((section) => section.header === "marketplaces.cairn"
1027
+ return splitSections(config).filter(isCairnConfigSection)
1028
+ .map((section) => splitTrailingTomlTrivia(section.text).body).join("");
1029
+ }
1030
+
1031
+ function isCairnConfigSection(section) {
1032
+ return section.header === "marketplaces.cairn"
918
1033
  || section.header === 'plugins."cairn@cairn"'
919
- || (section.header?.startsWith("hooks.state.") && section.header.includes("cairn@cairn:")))
920
- .map((section) => section.text).join("");
1034
+ || (section.header?.startsWith("hooks.state.") && section.header.includes("cairn@cairn:"));
1035
+ }
1036
+
1037
+ function splitTrailingTomlTrivia(text) {
1038
+ const lines = text.split(/(?<=\n)/);
1039
+ let boundary = lines.length;
1040
+ while (boundary > 0) {
1041
+ const line = lines[boundary - 1];
1042
+ if (line.length === 0 || line.trim().length === 0 || line.trimStart().startsWith("#")) boundary -= 1;
1043
+ else break;
1044
+ }
1045
+ return { body: lines.slice(0, boundary).join(""), trivia: lines.slice(boundary).join("") };
1046
+ }
1047
+
1048
+ async function readSharedConfigSnapshot(path) {
1049
+ let pathInfo;
1050
+ try {
1051
+ pathInfo = await lstat(path);
1052
+ } catch (error) {
1053
+ if (error?.code === "ENOENT") return { exists: false, text: "", digest: "missing" };
1054
+ throw error;
1055
+ }
1056
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`Managed config is not a regular file: ${path}`);
1057
+ let handle;
1058
+ try {
1059
+ const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
1060
+ handle = await open(path, flags);
1061
+ } catch (error) {
1062
+ if (["ELOOP", "EFTYPE", "ENXIO"].includes(error?.code)) throw new Error(`Managed config is not a regular file: ${path}`);
1063
+ throw error;
1064
+ }
1065
+ try {
1066
+ const openedInfo = await handle.stat();
1067
+ if (!openedInfo.isFile() || openedInfo.dev !== pathInfo.dev || openedInfo.ino !== pathInfo.ino) {
1068
+ throw new Error(`Managed config changed identity while opening: ${path}`);
1069
+ }
1070
+ const text = await handle.readFile("utf8");
1071
+ return { exists: true, text, digest: sha(text) };
1072
+ } finally {
1073
+ await handle.close();
1074
+ }
921
1075
  }
922
1076
 
923
1077
  export function ensureSetting(config, sectionName, key, value) {
@@ -117,7 +117,7 @@ const planTemplateKo = `# PLAN
117
117
 
118
118
  const contextLimits = {
119
119
  goalTitle: 64,
120
- taskId: 32,
120
+ roadmapTaskId: 24,
121
121
  taskTitle: 40,
122
122
  roadmapWindow: 3,
123
123
  };
@@ -158,7 +158,9 @@ export async function runStateResult(event = "manual", {
158
158
 
159
159
  if (event === "session-start" || event === "user-prompt-submit") {
160
160
  const state = await readGoalState({ root: resolvedRoot });
161
- if (!isGoalOwnedBySession(state, payload?.session_id)) return { status: 0, message: "", hookOutput: {} };
161
+ if (state?.goal?.status === "active" && !isGoalOwnedBySession(state, payload?.session_id)) {
162
+ return foreignSessionContextResult({ ko, event });
163
+ }
162
164
  return contextResult({ ko, state, event });
163
165
  }
164
166
 
@@ -222,57 +224,76 @@ async function writeIfMissing(root, path, content) {
222
224
  }
223
225
 
224
226
  function contextResult({ ko, state, event }) {
225
- const base = ko ? "Cairn: 루트 MEMORY.md를 읽으세요." : "Cairn: read root MEMORY.md.";
227
+ const base = ko ? "Cairn kernel: 루트 MEMORY.md를 먼저 읽으세요." : "Cairn kernel: read root MEMORY.md first.";
226
228
  const idlePolicy = event === "user-prompt-submit"
227
229
  ? (ko
228
- ? " 구현/계속 실행: 초기 트리아지 계획 작성 → update_plan → create_goal → 탐색 전 저장소 goal 시작 → 트리아지 후 계획 확정 → 구현. 상담·설명·계획 전용은 goal 없이 처리하세요."
229
- : " Implementation/continue: write an initial triage plan; call update_plan, then create_goal; start the repository goal before exploration; finalize the plan after triage, then implement. Skip goals for consultation, explanation, or plan-only requests.")
230
- : "";
231
- if (!state || state.goal.status !== "active") return contextHookResult({ event, message: `${base}${idlePolicy}` });
230
+ ? " 구현/계속 실행이면 cairn-plan을 로드하고 active plan과 current task를 복원하거나 생성하세요. 상담·설명·계획 전용은 goal 없이 처리하세요."
231
+ : " For implementation/continue, load cairn-plan and restore or create the active plan and current task. Keep consultation, explanation, and plan-only requests goal-free.")
232
+ : (ko
233
+ ? " 중요 작업 전에 cairn-plan을 로드하고 active plan과 current task를 복원하세요."
234
+ : " Before non-trivial work, load cairn-plan and restore the active plan and current task.");
235
+ const failClosed = ko
236
+ ? " state, skill, plan 또는 required reference가 없거나 읽을 수 없거나 일치하지 않으면 수정·위임·완료하지 말고 보고하거나 차단하세요."
237
+ : " If state, skill, plan, or a required ref is missing, unreadable, or inconsistent, do not edit, delegate, or complete; report/block.";
238
+ if (!state || state.goal.status !== "active") return contextHookResult({ event, message: `${base}${idlePolicy}${failClosed}` });
232
239
  const task = state.tasks.find((item) => item.status === "active")
233
240
  ?? state.tasks.find((item) => item.status === "pending")
234
241
  ?? state.tasks.find((item) => item.status === "blocked");
235
- const goalTitle = clipText(state.goal.title, contextLimits.goalTitle);
242
+ const planId = state.goal.planId;
243
+ const phase = task ? "cairn-work" : "cairn-review";
236
244
  const activeHeader = ko
237
- ? `Cairn active: "${goalTitle}". ${base}`
238
- : `Cairn active: "${goalTitle}". ${base}`;
245
+ ? `${base} ${phase}를 로드하고 정확한 plan ${planId}${task ? "과 아래 current task" : "의 완료 기준"}를 복원하세요. 압축·재시작·handoff·위임 뒤 다시 읽으세요.${failClosed}`
246
+ : `${base} Load ${phase}; restore exact plan ${planId}${task ? " and the current task below" : " completion criteria"}. Re-read after compaction/restart/handoff/delegation.${failClosed}`;
239
247
  if (!task) {
240
248
  return contextHookResult({
241
249
  event,
242
250
  message: [activeHeader, taskRoadmap(ko, state), activeGoalCompletionMessage(ko, state)].join("\n"),
243
251
  });
244
252
  }
245
- const taskId = clipText(task.id, contextLimits.taskId);
253
+ const taskId = task.id;
246
254
  const taskTitle = clipText(task.title, contextLimits.taskTitle);
247
- const continuation = ko
248
- ? `현재: ${taskId} (${taskTitle}). 바인딩된 증거 뒤에만 완료하세요.`
249
- : `Current: ${taskId} (${taskTitle}). Complete only after bound evidence.`;
250
- const resume = event === "user-prompt-submit"
255
+ const continuation = taskId.length > 48
251
256
  ? (ko
252
- ? `곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 ${taskId}를 재개하세요.`
253
- : `After a side question, resume ${taskId} unless asked to pause, stop, or switch.`)
257
+ ? `정확한 current task: ${taskId}. 바인딩된 증거 뒤에만 완료하세요.`
258
+ : `Exact task: ${taskId}. Complete after bound evidence.`)
259
+ : (ko
260
+ ? `현재: ${taskId} (${taskTitle}). 바인딩된 증거 뒤에만 완료하세요.`
261
+ : `Current: ${taskId} (${taskTitle}). Complete only after bound evidence.`);
262
+ const resume = event === "user-prompt-submit"
263
+ ? (taskId.length > 48
264
+ ? (ko
265
+ ? "곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 위의 정확한 task를 재개하세요."
266
+ : "Resume that exact task after side questions unless paused, stopped, or switched.")
267
+ : (ko
268
+ ? `곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 ${taskId}를 재개하세요.`
269
+ : `After a side question, resume ${taskId} unless asked to pause, stop, or switch.`))
254
270
  : "";
255
271
  return contextHookResult({ event, message: [activeHeader, taskRoadmap(ko, state), continuation, resume].filter(Boolean).join("\n") });
256
272
  }
257
273
 
274
+ function foreignSessionContextResult({ ko, event }) {
275
+ const message = ko
276
+ ? "Cairn kernel: 루트 MEMORY.md를 먼저 읽으세요. 저장소 goal을 다른 session이 소유합니다. cairn goal status로 상태를 확인하고 소유권이 해결될 때까지 Cairn 작업을 시작·수정·위임·완료하지 마세요. 이 capsule은 goal, plan, task 상세를 노출하지 않습니다."
277
+ : "Cairn kernel: read root MEMORY.md first. A repository goal is owned by another session. Inspect cairn goal status; until ownership is resolved, do not start, edit, delegate, or complete Cairn work. This capsule does not expose goal, plan, or task details.";
278
+ return contextHookResult({ event, message });
279
+ }
280
+
258
281
  function taskRoadmap(ko, state) {
259
- const formatTask = (task, index) => `${index + 1}. ${clipText(task.id, contextLimits.taskId)} [${task.status}] ${clipText(task.title, contextLimits.taskTitle)}`;
282
+ const formatTask = (task, index) => `${index + 1}. ${clipText(task.id, contextLimits.roadmapTaskId)} [${task.status}] ${clipText(task.title, contextLimits.taskTitle)}`;
260
283
  if (state.tasks.length <= contextLimits.roadmapWindow) {
261
284
  return `${ko ? "작업 단계" : "Work steps"}:\n${state.tasks.map(formatTask).join("\n")}`;
262
285
  }
263
286
 
264
287
  const currentIndex = Math.max(0, state.tasks.findIndex((task) => task.status === "active" || task.status === "pending"));
265
- const maxStart = state.tasks.length - contextLimits.roadmapWindow;
266
- const start = Math.min(Math.max(0, currentIndex - 1), maxStart);
267
- const visible = state.tasks.slice(start, start + contextLimits.roadmapWindow);
268
288
  const counts = Object.fromEntries([...TASK_STATUS_NAMES].map((status) => [status, state.tasks.filter((task) => task.status === status).length]));
269
289
  const countSummary = Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => `${status} ${count}`).join(", ");
270
- const omitted = state.tasks.length - visible.length;
290
+ const omitted = state.tasks.length - 1;
271
291
  const header = ko
272
292
  ? `작업 단계 (${state.tasks.length}개; ${countSummary})`
273
293
  : `Work steps (${state.tasks.length}; ${countSummary})`;
274
294
  const omission = ko ? `… ${omitted}개 단계 생략` : `… ${omitted} steps omitted`;
275
- return `${header}:\n${visible.map((task, index) => formatTask(task, start + index)).join("\n")}\n${omission}`;
295
+ const currentPosition = currentIndex + 1;
296
+ return `${header}. ${ko ? `현재 위치 ${currentPosition}` : `Current position ${currentPosition}`}; ${omission}`;
276
297
  }
277
298
 
278
299
  function contextHookResult({ event, message }) {
@@ -17,6 +17,10 @@ Do not treat planning as optional for implementation work that is more than a na
17
17
 
18
18
  Plan from the whole work down. First understand the full requested outcome and affected surfaces, then classify the work into small executable tasks. If one task is still too broad to verify cleanly, split that task into sub-tasks.
19
19
 
20
+ ## Re-entry and Required References
21
+
22
+ At the start and after compaction, restart, or handoff, restore context in this order: root `MEMORY.md` → `cairn-plan` → the active plan → current-task references → model guidance recorded by the plan. A new implementation request with no persisted goal may create its initial plan through the procedure below. If persisted state names a plan or task and that reference is missing, unreadable, or inconsistent, do not explore, edit, delegate, or complete work; report or record a blocker first. Do not add a read-receipt field that claims the model read a file: filesystem/state consistency and bound tool evidence remain the fail-closed authority.
23
+
20
24
  ## Runtime Location
21
25
 
22
26
  Resolve Cairn's read-only runtime from `references/cairn-runtime.json` next to this `SKILL.md`. Its `pluginRoot` contains Cairn's `scripts/`, `templates/`, commands, agents, and model guidance. In a source checkout where the locator does not exist, resolve the plugin root from this `SKILL.md` location (`../..`). Never search the target repository for Cairn scripts, templates, or model guidance, and never derive the plugin root from the current working directory.
@@ -13,6 +13,8 @@ Review is not another implementation loop. Confirm that the completed task satis
13
13
 
14
14
  Every review run and every delegated review agent must read the project-root `MEMORY.md` before doing assigned work.
15
15
 
16
+ At the start and after compaction, restart, delegation, or handoff, restore context in this order: root `MEMORY.md` → `cairn-review` → the active plan → completed/current-task references → model guidance recorded by the plan. Confirm those references agree with persisted goal state before reviewing. If any required reference is missing, unreadable, or inconsistent, do not approve, delegate, record final review, or complete the goal; report a blocker. Do not use a read-receipt field as evidence that a model understood a reference.
17
+
16
18
  When subagent tools are available, each agent may recursively delegate bounded review sub-tasks to subagents. Every child subagent must read the project-root `MEMORY.md`, keep the assigned scope, and preserve others' edits.
17
19
 
18
20
  Resolve any `cairn://...` resource through `references/cairn-runtime.json` next to this `SKILL.md` (or this skill's `../..` source root during development). Never look for Cairn runtime resources in the target repository.
@@ -21,6 +21,10 @@ Delegated subagents must provide a final report before leaving. After the orches
21
21
 
22
22
  If the user asks a side question, status question, or narrow clarification while the selected task is still active, use the active goal's ordered task roadmap to preserve position, answer briefly, and then resume the current task unless the user explicitly asks to pause, stop, or switch tasks.
23
23
 
24
+ ## Re-entry and Required References
25
+
26
+ At the start and after compaction, restart, delegation, or handoff, restore context in this order: root `MEMORY.md` → `cairn-work` → the active plan → current-task references → model guidance recorded by the plan. Confirm that the plan task, persisted current task, and worker assignment agree before acting. If state, skill, plan, assignment, or a required reference is missing, unreadable, or inconsistent, do not edit, delegate, record completion evidence, or complete work; report a blocker to the orchestrator. Do not introduce read receipts as proof of model attention: current state, readable references, and fresh tool-bound evidence are the authority.
27
+
24
28
  When subagent tools are available, each agent 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.
25
29
 
26
30
  ## Runtime Location