feat(daemon): add policies.claude_compaction.preparation_root for restore maps (#1585)

Managed Claude compaction wrote each restore map under the seat's launch folder whenever the daemon's own write check passed. A seat whose Claude permissions deny edits there couldn't write its map, so preparation ended `preparation_incomplete`, and the only way out was `--skip-map` or many generated permission rules.

Now an operator can set an absolute, instance-wide `policies.claude_compaction.preparation_root` (or its environment variable). Manual and automatic compaction then use `<root>/<session>/<attempt>/RESTORE-MAP.md`, chosen once per attempt and kept through preparation, publication and restore, even if the setting changes mid-attempt. The operator grants Claude edit access to that root; OpenRig doesn't change Claude's permissions. With the setting unset, paths are exactly as before, including the instance-home fallback.

A configured root that can't be created or written, or that already holds a different `.gitignore`, stops that attempt with `preparation_directory_unavailable`, shown in `rig compact --state`, instead of silently choosing another folder. `--skip-map` still works.

Limits: use a dedicated root. OpenRig writes a `.gitignore` of `*` into the root itself, so a root inside a repository folder without its own `.gitignore` hides new files there. No live Claude compaction or permission session was run; the tests use real temporary files with synthetic delivery.

Thanks to @Aschenbath for the PR, and to @aanestesia for the issue and for measuring what the workaround cost.

Cost: source +59/-21 (4 files), docs +43/-1 (2 files), tests +272/-8 (7 files). One new failure reason, `preparation_directory_unavailable`, reached only with the new setting.
This commit is contained in:
Nash Aschenbath authored and GitHub committed 2026-10-22 20:57:15 -07:00
1 parent f9f843bf62
commit 65e49b5e1e
13 files changed
+374 -30

No files matched your search

@@ -284,7 +284,9 @@ with. Claude Code before 2.1.290 ignores the key, Claude's managed settings take
2. The agent writes `RESTORE-MAP.md.tmp` with its file-edit tool, completion marker last.
Later ticks return `preparation_pending` until that exact map is complete. The map path is under the seat's working directory
(`.openrig/compaction/preparation/<session>/<attempt>/`, with a `.gitignore` of `*`) when
that is writable, otherwise under `compaction/preparation/` in the OpenRig home.
that is writable, otherwise under `compaction/preparation/` in the OpenRig home. An explicit
`policies.claude_compaction.preparation_root` instead places maps at `<root>/<session>/<attempt>/`
for both manual and automatic attempts; failure to prepare that root stops the attempt without fallback.
3. The daemon atomically renames the completed temp file to `RESTORE-MAP.md` in its existing
final send checks, after rechecking the attempt and occupant. An already-complete final map
remains compatible. Only then is `/compact` sent, with the final map path appended to the
+40
View File
@@ -523,6 +523,46 @@ remain shared.
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
### Claude compaction preparation directory
Managed Claude compaction asks the seat to write a restore map before `/compact`. By default,
OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when
that directory cannot be prepared. A filesystem write check cannot establish whether Claude's
file-edit permissions allow the seat to write there.
If the launch workspace is denied by Claude's permissions, select an absolute preparation root:
```sh
rig config set policies.claude_compaction.preparation_root /absolute/path/to/restore-maps
```
Grant the seat file-edit access to that root in its Claude configuration. OpenRig does not change
Claude permissions; choose a directory outside any matching deny rule. The corresponding environment
variable is `OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT`.
New manual and automatic attempts use `<root>/<session>/<attempt-id>/RESTORE-MAP.md`; an in-flight
attempt keeps its original path through the post-compaction restore request. The root gets an
exclusive `.gitignore` containing `*`, and the daemon publishes the marked `.tmp` file with an
atomic rename. Use a dedicated root: a conflicting
ignore file or an unwritable configured directory stops preparation with
`preparation_directory_unavailable`, visible in `rig compact <session> --state`, without choosing
another location. Fix the directory and explicitly retry. `--skip-map` still skips the map once.
Reset with `rig config reset policies.claude_compaction.preparation_root` to restore the default.
The setting applies to new attempts without restarting the daemon.
The managed `/compact` command keeps its 600-character budget. When the selected map path or
the attempt's compaction instruction does not fit, the existing restore request carries it;
it never substitutes a newer preparation root or instruction. An explicitly selected root's
published map is also named in that request when the path fit in `/compact`. The default root
keeps the existing command/restore handoff. Under `--skip-map`, restore still recognizes an
optional map completed at that attempt's path, or says that no map was required.
The compaction preparation and command-submission tests cover manual and automatic attempts,
long paths and instructions, settings changed during preparation, directory failures and
late maps after cancellation or expiry. They use synthetic delivery and filesystem fixtures;
they do not prove a live Claude session's permission choices or compaction behavior.
### Codex compaction and sizing
OpenRig's managed compaction is for Claude Code seats only, by design. That covers `rig compact`, the
+13 -2
View File
@@ -1,6 +1,6 @@
import { readFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
import { writeTextAtomically } from "./atomic-text-write.js";
import { join, dirname, resolve } from "node:path";
import { join, dirname, resolve, isAbsolute } from "node:path";
import { homedir } from "node:os";
import {
getDefaultOpenRigPath,
@@ -136,6 +136,7 @@ export interface RiggedConfig {
// extra instruction file path for mission-specific reading lists.
policies: {
claudeCompaction: {
preparationRoot: string;
enabled: boolean;
thresholdPercent: number;
preCompactInstruction: string;
@@ -317,6 +318,7 @@ const DEFAULTS = {
// Slice 27 — opt-in default-off. Threshold default 80% per spec.
policies: {
claudeCompaction: {
preparationRoot: "",
enabled: false,
thresholdPercent: 80,
preCompactInstruction: DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION,
@@ -459,7 +461,8 @@ export const VALID_KEYS = [
"runtime.codex.hooks_enabled",
"runtime.readiness_timeout_seconds",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
// 7 ConfigStore keys (lockstep with daemon SETTINGS_VALID_KEYS).
// 8 ConfigStore keys (lockstep with daemon SETTINGS_VALID_KEYS).
"policies.claude_compaction.preparation_root",
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.pre_compact_instruction",
@@ -575,6 +578,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"runtime.readiness_timeout_seconds": { primary: "OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS" },
// Slice 27 — Claude auto-compaction policy. OPENRIG_X primary only
// (net-new keys, no legacy).
"policies.claude_compaction.preparation_root": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT" },
"policies.claude_compaction.enabled": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED" },
"policies.claude_compaction.threshold_percent": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT" },
"policies.claude_compaction.pre_compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION" },
@@ -666,6 +670,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
"runtime.readiness_timeout_seconds": ["runtime", "readinessTimeoutSeconds"],
"policies.claude_compaction.preparation_root": ["policies", "claudeCompaction", "preparationRoot"],
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
"policies.claude_compaction.pre_compact_instruction": ["policies", "claudeCompaction", "preCompactInstruction"],
@@ -963,6 +968,11 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
// e.g. 50 = compact earlier; range is 1-100 inclusive. A value of 0
// would fire /compact on every poll tick (catastrophic without
// default-off as backstop).
"policies.claude_compaction.preparation_root": (_raw, value) => {
if (typeof value !== "string" || (value !== "" && (!isAbsolute(value) || value.includes("\0")))) {
throw new Error("Invalid policies.claude_compaction.preparation_root: use an absolute path or an empty string for the default");
}
},
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed)) {
@@ -1224,6 +1234,7 @@ export class ConfigStore {
},
policies: {
claudeCompaction: {
preparationRoot: v("policies.claude_compaction.preparation_root") as string,
enabled: v("policies.claude_compaction.enabled") as boolean,
thresholdPercent: v("policies.claude_compaction.threshold_percent") as number,
preCompactInstruction: v("policies.claude_compaction.pre_compact_instruction") as string,
@@ -65,6 +65,7 @@ function clearEnv(): () => void {
"OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG",
"OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED",
"OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION",
@@ -154,6 +155,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"runtime.codex.hooks_enabled",
"runtime.readiness_timeout_seconds",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
"policies.claude_compaction.preparation_root",
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.pre_compact_instruction",
@@ -1240,3 +1242,39 @@ describe("ConfigStore — GHOST-STAGE (d): write-canonical + verify-readback", (
expect((JSON.parse(readFileSync(join(home, "config.json"), "utf-8")) as { daemon?: { port?: number } }).daemon?.port).toBe(9191);
});
});
describe("compaction preparation root", () => {
let tmpDir: string, configPath: string;
let restoreEnv: () => void;
beforeEach(() => {
restoreEnv = clearEnv();
tmpDir = mkdtempSync(join(tmpdir(), "preparation-root-settings-"));
configPath = join(tmpDir, "config.json");
});
afterEach(() => { restoreEnv(); rmSync(tmpDir, { recursive: true, force: true }); });
const key = "policies.claude_compaction.preparation_root";
it("resolves file/env overrides consistently and resets to workspace-first preparation", () => {
const store = new ConfigStore(configPath);
const root = join(tmpDir, "maps with spaces");
expect(store.resolve().policies.claudeCompaction.preparationRoot).toBe("");
store.set(key, root);
expect(store.resolveWithSource(key)).toMatchObject({ value: root, source: "file" });
expect(new ConfigStore(configPath).resolve().policies.claudeCompaction.preparationRoot).toBe(root);
process.env.OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT = join(root, "override");
try {
expect(store.resolveWithSource(key)).toMatchObject({ value: join(root, "override"), source: "env" });
} finally { delete process.env.OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT; }
store.reset(key);
expect(store.resolve().policies.claudeCompaction.preparationRoot).toBe("");
store.set(key, "");
expect(store.resolve().policies.claudeCompaction.preparationRoot).toBe("");
});
it.each(["relative/maps", "~/maps", " /maps", "/maps\0invalid"])("rejects invalid root %j without replacing valid settings", (value) => {
const store = new ConfigStore(configPath);
const root = join(tmpDir, "maps");
store.set(key, root);
expect(() => store.set(key, value)).toThrow(/absolute path/);
expect(store.resolve().policies.claudeCompaction.preparationRoot).toBe(root);
});
});
@@ -129,6 +129,7 @@ export type EnforcerSkipReason =
| "preparation_pending"
| "preparation_incomplete"
| "preparation_stopped"
| "preparation_directory_unavailable"
| "occupant_generation_unavailable"
| "no_pending_stage"
| "waiting_for_idle"
@@ -363,7 +364,8 @@ export function buildPostCompactRestorePrompt(input: {
// refused at the source. Tell the seat NOT to seek it out (it is not its state).
pieces.push("A post-compaction instruction file declaring a DIFFERENT seat was present and has been IGNORED — it is not yours; do NOT read or follow it. Rely on the per-seat marker and the JSONL transcript for restore.");
}
pieces.push("Load/read the claude-compaction-restore skill, follow the marker's restoreInstruction and postCompactInstruction when present, read your newest restore map and use the restore packet as a lookup, then reply with: restored from packet at <path>; resumed at step <X>.");
const restoreMap = input.attemptMapPath ? "this preparation attempt's restore map" : "your newest restore map";
pieces.push(`Load/read the claude-compaction-restore skill, follow the marker's restoreInstruction and postCompactInstruction when present, read ${restoreMap} and use the restore packet as a lookup, then reply with: restored from packet at <path>; resumed at step <X>.`);
pieces.push(`During this restore, read ${path.join(input.openrigHome, "plugins", "openrig-core", "skills", "refocusing", "SKILL.md")} and consume the current topology and work traces delivered with this restore request, if present; do not rerun Python just to duplicate a delivered trace. Missing trace delivery is a named gap. Read required notes and full sources with the native file-read tool; a pointer, compact summary or truncated extract is not a full source read. Name any missing source, the current user outcome, and your next action from the files you actually read, not from the compact summary.`);
return pieces.join(" ");
}
@@ -421,6 +423,8 @@ export interface PreparationState {
interface PreparationAttempt extends PreparationState {
sends: number;
policyWasEnabled: boolean;
/** The explicit preparation root selected when this attempt began; null keeps the default flow. */
preparationRoot: string | null;
controller: AbortController;
/** The policy's compaction instruction when the attempt began; the /compact command uses this one. */
compactInstruction: string;
@@ -795,7 +799,7 @@ export class ClaudeCompactionEnforcer {
if (attempt?.status === "stopped") return { triggered: false, reason: "preparation_stopped" };
if (!attempt) {
attempt = this.beginPreparation(input, "automatic");
if (attempt.status === "stopped") return { triggered: false, reason: "occupant_generation_unavailable" };
if (attempt.status === "stopped") return { triggered: false, reason: attempt.reason === "preparation_directory_unavailable" ? attempt.reason : "occupant_generation_unavailable" };
}
if (attempt.status === "sending") {
await this.deliverPreparation(input, attempt);
@@ -850,7 +854,7 @@ export class ClaudeCompactionEnforcer {
// The preparation prompt cannot be written to such a seat either, so the whole attempt is refused up front.
if (this.paneUnknownProtected(input.sessionName)) return this.recordManualFailure(input.sessionName, "pane_unknown_protected");
const attempt = this.beginPreparation(input, "manual", opts.skipMap === true);
if (attempt.status === "stopped") return this.recordManualFailure(input.sessionName, "occupant_generation_unavailable");
if (attempt.status === "stopped") return this.recordManualFailure(input.sessionName, attempt.reason ?? "occupant_generation_unavailable");
const failed = (reason: string): ManualCompactionOutcome => {
// A cancelled request may settle after an explicit retry. Its receipt must
// not stop or overwrite the successor attempt's state.
@@ -896,26 +900,31 @@ export class ClaudeCompactionEnforcer {
private beginPreparation(input: EnforcerInput, mode: "automatic" | "manual", skipMap = false): PreparationAttempt {
const attemptId = randomUUID();
const startedAt = this.now();
const policy = this.settingsStore.resolveClaudeCompactionPolicy();
const preparationRoot = policy.preparationRoot || null;
const fallbackMapPath = path.join(this.openrigHome, "compaction", "preparation", sanitizeSessionKey(input.sessionName), attemptId, "RESTORE-MAP.md");
const occupantGeneration = this.resolveOccupantGeneration?.(input.sessionName)
?? this.sessionTransport.deliveryGuard?.maybeTarget(input.sessionName)?.occupant ?? null;
const attempt: PreparationAttempt = {
attemptId, occupantGeneration, mode, status: "sending", delivery: "pending", compact: "not_sent", sends: 0,
deadlineAt: mode === "manual" ? this.now() + this.manualPrepWaitMs : null,
mapPath: fallbackMapPath,
deadlineAt: mode === "manual" ? startedAt + this.manualPrepWaitMs : null,
mapPath: preparationRoot ? path.join(preparationRoot, sanitizeSessionKey(input.sessionName), attemptId, "RESTORE-MAP.md") : fallbackMapPath,
marker: `<!-- openrig-compaction-complete ${JSON.stringify({ attemptId, session: input.sessionName, occupantGeneration })} -->`,
policyWasEnabled: this.settingsStore.resolveClaudeCompactionPolicy().enabled,
policyWasEnabled: policy.enabled,
preparationRoot,
controller: new AbortController(),
compactInstruction: this.settingsStore.resolveClaudeCompactionPolicy().compactInstruction ?? "",
compactInstruction: policy.compactInstruction ?? "",
};
this.pendingPreCompactPrep.set(input.sessionName, attempt);
// A map cannot satisfy an unknown occupant. Keep the failed attempt visible and
// disarmed; explicit skip-map still bypasses only the artifact prerequisite.
if (occupantGeneration === null && !skipMap) this.stopPreparation(input.sessionName, "occupant_generation_unavailable");
if (attempt.status !== "stopped" && input.cwd && path.isAbsolute(input.cwd)) {
// Keep writes inside Claude's existing edit workspace, without adding permissions.
// This folder holds private working context; ignore its entire contents in Git.
const root = path.join(input.cwd, ".openrig", "compaction");
if (skipMap && preparationRoot) return attempt;
if (attempt.status !== "stopped" && (preparationRoot || (input.cwd && path.isAbsolute(input.cwd)))) {
// An explicit root can be outside a workspace denied by Claude file permissions.
// The daemon cannot prove those permissions; the operator grants access to this root.
const root = preparationRoot ?? path.join(input.cwd!, ".openrig", "compaction");
try {
fs.mkdirSync(root, { recursive: true });
const ignorePath = path.join(root, ".gitignore");
@@ -934,11 +943,15 @@ export class ClaudeCompactionEnforcer {
}
} finally { fs.closeSync(fd); }
}
const parent = path.join(root, "preparation", sanitizeSessionKey(input.sessionName), attemptId);
const parent = path.join(preparationRoot ?? path.join(root, "preparation"), sanitizeSessionKey(input.sessionName), attemptId);
fs.mkdirSync(parent, { recursive: true });
fs.accessSync(parent, fs.constants.W_OK | fs.constants.X_OK);
attempt.mapPath = path.join(parent, "RESTORE-MAP.md");
} catch { /* Preserve the existing instance-home path for legacy or unwritable workspaces. */ }
} catch {
// An explicit location is a permission choice: never silently move it back into
// the denied workspace or instance home. Keep the selected path visible in --state.
if (preparationRoot) this.stopPreparation(input.sessionName, "preparation_directory_unavailable");
}
}
if (attempt.status !== "stopped" && attempt.mapPath === fallbackMapPath) {
// The cwd path already creates its parent. Prepare the legacy fallback too;
@@ -1048,10 +1061,11 @@ export class ClaudeCompactionEnforcer {
}
attempt.compact = "submitted_unconfirmed";
if (attempt.controller.signal.aborted) return { ok: false, reason: attempt.reason };
// Whatever the command could not carry, the restore request carries instead.
if (skipMap || !composed.mapPathInCommand || !composed.instructionInCommand) {
// Whatever the command could not carry, the restore request carries instead. An explicitly
// selected preparation root also keeps its exact map in that request, even when the command fit it.
if (skipMap || attempt.preparationRoot !== null || !composed.mapPathInCommand || !composed.instructionInCommand) {
this.restoreCarry.set(input.sessionName, {
mapPath: !skipMap && !composed.mapPathInCommand ? attempt.mapPath : null,
mapPath: !skipMap && (attempt.preparationRoot !== null || !composed.mapPathInCommand) ? attempt.mapPath : null,
compactInstruction: composed.instructionInCommand ? null : attempt.compactInstruction,
...(skipMap ? { skipMap: { mapPath: attempt.mapPath, marker: attempt.marker } } : {}),
});
@@ -1123,7 +1137,8 @@ export class ClaudeCompactionEnforcer {
this.reconcilePreparations();
const attempt = this.pendingPreCompactPrep.get(session);
if (!attempt) return null;
const { controller: _controller, sends: _sends, policyWasEnabled: _enabled, compactInstruction: _instruction, stoppedAt: _stoppedAt, cancelled: _cancelled, ...state } = attempt;
const { controller: _controller, sends: _sends, policyWasEnabled: _enabled, preparationRoot: _preparationRoot,
compactInstruction: _instruction, stoppedAt: _stoppedAt, cancelled: _cancelled, ...state } = attempt;
return state;
}
@@ -30,7 +30,7 @@ interface BrowserSource { id: string; state: State; path: string | null; detail:
/** Names are presentation policy, never a second setting registry or resolver. */
const PATHS = new Set(["db.path", "transcripts.path", "workspace.root", "workspace.slices_root",
"workspace.steering_path", "workspace.specs_root", "workspace.projects_root", "workspace.catalog_path",
"topology.root", "context.root", "skills.root", "policies.claude_compaction.message_file_path"]);
"topology.root", "context.root", "skills.root", "policies.claude_compaction.message_file_path", "policies.claude_compaction.preparation_root"]);
const IDENTITIES = new Set(["daemon.host", "host.name", "host.selected", "context.system_world",
"ui.timezone", "agents.advisor_session", "agents.operator_session", "workspace.operator_seat_name",
"workflow.exception_routing", "policies.idle_gate_qitem.auto_register"]);
@@ -151,7 +151,7 @@ export const SETTINGS_VALID_KEYS = [
"runtime.codex.hooks_enabled",
"runtime.readiness_timeout_seconds",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
// 7 keys (lockstep with cli/src/config-store.ts VALID_KEYS).
// 8 keys (lockstep with cli/src/config-store.ts VALID_KEYS).
// Opt-in default-off; daemon ContextMonitor reads `enabled` +
// `threshold_percent` to decide when to send pre-compact prep +
// /compact. The daemon wraps `pre_compact_instruction` with usage
@@ -159,6 +159,7 @@ export const SETTINGS_VALID_KEYS = [
// the actual compaction phase, uses `message_inline` +
// `message_file_path` for post-compaction restore guidance, then
// wraps `post_restore_audit_instruction` as the read-depth nudge.
"policies.claude_compaction.preparation_root",
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.pre_compact_instruction",
@@ -287,6 +288,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
"runtime.readiness_timeout_seconds": { primary: "OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS" },
// Slice 27 — Claude auto-compaction policy. Net-new keys; OPENRIG_X
// primary only.
"policies.claude_compaction.preparation_root": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT" },
"policies.claude_compaction.enabled": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED" },
"policies.claude_compaction.threshold_percent": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT" },
"policies.claude_compaction.pre_compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION" },
@@ -377,6 +379,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
"runtime.readiness_timeout_seconds": ["runtime", "readinessTimeoutSeconds"],
"policies.claude_compaction.preparation_root": ["policies", "claudeCompaction", "preparationRoot"],
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
"policies.claude_compaction.pre_compact_instruction": ["policies", "claudeCompaction", "preCompactInstruction"],
@@ -673,6 +676,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
// the canonical restore skill; compact_instruction is intentionally
// blank because Claude's native compact summary is less reliable
// than the explicit pre-compact and post-compact user-channel flow.
case "policies.claude_compaction.preparation_root": return "";
case "policies.claude_compaction.enabled": return false;
case "policies.claude_compaction.threshold_percent": return 80;
case "policies.claude_compaction.pre_compact_instruction": return DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION;
@@ -833,6 +837,11 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
// "80.5" → 80) is not safe for a key the daemon's compaction trigger
// reads at every poll tick; runtime validator rejects what the
// contract forbids per banked feedback_static_gates_mirror_runtime_validators.
"policies.claude_compaction.preparation_root": (_raw, value) => {
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
throw new Error("Invalid policies.claude_compaction.preparation_root: use an absolute path or an empty string for the default");
}
},
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed)) {
@@ -950,6 +959,8 @@ function validateTypedFileValue(key: SettingsValidKey, value: string | number |
* shape via direct config.json read (without depending on the daemon).
*/
export interface ClaudeCompactionPolicy {
/** Empty or absent keeps workspace-first preparation; read for each new attempt. */
preparationRoot?: string;
enabled: boolean;
thresholdPercent: number;
preCompactInstruction: string;
@@ -1108,6 +1119,7 @@ export class SettingsStore {
const fc = this.readConfigFile();
const wr = this.resolveWorkspaceRootRaw(fc);
return {
preparationRoot: this.resolveOne("policies.claude_compaction.preparation_root", fc, wr).value as string,
enabled: this.resolveOne("policies.claude_compaction.enabled", fc, wr).value as boolean,
thresholdPercent: this.resolveOne("policies.claude_compaction.threshold_percent", fc, wr).value as number,
preCompactInstruction: this.resolveOne("policies.claude_compaction.pre_compact_instruction", fc, wr).value as string,
@@ -22,7 +22,7 @@ function fixture(manualPrepWaitMs:number|null=1000,guardDb?:Database.Database){
let onSleep:(()=>Promise<void>)|undefined;
const db=guardDb??({prepare:()=>({get:()=>undefined,all:()=>[]})} as any);
const writes:string[]=[],keys:string[][]=[];
const policy={enabled:true,thresholdPercent:80,preCompactInstruction:'Write a restore map.',compactInstruction:'',messageInline:'',messageFilePath:'',postRestoreAuditInstruction:''};
const policy={preparationRoot:'',enabled:true,thresholdPercent:80,preCompactInstruction:'Write a restore map.',compactInstruction:'',messageInline:'',messageFilePath:'',postRestoreAuditInstruction:''};
const tmux={probeSession:vi.fn(async()=>({state:'present'})),sendText:vi.fn(async(_:string,text:string)=>{writes.push(text);return{ok:true};}),sendKeys:vi.fn(async(_:string,k:string[])=>{keys.push(k);return{ok:true};})};
const guard=new SeatDeliveryGuard(db,()=>({nodeId:'node-one',session:seat,pane:'%1',occupant:generation}));
Object.assign(tmux,{deliveryGuard:guard});
@@ -508,6 +508,182 @@ it('legacy fallback parent is created before asking the seat to edit its temp fi
await f.e.maybeAutoCompact(input);expect(compacts(f)).toHaveLength(1);
});
for (const mode of ['manual', 'automatic'] as const) it(`configured preparation root: ${mode} publishes outside the launch workspace`, async () => {
const f=fixture(),cwd=join(f.home,'denied launch directory'),root=join(f.home,'allowed maps');
mkdirSync(cwd);f.policy.preparationRoot=root;
// A writable cwd is still selected away from: POSIX writability cannot prove Claude permission.
mkdirSync(join(cwd,'.claude'));writeFileSync(join(cwd,'.claude','settings.json'),JSON.stringify({permissions:{deny:[`Edit(${cwd}/**)`]}}));
if(mode==='manual'){
f.onSleep(async()=>{stageMap(f);});
expect((await routeFixture(f,cwd)()).status).toBe(200);
}else{
await f.e.maybeAutoCompact({...input,cwd});stageMap(f);
await f.e.maybeAutoCompact({...input,cwd});
}
const a=f.e.getPreparationState(seat)!;
expect(a.mapPath).toBe(join(root,seat,a.attemptId,'RESTORE-MAP.md'));
expect(readFileSync(join(root,'.gitignore'),'utf8')).toBe('*\n');
expect(existsSync(join(cwd,'.openrig'))).toBe(false);
expect(readFileSync(a.mapPath,'utf8')).toContain(a.marker);
expect(existsSync(a.mapPath+'.tmp')).toBe(false);
expect(compacts(f)).toHaveLength(1);
expect(f.writes[0]).toContain(JSON.stringify(a.mapPath+'.tmp'));
f.policy.preparationRoot=join(f.home,'new maps');
await f.e.maybeAutoCompact({...input,cwd,usedPercentage:20});
await f.e.maybeAutoCompact({...input,cwd,usedPercentage:20});
expect(f.writes.at(-1)).toContain(`This attempt's restore map is ${a.mapPath}; read it.`);
expect(f.writes.at(-1)).toContain("read this preparation attempt's restore map and use the restore packet as a lookup");
expect(f.writes.at(-1)).not.toContain('read your newest restore map');
expect(f.writes.at(-1)).not.toContain(f.policy.preparationRoot);
});
it('configured preparation root: stays bound to the attempt while later seats use new settings',async()=>{
const f=fixture(),root=join(f.home,'maps');f.policy.preparationRoot=root;
await f.e.maybeAutoCompact(input);const first=f.e.getPreparationState(seat)!;
f.policy.preparationRoot=join(f.home,'new maps');
await f.e.maybeAutoCompact({...input,sessionName:'sibling@demo'});
expect(f.e.getPreparationState('sibling@demo')!.mapPath).toContain(join(f.policy.preparationRoot,'sibling@demo'));
expect(f.e.getPreparationState(seat)!.mapPath).toBe(first.mapPath);
stageMap(f);await f.e.maybeAutoCompact(input);
expect(readFileSync(first.mapPath,'utf8')).toContain(first.marker);
});
for(const mode of ['manual','automatic'] as const)it(`configured preparation root: ${mode} keeps the bounded command and carries the original long path and instruction into restore`,async()=>{
const f=fixture();
const root=join(f.home,...Array.from({length:8},()=> 'allowed-map-directory-'.repeat(4)));
const instruction='Keep the outstanding decision and its supporting source. '.repeat(30).trim();
f.policy.preparationRoot=root;f.policy.compactInstruction=instruction;
const finish=()=>{
stageMap(f);
f.policy.preparationRoot=join(f.home,'later maps');
f.policy.compactInstruction='A later instruction belongs to a later attempt.';
};
if(mode==='manual'){
f.onSleep(async()=>{finish();});
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true})).triggered).toBe(true);
}else{
await f.e.maybeAutoCompact(input);finish();await f.e.maybeAutoCompact(input);
}
const attempt=f.e.getPreparationState(seat)!;
expect(attempt.mapPath).toBe(join(root,seat,attempt.attemptId,'RESTORE-MAP.md'));
expect(compacts(f)).toHaveLength(1);
expect(compacts(f)[0]!.length).toBeLessThanOrEqual(600);
expect(compacts(f)[0]).not.toContain(attempt.mapPath);
expect(compacts(f)[0]).not.toContain(instruction);
const unsampled={sessionName:seat,runtime:'claude-code'};
await f.e.drainPendingPostCompactStage(unsampled);
await f.e.drainPendingPostCompactStage(unsampled);
const restore=f.writes.at(-1)!;
expect(restore).toContain(`This attempt's restore map is ${attempt.mapPath}; read it.`);
expect(restore).toContain(instruction);
expect(restore).not.toContain(f.policy.preparationRoot);
expect(restore).not.toContain(f.policy.compactInstruction);
});
it('configured preparation root: skip-map still uses an optional map completed at this attempt path',async()=>{
const f=fixture();f.policy.preparationRoot=join(f.home,'allowed maps');
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true,skipMap:true})).triggered).toBe(true);
const attempt=f.e.getPreparationState(seat)!;stageMap(f);
f.policy.preparationRoot=join(f.home,'later maps');
const unsampled={sessionName:seat,runtime:'claude-code'};
await f.e.drainPendingPostCompactStage(unsampled);
await f.e.drainPendingPostCompactStage(unsampled);
expect(f.writes.at(-1)).toContain(`This attempt's restore map is ${attempt.mapPath}.tmp; read it.`);
expect(f.writes.at(-1)).not.toContain('No restore map was required');
expect(f.writes.at(-1)).not.toContain(f.policy.preparationRoot);
expect(existsSync(attempt.mapPath)).toBe(false);
expect(compacts(f)).toHaveLength(1);
});
for(const mode of ['manual','automatic'] as const)it(`configured preparation root: ${mode} restore without a usage sample retains the published map until audit`,async()=>{
const f=fixture();f.policy.preparationRoot=join(f.home,'original maps');
if(mode==='manual'){
f.onSleep(async()=>{stageMap(f);});
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true})).triggered).toBe(true);
}else{
await f.e.maybeAutoCompact(input);stageMap(f);await f.e.maybeAutoCompact(input);
}
const a=f.e.getPreparationState(seat)!;
f.policy.preparationRoot=join(f.home,'later maps');
const send=vi.spyOn(f.transport,'send');
const unsampled={sessionName:seat,runtime:'claude-code'};
expect((await f.e.drainPendingPostCompactStage(unsampled)).triggered).toBe(true);
expect(f.e.getPreparationState(seat)?.mapPath).toBe(a.mapPath);
expect((await f.e.drainPendingPostCompactStage(unsampled)).triggered).toBe(true);
expect(f.writes.at(-1)).toContain(`This attempt's restore map is ${a.mapPath}; read it.`);
expect(f.writes.at(-1)).not.toContain(f.policy.preparationRoot);
expect(f.e.getPreparationState(seat)?.mapPath).toBe(a.mapPath);
expect((await f.e.drainPendingPostCompactStage(unsampled)).triggered).toBe(true);
expect(send.mock.calls).toHaveLength(3);
for(const call of send.mock.calls)expect(call[2]).toMatchObject({readinessFromPaneOnly:true});
expect(f.e.getPreparationState(seat)).toBeNull();
expect(f.e.hasPendingPostCompactStage(seat)).toBe(false);
expect(readFileSync(a.mapPath,'utf8')).toContain(a.marker);
expect(compacts(f)).toHaveLength(1);
});
for(const kind of ['file','ignore','symlink'] as const) it(`configured preparation root: ${kind} conflict stops without fallback or rewriting`,async()=>{
const f=fixture(),root=join(f.home,'maps'),cwd=join(f.home,'cwd');mkdirSync(cwd);
f.policy.preparationRoot=root;
if(kind==='file')writeFileSync(root,'keep');
else{
mkdirSync(root);
if(kind==='ignore')writeFileSync(join(root,'.gitignore'),'keep');
else{writeFileSync(join(f.home,'outside'),'keep');symlinkSync(join(f.home,'outside'),join(root,'.gitignore'));}
}
expect(await f.e.maybeAutoCompact({...input,cwd})).toEqual({triggered:false,reason:'preparation_directory_unavailable'});
expect(f.e.getPreparationState(seat)).toMatchObject({status:'stopped',reason:'preparation_directory_unavailable'});
expect(f.e.getPreparationState(seat)!.mapPath).toContain(root);
expect(f.writes).toHaveLength(0);
expect(existsSync(join(cwd,'.openrig'))).toBe(false);
expect(existsSync(join(f.home,'compaction'))).toBe(false);
expect(readFileSync(kind==='file'?root:join(root,'.gitignore'),'utf8')).toBe('keep');
});
it('configured preparation root: manual reports directory failure; explicit skip-map still works',async()=>{
const f=fixture(),root=join(f.home,'maps');writeFileSync(root,'keep');f.policy.preparationRoot=root;
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_directory_unavailable'});
expect(f.writes).toHaveLength(0);
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true,skipMap:true})).triggered).toBe(true);
expect(readFileSync(root,'utf8')).toBe('keep');
await f.e.maybeAutoCompact({...input,usedPercentage:20});
await f.e.maybeAutoCompact({...input,usedPercentage:20});
expect(f.writes.at(-1)).toContain("No restore map was required for this compaction (--skip-map)");
expect(f.writes.at(-1)).not.toContain("This attempt's restore map is");
});
for(const mode of ['manual','automatic'] as const)for(const end of ['expiry','cancel','disable','replacement'] as const)it(`configured preparation root: ${mode} ${end} never publishes a late map`,async()=>{
const f=fixture();f.policy.preparationRoot=join(f.home,'maps');
const disarm=()=>{
if(end==='expiry')f.advance(mode==='manual'?1001:AUTO_PREP_WAIT_MS_DEFAULT+1);
if(end==='cancel')f.e.cancelPreparation(seat);
if(end==='disable')f.policy.enabled=false;
if(end==='replacement')f.generation('generation-two');
f.e.reconcilePreparations();stageMap(f);
};
if(mode==='manual'){
f.onSleep(async()=>{disarm();});
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true})).triggered).toBe(false);
}else{
await f.e.maybeAutoCompact(input);disarm();
}
f.policy.enabled=true;await f.e.maybeAutoCompact(input);
const a=f.e.getPreparationState(seat)!;
expect(a).toMatchObject({status:'stopped',reason:{expiry:'preparation_incomplete',cancel:'preparation_cancelled',disable:'disabled',replacement:'stale_generation'}[end]});
expect(existsSync(a.mapPath)).toBe(false);expect(existsSync(a.mapPath+'.tmp')).toBe(true);
expect(compacts(f)).toHaveLength(0);expect(f.writes).toHaveLength(1);
});
it.skipIf(process.getuid?.()===0)('configured preparation root: an unwritable descendant stops without fallback',async()=>{
const f=fixture(),root=join(f.home,'maps'),parent=join(root,seat);mkdirSync(parent,{recursive:true});chmodSync(parent,0o500);
f.policy.preparationRoot=root;
try{
expect(await f.e.maybeAutoCompact(input)).toEqual({triggered:false,reason:'preparation_directory_unavailable'});
expect(f.writes).toHaveLength(0);expect(existsSync(join(f.home,'compaction'))).toBe(false);
}finally{chmodSync(parent,0o700);}
});
// Manual preparation owns this bounded attention wait; generic sends still refuse attention.
it('manual attention clears within the same attempt with one preparation and one compact',async()=>{
const f=fixture();let sleeps=0;let id='';
@@ -118,6 +118,8 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
function buildApp(opts?: { wireEnforcer?: boolean; completeMap?: boolean; bearerToken?: string }): AppParts {
const sentTexts: string[] = [];
let clock = Date.now();
const sleep = async (ms: number) => { clock += ms; };
const transport = new SessionTransport({
db,
rigRepo,
@@ -129,10 +131,14 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
if (opts?.completeMap !== false && marker && target) { const file = JSON.parse(target[1]!); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, "# Fixture map\n" + marker); }
return { ok: true as const };
}),
sleep: async () => undefined,
now: () => new Date(clock),
sleep,
waitForIdlePollMs: 1,
});
const enforcer = new ClaudeCompactionEnforcer(makeSettings(), transport, { openrigHome: stateDir, manualPrepWaitMs: 50, resolveOccupantGeneration: () => "fixture-generation" });
const enforcer = new ClaudeCompactionEnforcer(makeSettings(), transport, {
openrigHome: stateDir, manualPrepWaitMs: 1000, resolveOccupantGeneration: () => "fixture-generation",
now: () => clock, sleep,
});
const app = new Hono();
app.use("*", async (c, next) => {
c.set("sessionTransport" as never, transport);
@@ -356,8 +356,10 @@ describe("GET /api/provider/usage/hosts", () => {
describe("POST /api/provider/usage/hosts/refresh", () => {
it("a refresh slower than the 5 s read class still succeeds, and its reading reaches the view", async () => {
const a = await host("a", { codex: { delayMs: 6_000, secondaryUsed: 85 } });
const hub = await host("hub", { registry: [a.entry()] });
// Keep the scripted weekly window before its reset while the delay uses real timers.
const now = () => "2026-10-14T00:00:00.000Z";
const a = await host("a", { now, codex: { delayMs: 6_000, secondaryUsed: 85 } });
const hub = await host("hub", { now, registry: [a.entry()] });
const started = Date.now();
const { status, body } = await refreshVia(hub, { provider: "codex" });
expect(Date.now() - started).toBeGreaterThan(5_000);
@@ -1,5 +1,5 @@
import { afterEach, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, readlinkSync, readdirSync, realpathSync, rmSync } from "node:fs";
import { mkdtempSync, mkdirSync, writeFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
import { execFile } from "node:child_process";
@@ -74,7 +74,8 @@ it.each(["claude", "codex"])("D: nushell retains the previous literal %s command
expect(command).not.toContain('"$PATH"'); expect(f.warn).toHaveBeenCalledTimes(1);
});
it.each(["fresh", "resume", "fork"] as const)("C: %s uses the probe's separate Node interpreter while preserving user PATH", async kind => {
const f = fixture(), nodeBin = path.dirname(process.execPath);
const f = fixture(), nodeBin = path.join(f.root, "node interpreter");
mkdirSync(nodeBin); symlinkSync(process.execPath, path.join(nodeBin, "node"));
expect(["/usr/bin", "/bin", f.shared, f.runtime]).not.toContain(nodeBin);
f.exe(path.join(f.runtime, "codex"), `#!/usr/bin/env node\nif(process.argv.includes('--help'))console.log('Usage: codex [OPTIONS]\\n --no-daemon');else console.log(JSON.stringify({started:true,node:process.execPath,path:process.env.PATH}));\n`);
// There is no Node in the pane PATH. The capability query really executes this npm-style entry.
@@ -24,6 +24,7 @@ function clearEnv(): () => void {
"OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH",
"OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT",
"OPENRIG_LAUNCH_NON_INTERRUPTIVE",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT",
"OPENRIG_BUNDLE_SERVICES_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
@@ -116,13 +117,15 @@ describe("config routes (User Settings v0)", () => {
// + 1 usage.pi_cost_facts (slice 02 Pi cost from persisted entries; default off).
// + 1 usage.pi_openrouter_charge (slice 02 K4 OpenRouter charge beside the Pi estimate; default off).
// + 1 transport.verify_input_box (input-box evidence for verified Claude Code and Codex sends; default off).
expect(body.settings["policies.claude_compaction.preparation_root"]).toMatchObject({ value: "", source: "default" });
// + 1 bundle.services_enabled (service archive packing, preview and target installs; default off).
expect(body.settings["transport.local_user_guard"]).toMatchObject({ value: false, source: "default", defaultValue: false });
expect(body.settings["bundle.services_enabled"]).toMatchObject({ value: false, source: "default", defaultValue: false });
// (provider.subscription_moves was removed with the subscription move record, which nothing acted on.)
// + 1 terminal.history_limit (rows of tmux scrollback in the block rig setup writes; default 10000).
// + 1 skills.router_line.enabled (the seat-skills bootstrap line; default off).
expect(Object.keys(body.settings).length).toBe(84);
// + 1 instance-wide Claude compaction preparation root (#1445).
expect(Object.keys(body.settings).length).toBe(85);
expect(body.settings["terminal.history_limit"]).toMatchObject({ value: 10000, source: "default", defaultValue: 10000 });
expect(body.settings).not.toHaveProperty("provider.subscription_moves");
expect(body.settings["launch.non_interruptive"]).toMatchObject({ value: false, source: "default" });
@@ -48,6 +48,7 @@ function clearEnv(): () => void {
"OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED",
"OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS",
// Slice 27 — Claude auto-compaction policy env-map.
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION",
@@ -136,6 +137,7 @@ describe("SettingsStore (User Settings v0)", () => {
"runtime.codex.hooks_enabled",
"runtime.readiness_timeout_seconds",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
"policies.claude_compaction.preparation_root",
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.pre_compact_instruction",
@@ -887,3 +889,39 @@ describe("parseNamedPairs (daemon copy)", () => {
expect(parseNamedPairs(" ")).toEqual([]);
});
});
describe("compaction preparation root", () => {
let tmpDir: string, configPath: string;
let restoreEnv: () => void;
beforeEach(() => {
restoreEnv = clearEnv();
tmpDir = mkdtempSync(join(tmpdir(), "preparation-root-settings-"));
configPath = join(tmpDir, "config.json");
});
afterEach(() => { restoreEnv(); rmSync(tmpDir, { recursive: true, force: true }); });
const key = "policies.claude_compaction.preparation_root";
it("resolves file/env overrides consistently and resets to workspace-first preparation", () => {
const store = new SettingsStore(configPath);
const root = join(tmpDir, "maps with spaces");
expect(store.resolveClaudeCompactionPolicy().preparationRoot).toBe("");
store.set(key, root);
expect(store.resolveOne(key)).toMatchObject({ value: root, source: "file" });
expect(new SettingsStore(configPath).resolveClaudeCompactionPolicy().preparationRoot).toBe(root);
process.env.OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT = join(root, "override");
try {
expect(store.resolveOne(key)).toMatchObject({ value: join(root, "override"), source: "env" });
} finally { delete process.env.OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT; }
store.reset(key);
expect(store.resolveClaudeCompactionPolicy().preparationRoot).toBe("");
store.set(key, "");
expect(store.resolveClaudeCompactionPolicy().preparationRoot).toBe("");
});
it.each(["relative/maps", "~/maps", " /maps", "/maps\0invalid"])("rejects invalid root %j without replacing valid settings", (value) => {
const store = new SettingsStore(configPath);
const root = join(tmpDir, "maps");
store.set(key, root);
expect(() => store.set(key, value)).toThrow(/absolute path/);
expect(store.resolveClaudeCompactionPolicy().preparationRoot).toBe(root);
});
});