feat(daemon,ui): Claude auto-compaction policy (slice 27)

Operator-configurable pre-compaction trigger. When a Claude seat's
context usage crosses a configured threshold, the daemon dispatches
`/compact` via SessionTransport and the existing PreCompact hook
appends an operator-provided message to the post-compaction system
message alongside the standard restore-instructions.

Architecture:
- 4 new ConfigStore + SettingsStore keys
  (policies.claude_compaction.{enabled,threshold_percent,
  message_inline,message_file_path}) in lockstep across the CLI +
  daemon allowlists.
- ClaudeCompactionEnforcer encapsulates policy + dedup + send so the
  trigger path is unit-testable independently from ContextMonitor's
  scheduler concern.
- ContextMonitor's polling loop calls the enforcer after each
  observation persists; enforcer respects runtime filter (claude-code
  only), threshold gating, opt-in default-off, and a per-seat 60s
  dedup window. Send-failure does NOT advance the dedup timestamp,
  so a transient failure can retry on the next tick.
- precompact-hook.mjs reads $OPENRIG_HOME/config.json directly (no
  daemon-HTTP dependency). Inline message wins when both inline + file
  path are set; missing file degrades gracefully (no append, no fail).
- /settings/policies route + ClaudeCompactionPolicyForm shipped as a
  temporary mount until slice 26's Settings explorer integrates the
  form into the canonical Policies destination.

Hard-gate coverage:
- HG-1 settings round-trip via SettingsStore set/resolveOne
- HG-2 threshold-check fires at + above threshold
- HG-3 SessionTransport.send dispatched with canonical session name
- HG-4 dedup window blocks repeat triggers; resumes after interval
- HG-5 opt-in default-off regression (compaction lifecycle blast-radius
       guard per banked permission-layer foot-gun rule)
- HG-6 inline message appended to systemMessage
- HG-7 file-path message read + appended when inline empty
- HG-8 neither set: standard restore-instructions preserved
- HG-9 form renders + validates + persists each key
- HG-10 resolveClaudeCompactionPolicy rereads config.json without
        daemon restart (single resolve call picks up live edits)
- HG-11 / HG-12 land at velocity-qa VM dogfood + final SC-29 verify

Test counts: enforcer 8/8, settings-store +5/+5, hook 6/6, UI form 4/4
(23 new tests; full daemon + cli + ui suites green minus 2 pre-existing
UI flakes + 1 bootstrap-orchestrator ordering flake — verified on main).

SC-29 EXCEPTION #10 declared: 4 new ConfigStore + SettingsStore keys in
the policies.claude_compaction.* namespace. Lockstep CLI + daemon
allowlists; no schema migrations / new endpoints / new event types
beyond reusing the existing /api/config POST surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-12 03:01:32 -07:00
1 parent f27da1a48a
commit fe50ce7759
17 files changed
+1218 -8

No files matched your search

+2
View File
@@ -43,6 +43,8 @@ Keys:
agents.* advisor_session, operator_session
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
runtime.codex.* hooks_enabled
policies.claude_compaction.*
enabled, threshold_percent, message_inline, message_file_path
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean; withSource?: boolean }) => {
+52
View File
@@ -91,6 +91,25 @@ export interface RiggedConfig {
hooksEnabled: boolean;
};
};
// Slice 27 — Claude auto-compaction policy. Operator-configurable
// pre-compaction trigger: when a Claude seat's context usage crosses
// `thresholdPercent`, daemon sends /compact via SessionTransport and
// the existing PreCompact hook injects `messageInline` (or contents of
// `messageFilePath` if inline is empty) alongside the standard
// restore-instructions in the post-compact systemMessage.
//
// Defaults: opt-in default-off (enabled=false). Empty string on
// message_inline / message_file_path means "not set" (consistent with
// agents.operator_session pattern; ConfigStore schema avoids nullable
// strings at the file layer).
policies: {
claudeCompaction: {
enabled: boolean;
thresholdPercent: number;
messageInline: string;
messageFilePath: string;
};
};
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
@@ -151,6 +170,15 @@ const DEFAULTS = {
hooksEnabled: true,
},
},
// Slice 27 — opt-in default-off. Threshold default 80% per spec.
policies: {
claudeCompaction: {
enabled: false,
thresholdPercent: 80,
messageInline: "",
messageFilePath: "",
},
},
} as const;
export const VALID_KEYS = [
@@ -192,6 +220,12 @@ export const VALID_KEYS = [
"feed.subscriptions.audit_log",
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
"runtime.codex.hooks_enabled",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
// 4 new ConfigStore keys (lockstep with daemon SETTINGS_VALID_KEYS).
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
] as const;
export type ValidKey = typeof VALID_KEYS[number];
@@ -233,6 +267,12 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
// Net-new key post-rename: OPENRIG_X primary only per the 5-key
// boundary doctrine (no RIGGED_X legacy on net-new keys).
"runtime.codex.hooks_enabled": { primary: "OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED" },
// Slice 27 — Claude auto-compaction policy. OPENRIG_X primary only
// (net-new keys, no legacy).
"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.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
};
// Maps dotted-string config keys to the camelCase RiggedConfig path.
@@ -269,6 +309,10 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"feed.subscriptions.progress": ["feed", "subscriptions", "progress"],
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
};
function isValidKey(key: string): key is ValidKey {
@@ -444,6 +488,14 @@ export class ConfigStore {
hooksEnabled: v("runtime.codex.hooks_enabled") as boolean,
},
},
policies: {
claudeCompaction: {
enabled: v("policies.claude_compaction.enabled") as boolean,
thresholdPercent: v("policies.claude_compaction.threshold_percent") as number,
messageInline: v("policies.claude_compaction.message_inline") as string,
messageFilePath: v("policies.claude_compaction.message_file_path") as string,
},
},
};
}
@@ -98,6 +98,11 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"feed.subscriptions.audit_log",
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
"runtime.codex.hooks_enabled",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
];
expect([...VALID_KEYS]).toEqual(expected);
});
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
@@ -17,6 +18,51 @@ function readHookInput() {
return JSON.parse(raw);
}
// Slice 27 — read the OpenRig config directly (no daemon HTTP dependency
// so the hook still works when the daemon isn't running or isn't
// reachable from this process). Returns "" for either field when the
// config is missing, malformed, or the policy isn't set.
function readClaudeCompactionMessage() {
const configPath = path.join(
process.env.OPENRIG_HOME || process.env.RIGGED_HOME || path.join(os.homedir(), ".openrig"),
"config.json",
);
let inline = "";
let filePath = "";
try {
if (!fs.existsSync(configPath)) return "";
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw);
const policy = parsed?.policies?.claudeCompaction;
if (policy && typeof policy === "object") {
if (typeof policy.messageInline === "string") inline = policy.messageInline;
if (typeof policy.messageFilePath === "string") filePath = policy.messageFilePath;
}
} catch {
return "";
}
if (inline && inline.length > 0) return inline;
if (filePath && filePath.length > 0) {
try {
const expanded = filePath.startsWith("~/")
? path.join(os.homedir(), filePath.slice(2))
: filePath;
if (fs.existsSync(expanded)) {
return fs.readFileSync(expanded, "utf8");
}
} catch {
return "";
}
}
return "";
}
function buildSystemMessage(restoreInstruction, customMessage) {
if (!customMessage) return restoreInstruction;
return `${restoreInstruction}\n\n--- Operator-configured pre-compaction message ---\n${customMessage}`;
}
try {
const input = readHookInput();
const args = [restoreScript, "--out", outRoot, "--json"];
@@ -25,19 +71,23 @@ try {
args.push(input.transcript_path);
}
const customMessage = readClaudeCompactionMessage();
const result = spawnSync("node", args, { encoding: "utf8" });
if (result.status !== 0) {
const baseFailure = `Claude compaction restore packet generation failed: ${(result.stderr || result.stdout || "unknown error").trim()}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`;
emit({
continue: true,
systemMessage: `Claude compaction restore packet generation failed: ${(result.stderr || result.stdout || "unknown error").trim()}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`,
systemMessage: buildSystemMessage(baseFailure, customMessage),
});
process.exit(0);
}
const parsed = JSON.parse(result.stdout);
const baseRestore = `Pre-compaction restore seed packet prepared at ${parsed.outputDir}. After compaction, immediately restore before doing any other work: load/read the claude-compaction-restore skill, run "node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore --json" yourself, read the generated restore-instructions.md, read the generated touched-files.md, identify remembered important files, read those files in full, read root/as-built/codemap docs before real work, then report exactly "restored from packet at <path>; resumed at step <X>" with the files you read in full. If any step fails, report the failure explicitly.`;
emit({
continue: true,
systemMessage: `Pre-compaction restore seed packet prepared at ${parsed.outputDir}. After compaction, immediately restore before doing any other work: load/read the claude-compaction-restore skill, run "node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore --json" yourself, read the generated restore-instructions.md, read the generated touched-files.md, identify remembered important files, read those files in full, read root/as-built/codemap docs before real work, then report exactly "restored from packet at <path>; resumed at step <X>" with the files you read in full. If any step fails, report the failure explicitly.`,
systemMessage: buildSystemMessage(baseRestore, customMessage),
});
} catch (error) {
emit({
@@ -0,0 +1,100 @@
import type { SessionTransport } from "./session-transport.js";
import type { SettingsStore } from "./user-settings/settings-store.js";
/**
* Slice 27 — Claude auto-compaction policy enforcer.
*
* Decides per-seat whether ContextMonitor should send `/compact` based on
* operator-configured policy (`policies.claude_compaction.*` settings).
* Decoupled from ContextMonitor's scheduling concern so it can be tested
* + composed independently.
*
* Risk class: compaction lifecycle is load-bearing (banked permission-layer
* foot-gun rule extends to any agent-runtime trigger). Defensive contract:
*
* - Opt-in default-off: `enabled=false` → never triggers. Verified by
* regression test HG-5.
* - Runtime filter: triggers only when runtime === "claude-code". Codex
* compacts cleanly via its own runtime per agent-startup-guide; other
* runtimes are out of scope.
* - Dedup: in-memory per-session window (default 60s) prevents a
* flapping percentage from firing repeated /compact sends. State is
* intentionally NOT persisted; daemon restart resets the window which
* is the safer-failure direction (might re-compact once on restart in
* rare cases, won't lock out forever).
* - Send-failure graceful-degrade: returns { triggered: false } with a
* reason; does not throw. The dedup timestamp is only set on
* successful send, so a transient send failure can retry on the next
* polling tick.
*/
export const DEDUP_WINDOW_MS_DEFAULT = 60_000;
export interface EnforcerInput {
sessionName: string;
runtime: string | null;
usedPercentage: number | null;
}
export type EnforcerOutcome =
| { triggered: true }
| { triggered: false; reason: EnforcerSkipReason };
export type EnforcerSkipReason =
| "runtime_filter"
| "no_usage_data"
| "disabled"
| "below_threshold"
| "dedup_window"
| "send_failed";
export class ClaudeCompactionEnforcer {
private readonly settingsStore: SettingsStore;
private readonly sessionTransport: SessionTransport;
private readonly dedupWindowMs: number;
private readonly lastAutoCompactAt = new Map<string, number>();
constructor(
settingsStore: SettingsStore,
sessionTransport: SessionTransport,
opts?: { dedupWindowMs?: number },
) {
this.settingsStore = settingsStore;
this.sessionTransport = sessionTransport;
this.dedupWindowMs = opts?.dedupWindowMs ?? DEDUP_WINDOW_MS_DEFAULT;
}
/**
* Inspect a single observation and trigger /compact when policy says so.
* Safe to call on every poll tick; non-eligible inputs return early
* with a skip reason and never touch SessionTransport.
*/
async maybeAutoCompact(input: EnforcerInput): Promise<EnforcerOutcome> {
if (input.runtime !== "claude-code") {
return { triggered: false, reason: "runtime_filter" };
}
if (input.usedPercentage == null) {
return { triggered: false, reason: "no_usage_data" };
}
const policy = this.settingsStore.resolveClaudeCompactionPolicy();
if (!policy.enabled) {
return { triggered: false, reason: "disabled" };
}
if (input.usedPercentage < policy.thresholdPercent) {
return { triggered: false, reason: "below_threshold" };
}
const now = Date.now();
const last = this.lastAutoCompactAt.get(input.sessionName);
if (last !== undefined && now - last < this.dedupWindowMs) {
return { triggered: false, reason: "dedup_window" };
}
const result = await this.sessionTransport.send(input.sessionName, "/compact");
if (!result.ok) {
return { triggered: false, reason: "send_failed" };
}
this.lastAutoCompactAt.set(input.sessionName, now);
return { triggered: true };
}
}
+39 -2
View File
@@ -1,10 +1,12 @@
import type Database from "better-sqlite3";
import type { ClaudeCompactionEnforcer } from "./claude-compaction-enforcer.js";
import type { ContextUsageStore } from "./context-usage-store.js";
import {
isAttentionRequiredReadinessCode,
type NodeBinding,
type ReadinessResult,
} from "./runtime-adapter.js";
import type { ContextUsage } from "./types.js";
/** Default polling interval: 30 seconds. */
export const DEFAULT_POLL_INTERVAL_MS = 30_000;
@@ -28,31 +30,41 @@ interface ClaudeContextProvisioner {
* Polls known managed runtime context sources and persists the latest
* normalized telemetry. Scheduler-only: no queries, no response shaping,
* no in-memory truth.
*
* Slice 27: optional `compactionEnforcer` participates in each polling
* tick after persistence so policy-driven /compact triggers fire on the
* same observation the operator sees in the UI. Without an enforcer
* provided, polling behavior is unchanged.
*/
export class ContextMonitor {
private db: Database.Database;
private store: ContextUsageStore;
private claudeContextProvisioner: ClaudeContextProvisioner | null;
private compactionEnforcer: ClaudeCompactionEnforcer | null;
private timer: ReturnType<typeof setInterval> | null = null;
constructor(
db: Database.Database,
store: ContextUsageStore,
claudeContextProvisioner?: ClaudeContextProvisioner,
compactionEnforcer?: ClaudeCompactionEnforcer,
) {
this.db = db;
this.store = store;
this.claudeContextProvisioner = claudeContextProvisioner ?? null;
this.compactionEnforcer = compactionEnforcer ?? null;
}
/** Discover active managed Claude sessions and poll their sidecar files. */
async pollOnce(): Promise<void> {
const sessions = this.getEligibleSessions();
for (const session of sessions) {
let observed: ContextUsage | null = null;
try {
const usage = this.readContextUsage(session);
this.store.persist(session.node_id, usage);
observed = this.readContextUsage(session);
this.store.persist(session.node_id, observed);
} catch {
observed = null;
// One bad session must not crash polling for others
try {
this.store.persist(session.node_id, this.store.unknownUsage("parse_error"));
@@ -60,6 +72,31 @@ export class ContextMonitor {
}
await this.normalizeStartupStatus(session);
await this.maybeAutoCompact(session, observed);
}
}
/**
* Slice 27 — invoke the enforcer with the latest observation. The
* enforcer owns policy + dedup + send; ContextMonitor only relays
* data and absorbs enforcer errors so a trigger-path fault never
* crashes telemetry polling.
*/
private async maybeAutoCompact(
session: EligibleSession,
usage: ContextUsage | null,
): Promise<void> {
if (!this.compactionEnforcer) return;
if (!usage || usage.availability !== "known") return;
try {
await this.compactionEnforcer.maybeAutoCompact({
sessionName: session.session_name,
runtime: session.runtime,
usedPercentage: usage.usedPercentage,
});
} catch {
// Defensive: enforcer should not throw, but absorb here so the
// polling loop continues to make progress for remaining sessions.
}
}
@@ -85,6 +85,16 @@ export const SETTINGS_VALID_KEYS = [
// Codex runtime. When false, operator is managing Codex config
// independently — daemon does NOT mutate.
"runtime.codex.hooks_enabled",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
// 4 new 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 /compact; PreCompact
// hook reads `message_inline` + `message_file_path` to inject a
// custom message alongside the existing restore-instructions.
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
] as const;
export type SettingsValidKey = typeof SETTINGS_VALID_KEYS[number];
@@ -124,6 +134,12 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
// OPENRIG_X primary only per the post-rename 5-key boundary doctrine
// (no RIGGED_X legacy on net-new keys).
"runtime.codex.hooks_enabled": { primary: "OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED" },
// Slice 27 — Claude auto-compaction policy. Net-new keys; OPENRIG_X
// primary only.
"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.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
};
const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
@@ -156,6 +172,10 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"feed.subscriptions.progress": ["feed", "subscriptions", "progress"],
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
};
export type SettingSource = "env" | "file" | "default";
@@ -274,6 +294,15 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
// Daemon ensures `codex_hooks = true` in ~/.codex/config.toml on
// launch unless operator explicitly sets to false.
case "runtime.codex.hooks_enabled": return true;
// Slice 27 — Claude auto-compaction policy defaults. Opt-in
// default-off; threshold 80% per spec. Empty-string defaults on
// message_inline / message_file_path mean "not set" (consistent
// with agents.operator_session pattern; hook treats empty as
// "skip custom message").
case "policies.claude_compaction.enabled": return false;
case "policies.claude_compaction.threshold_percent": return 80;
case "policies.claude_compaction.message_inline": return "";
case "policies.claude_compaction.message_file_path": return "";
default: return "";
}
}
@@ -293,6 +322,18 @@ function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string):
return raw;
}
/**
* Slice 27 — projected Claude auto-compaction policy. ContextMonitor
* consumes this snapshot per-poll; the PreCompact hook reads the same
* shape via direct config.json read (without depending on the daemon).
*/
export interface ClaudeCompactionPolicy {
enabled: boolean;
thresholdPercent: number;
messageInline: string;
messageFilePath: string;
}
export interface ResolvedConfig {
workspaceRoot: string;
workspaceSlicesRoot: string;
@@ -373,6 +414,22 @@ export class SettingsStore {
};
}
/**
* Slice 27 — read the Claude auto-compaction policy as a typed snapshot.
* Called per-poll by ContextMonitor so live edits to ~/.openrig/config.json
* take effect within one polling interval without daemon restart.
*/
resolveClaudeCompactionPolicy(): ClaudeCompactionPolicy {
const fc = this.readConfigFile();
const wr = this.resolveWorkspaceRootRaw(fc);
return {
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,
messageInline: this.resolveOne("policies.claude_compaction.message_inline", fc, wr).value as string,
messageFilePath: this.resolveOne("policies.claude_compaction.message_file_path", fc, wr).value as string,
};
}
set(key: string, value: string): void {
if (!isSettingsValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${SETTINGS_VALID_KEYS.join(", ")}`);
+11 -1
View File
@@ -1155,8 +1155,18 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// Context monitor — constructed before createApp so routes can access pollOnce for refresh.
// Caller (index.ts) starts polling after listen.
// Slice 27: wire ClaudeCompactionEnforcer when sessionTransport is
// available (it always is in the assembled deps; the conditional keeps
// the type narrow for downstream consumers).
const { ContextMonitor } = await import("./domain/context-monitor.js");
const contextMonitor = new ContextMonitor(db, contextUsageStore, claudeAdapter);
const { ClaudeCompactionEnforcer } = await import("./domain/claude-compaction-enforcer.js");
const compactionEnforcer = deps.sessionTransport
? new ClaudeCompactionEnforcer(
new ContextPackSettingsStore(),
deps.sessionTransport,
)
: undefined;
const contextMonitor = new ContextMonitor(db, contextUsageStore, claudeAdapter, compactionEnforcer);
deps.contextMonitor = contextMonitor;
const app = createApp(deps);
@@ -0,0 +1,212 @@
// Slice 27 — ClaudeCompactionEnforcer unit tests.
//
// Hard-gate coverage:
// HG-2 threshold-check fires when usedPercentage >= threshold + policy enabled
// HG-3 send('/compact') routed via SessionTransport with canonical session name
// HG-4 dedup window blocks repeated triggers within the configured interval
// HG-5 opt-in default-off — with policy disabled the enforcer must NOT fire
// (REGRESSION GATE for compaction lifecycle blast radius)
//
// Plus runtime-filter (claude-code only), below-threshold gating, missing
// usage data short-circuit, send-failure no-dedup-update semantics.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { ClaudeCompactionEnforcer } from "../src/domain/claude-compaction-enforcer.js";
import type { SessionTransport } from "../src/domain/session-transport.js";
import type { ClaudeCompactionPolicy, SettingsStore } from "../src/domain/user-settings/settings-store.js";
function makeSettingsStore(policy: ClaudeCompactionPolicy): SettingsStore {
return {
resolveClaudeCompactionPolicy: vi.fn(() => policy),
} as unknown as SettingsStore;
}
function makeSessionTransport(sendResult: { ok: boolean } = { ok: true }): {
transport: SessionTransport;
send: ReturnType<typeof vi.fn>;
} {
const send = vi.fn(async () => sendResult);
return {
transport: { send } as unknown as SessionTransport,
send,
};
}
const POLICY_DISABLED: ClaudeCompactionPolicy = {
enabled: false,
thresholdPercent: 80,
messageInline: "",
messageFilePath: "",
};
const POLICY_ENABLED_AT_80: ClaudeCompactionPolicy = {
enabled: true,
thresholdPercent: 80,
messageInline: "",
messageFilePath: "",
};
describe("ClaudeCompactionEnforcer", () => {
let dateNow: () => number;
beforeEach(() => {
let t = 1_700_000_000_000;
dateNow = vi.spyOn(Date, "now").mockImplementation(() => t) as unknown as () => number;
void dateNow;
// Advance the mock by re-defining via closures below where needed.
vi.restoreAllMocks();
});
it("HG-5: opt-in default-off — disabled policy must NOT trigger send", async () => {
const settings = makeSettingsStore(POLICY_DISABLED);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 99,
});
expect(outcome).toEqual({ triggered: false, reason: "disabled" });
expect(send).not.toHaveBeenCalled();
});
it("HG-2 + HG-3: fires when usedPercentage >= threshold AND policy enabled; sends '/compact' to the session", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 80,
});
expect(outcome).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith("claude-seat@rig", "/compact");
});
it("HG-2 negative path: below-threshold usage does NOT trigger send", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 79,
});
expect(outcome).toEqual({ triggered: false, reason: "below_threshold" });
expect(send).not.toHaveBeenCalled();
});
it("HG-4: dedup window blocks repeated trigger within interval (default 60s)", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { dedupWindowMs: 60_000 });
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const first = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 90,
});
expect(first).toEqual({ triggered: true });
now += 30_000;
const second = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
});
expect(second).toEqual({ triggered: false, reason: "dedup_window" });
now += 31_000;
const third = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
});
expect(third).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(2);
});
it("runtime filter: only claude-code triggers (codex sessions return runtime_filter without invoking send)", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "codex-seat@rig",
runtime: "codex",
usedPercentage: 99,
});
expect(outcome).toEqual({ triggered: false, reason: "runtime_filter" });
expect(send).not.toHaveBeenCalled();
});
it("missing usage data short-circuits without invoking settings or send", async () => {
const policySpy = vi.fn(() => POLICY_ENABLED_AT_80);
const settings = { resolveClaudeCompactionPolicy: policySpy } as unknown as SettingsStore;
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: null,
});
expect(outcome).toEqual({ triggered: false, reason: "no_usage_data" });
expect(policySpy).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
it("send-failure: returns send_failed without recording dedup (next tick can retry)", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport({ ok: false });
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { dedupWindowMs: 60_000 });
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const first = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
});
expect(first).toEqual({ triggered: false, reason: "send_failed" });
// Second tick 1s later: dedup must NOT block (no successful send was
// recorded). Send is attempted again.
now += 1_000;
send.mockImplementationOnce(async () => ({ ok: true }));
const second = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
});
expect(second).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(2);
});
it("dedup keyed per-session: two distinct seats do not block each other", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { dedupWindowMs: 60_000 });
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
expect(await enforcer.maybeAutoCompact({ sessionName: "a@rig", runtime: "claude-code", usedPercentage: 99 })).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({ sessionName: "b@rig", runtime: "claude-code", usedPercentage: 99 })).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(2);
expect(send.mock.calls[0]![0]).toBe("a@rig");
expect(send.mock.calls[1]![0]).toBe("b@rig");
});
});
@@ -0,0 +1,167 @@
// Slice 27 — precompact-hook.mjs end-to-end tests.
//
// The hook ships as a static asset under
// `packages/daemon/assets/plugins/openrig-core/skills/claude-compaction-restore/scripts/precompact-hook.mjs`
// and is invoked by Claude Code as a child process. These tests spawn the
// actual hook file with controlled stdin + an isolated OPENRIG_HOME so
// the on-disk behavior matches what Claude will observe at PreCompact
// time.
//
// Hard-gate coverage:
// HG-6 inline message appended to systemMessage
// HG-7 file-path message read + appended when inline is empty
// HG-8 neither set → existing restore-instructions only (no custom append)
// Inline wins when both set
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK_SCRIPT = resolve(
HERE,
"..",
"assets",
"plugins",
"openrig-core",
"skills",
"claude-compaction-restore",
"scripts",
"precompact-hook.mjs",
);
const APPEND_MARKER = "Operator-configured pre-compaction message";
function runHook(openrigHome: string): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync(process.execPath, [HOOK_SCRIPT], {
input: JSON.stringify({}),
encoding: "utf8",
env: {
...process.env,
OPENRIG_HOME: openrigHome,
// Ensure RIGGED_HOME doesn't pre-empt OPENRIG_HOME selection.
RIGGED_HOME: undefined,
} as NodeJS.ProcessEnv,
});
return {
stdout: result.stdout || "",
stderr: result.stderr || "",
status: result.status,
};
}
function writePolicyConfig(home: string, policy: {
enabled?: boolean;
thresholdPercent?: number;
messageInline?: string;
messageFilePath?: string;
}): void {
mkdirSync(home, { recursive: true });
writeFileSync(
join(home, "config.json"),
JSON.stringify({
policies: {
claudeCompaction: {
enabled: policy.enabled ?? false,
thresholdPercent: policy.thresholdPercent ?? 80,
messageInline: policy.messageInline ?? "",
messageFilePath: policy.messageFilePath ?? "",
},
},
}),
);
}
describe("precompact-hook.mjs (slice 27 custom message append)", () => {
let tmpDir: string;
let openrigHome: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "precompact-hook-"));
openrigHome = join(tmpDir, ".openrig");
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("HG-6: inline message is appended to systemMessage", () => {
writePolicyConfig(openrigHome, {
messageInline: "Operator says hi — remember the migration step.",
});
const { stdout, status } = runHook(openrigHome);
expect(status).toBe(0);
const payload = JSON.parse(stdout.trim());
expect(payload.continue).toBe(true);
expect(payload.systemMessage).toContain(APPEND_MARKER);
expect(payload.systemMessage).toContain("Operator says hi — remember the migration step.");
});
it("HG-7: file-path message is read + appended when inline is empty", () => {
const messageFile = join(tmpDir, "msg.txt");
writeFileSync(messageFile, "Read from disk on every compaction.");
writePolicyConfig(openrigHome, {
messageInline: "",
messageFilePath: messageFile,
});
const { stdout, status } = runHook(openrigHome);
expect(status).toBe(0);
const payload = JSON.parse(stdout.trim());
expect(payload.continue).toBe(true);
expect(payload.systemMessage).toContain(APPEND_MARKER);
expect(payload.systemMessage).toContain("Read from disk on every compaction.");
});
it("HG-8: neither inline nor file-path set → no custom append (existing restore-instructions preserved)", () => {
writePolicyConfig(openrigHome, { messageInline: "", messageFilePath: "" });
const { stdout, status } = runHook(openrigHome);
expect(status).toBe(0);
const payload = JSON.parse(stdout.trim());
expect(payload.continue).toBe(true);
expect(payload.systemMessage).not.toContain(APPEND_MARKER);
expect(payload.systemMessage).toContain("Pre-compaction restore seed packet prepared");
});
it("inline wins when both are set", () => {
const messageFile = join(tmpDir, "msg.txt");
writeFileSync(messageFile, "FILE WINS NOT");
writePolicyConfig(openrigHome, {
messageInline: "INLINE WINS",
messageFilePath: messageFile,
});
const { stdout, status } = runHook(openrigHome);
expect(status).toBe(0);
const payload = JSON.parse(stdout.trim());
expect(payload.systemMessage).toContain("INLINE WINS");
expect(payload.systemMessage).not.toContain("FILE WINS NOT");
});
it("file-path with missing file degrades gracefully (no append, no error)", () => {
writePolicyConfig(openrigHome, {
messageInline: "",
messageFilePath: join(tmpDir, "no-such-file.txt"),
});
const { stdout, status } = runHook(openrigHome);
expect(status).toBe(0);
const payload = JSON.parse(stdout.trim());
expect(payload.continue).toBe(true);
expect(payload.systemMessage).not.toContain(APPEND_MARKER);
expect(payload.systemMessage).toContain("Pre-compaction restore seed packet prepared");
});
it("missing config.json: hook still emits restore-instructions (graceful degrade)", () => {
// No config written — OPENRIG_HOME directory may not even exist.
const { stdout, status } = runHook(openrigHome);
expect(status).toBe(0);
const payload = JSON.parse(stdout.trim());
expect(payload.continue).toBe(true);
expect(payload.systemMessage).not.toContain(APPEND_MARKER);
expect(payload.systemMessage).toContain("Pre-compaction restore seed packet prepared");
});
});
@@ -70,8 +70,9 @@ describe("config routes (User Settings v0)", () => {
// 18 v0 keys + 2 Phase 4 (advisor/operator) + 5 Phase 5 (feed.subscriptions.*)
// + 2 V1 pre-release Item 1 (transcripts.lines / transcripts.poll_interval_seconds)
// + 1 plugin-primitive Phase 3a slice 3.5 (runtime.codex.hooks_enabled)
// + 1 V0.3.1 slice 05 (workspace.operator_seat_name).
expect(Object.keys(body.settings).length).toBe(29);
// + 1 V0.3.1 slice 05 (workspace.operator_seat_name)
// + 4 slice 27 (policies.claude_compaction.*) → 33 total.
expect(Object.keys(body.settings).length).toBe(33);
expect(body.settings["daemon.port"]?.source).toBe("default");
expect(body.settings["ui.preview.refresh_interval_seconds"]?.value).toBe(3);
expect(body.settings["ui.preview.max_pins"]?.value).toBe(4);
@@ -32,6 +32,12 @@ function clearEnv(): () => void {
"OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED", "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS",
"OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED", "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS",
"OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG",
"OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED",
// Slice 27 — Claude auto-compaction policy env-map.
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH",
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
"RIGGED_TRANSCRIPTS_ENABLED", "RIGGED_TRANSCRIPTS_PATH",
];
@@ -90,6 +96,11 @@ describe("SettingsStore (User Settings v0)", () => {
"feed.subscriptions.audit_log",
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
"runtime.codex.hooks_enabled",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
]);
});
@@ -234,6 +245,68 @@ describe("SettingsStore (User Settings v0)", () => {
expect(raw.workspace.slicesRoot).toBe("/x");
expect(raw.daemon.port).toBe(1234);
});
// Slice 27 — Claude auto-compaction policy resolution.
it("HG-5 + HG-10: resolveClaudeCompactionPolicy returns defaults when no file/env present (opt-in default-off)", () => {
const store = new SettingsStore(configPath);
const policy = store.resolveClaudeCompactionPolicy();
expect(policy.enabled).toBe(false);
expect(policy.thresholdPercent).toBe(80);
expect(policy.messageInline).toBe("");
expect(policy.messageFilePath).toBe("");
});
it("HG-10: resolveClaudeCompactionPolicy picks up direct config.json edits without daemon restart (single resolve call rereads file)", () => {
const store = new SettingsStore(configPath);
expect(store.resolveClaudeCompactionPolicy().enabled).toBe(false);
writeFileSync(
configPath,
JSON.stringify({
policies: {
claudeCompaction: {
enabled: true,
thresholdPercent: 65,
messageInline: "carry-forward note",
messageFilePath: "",
},
},
}),
);
const updated = store.resolveClaudeCompactionPolicy();
expect(updated.enabled).toBe(true);
expect(updated.thresholdPercent).toBe(65);
expect(updated.messageInline).toBe("carry-forward note");
expect(updated.messageFilePath).toBe("");
});
it("HG-1: set/get round-trip for each policy key persists to disk and reads back", () => {
const store = new SettingsStore(configPath);
store.set("policies.claude_compaction.enabled", "true");
store.set("policies.claude_compaction.threshold_percent", "60");
store.set("policies.claude_compaction.message_inline", "rehydrate the agent");
store.set("policies.claude_compaction.message_file_path", "/tmp/msg.txt");
const raw = JSON.parse(require("node:fs").readFileSync(configPath, "utf-8"));
expect(raw.policies.claudeCompaction.enabled).toBe(true);
expect(raw.policies.claudeCompaction.thresholdPercent).toBe(60);
expect(raw.policies.claudeCompaction.messageInline).toBe("rehydrate the agent");
expect(raw.policies.claudeCompaction.messageFilePath).toBe("/tmp/msg.txt");
expect(store.resolveOne("policies.claude_compaction.enabled").value).toBe(true);
expect(store.resolveOne("policies.claude_compaction.threshold_percent").value).toBe(60);
expect(store.resolveOne("policies.claude_compaction.message_inline").value).toBe("rehydrate the agent");
expect(store.resolveOne("policies.claude_compaction.message_file_path").value).toBe("/tmp/msg.txt");
});
it("HG-1: invalid threshold rejected by coerceValue (non-numeric raises)", () => {
const store = new SettingsStore(configPath);
expect(() => store.set("policies.claude_compaction.threshold_percent", "not-a-number")).toThrow(/expected a number/);
});
void existsSync;
});
describe("parseNamedPairs (daemon copy)", () => {
@@ -0,0 +1,239 @@
// Slice 27 — Claude Auto-Compaction Policy form.
//
// Operator-configurable pre-compaction trigger: when a Claude seat's
// context usage crosses `threshold_percent`, the daemon's ContextMonitor
// dispatches `/compact` via SessionTransport; the existing PreCompact
// hook injects the operator's `message_inline` (or contents of
// `message_file_path`) alongside the standard restore-instructions.
//
// Opt-in default-off. Empty inline + file path = standard restore behavior
// preserved. Designed to land on the slice 26 `/settings/policies` route
// once the route shell ships; while slice 26 is in flight, the form is
// also reachable from a temporary mount at `/settings/policies` here.
import { useState } from "react";
import type { FormEvent } from "react";
import { SectionHeader } from "../ui/section-header.js";
import { useSettings, useSetSetting } from "../../hooks/useSettings.js";
type FormState = {
enabled: boolean;
thresholdPercent: number;
messageInline: string;
messageFilePath: string;
};
const KEY_ENABLED = "policies.claude_compaction.enabled" as const;
const KEY_THRESHOLD = "policies.claude_compaction.threshold_percent" as const;
const KEY_INLINE = "policies.claude_compaction.message_inline" as const;
const KEY_FILE_PATH = "policies.claude_compaction.message_file_path" as const;
function coerceBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return fallback;
}
function coerceNumber(value: unknown, fallback: number): number {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim().length > 0) {
const n = Number(value);
if (!Number.isNaN(n)) return n;
}
return fallback;
}
function coerceString(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
return String(value);
}
export function ClaudeCompactionPolicyForm() {
const { data, isLoading, error } = useSettings();
const setSetting = useSetSetting();
if (!data) {
return (
<section
data-testid="claude-compaction-policy-form"
className="border border-outline-variant p-5 bg-white/50"
>
<SectionHeader tone="muted">Policy</SectionHeader>
<h2 className="font-headline text-headline-sm font-bold tracking-tight uppercase text-stone-900 mt-1">
Claude Auto-Compaction
</h2>
{isLoading && (
<p className="mt-4 text-sm text-on-surface-variant" data-testid="claude-compaction-policy-loading">
Loading current settings…
</p>
)}
{error && (
<p className="mt-4 text-sm text-error" data-testid="claude-compaction-policy-error">
{error instanceof Error ? error.message : String(error)}
</p>
)}
</section>
);
}
return <PolicyFormBody data={data.settings} setSetting={setSetting} />;
}
interface PolicyFormBodyProps {
data: Record<string, { value: unknown }>;
setSetting: ReturnType<typeof useSetSetting>;
}
function PolicyFormBody({ data, setSetting }: PolicyFormBodyProps) {
const [form, setForm] = useState<FormState>(() => ({
enabled: coerceBoolean(data[KEY_ENABLED]?.value, false),
thresholdPercent: coerceNumber(data[KEY_THRESHOLD]?.value, 80),
messageInline: coerceString(data[KEY_INLINE]?.value),
messageFilePath: coerceString(data[KEY_FILE_PATH]?.value),
}));
const [thresholdError, setThresholdError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitOk, setSubmitOk] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitOk(false);
setSubmitError(null);
if (form.thresholdPercent < 1 || form.thresholdPercent > 100 || !Number.isInteger(form.thresholdPercent)) {
setThresholdError("Threshold must be an integer between 1 and 100.");
return;
}
setThresholdError(null);
const updates: Array<{ key: Parameters<typeof setSetting.mutateAsync>[0]["key"]; value: string }> = [
{ key: KEY_ENABLED, value: form.enabled ? "true" : "false" },
{ key: KEY_THRESHOLD, value: String(form.thresholdPercent) },
{ key: KEY_INLINE, value: form.messageInline },
{ key: KEY_FILE_PATH, value: form.messageFilePath },
];
try {
for (const update of updates) {
await setSetting.mutateAsync(update);
}
setSubmitOk(true);
} catch (err) {
setSubmitError(err instanceof Error ? err.message : String(err));
}
}
return (
<section
data-testid="claude-compaction-policy-form"
className="border border-outline-variant p-5 bg-white/50"
>
<SectionHeader tone="muted">Policy</SectionHeader>
<h2 className="font-headline text-headline-sm font-bold tracking-tight uppercase text-stone-900 mt-1">
Claude Auto-Compaction
</h2>
<p className="mt-2 text-sm text-on-surface-variant max-w-prose">
When a Claude seat's context usage crosses the configured threshold,
OpenRig sends <code className="font-mono text-[12px]">/compact</code> to the seat.
The existing pre-compaction hook continues to emit the standard
restore packet; the optional custom message below is appended to the
post-compaction system message.
</p>
<form
className="mt-4 flex flex-col gap-5"
onSubmit={handleSubmit}
data-testid="claude-compaction-policy-form-element"
>
<label className="inline-flex items-center gap-2 text-sm text-stone-900">
<input
type="checkbox"
data-testid="claude-compaction-enabled"
checked={form.enabled}
onChange={(e) => setForm((s) => ({ ...s, enabled: e.target.checked }))}
/>
<span>Enable automatic pre-compaction trigger</span>
</label>
<div className="flex flex-col gap-1">
<label htmlFor="claude-compaction-threshold" className="text-sm font-medium text-stone-900">
Threshold percentage
</label>
<input
id="claude-compaction-threshold"
data-testid="claude-compaction-threshold"
type="number"
step={1}
value={form.thresholdPercent}
onChange={(e) => setForm((s) => ({ ...s, thresholdPercent: Number(e.target.value) }))}
className="border border-outline-variant px-2 py-1 w-32 font-mono text-sm"
aria-describedby="claude-compaction-threshold-hint"
/>
<span className="text-xs text-on-surface-variant">
Fires when context usage at-or-above this percentage (1–100).
</span>
{thresholdError && (
<span className="text-xs text-error" data-testid="claude-compaction-threshold-error">
{thresholdError}
</span>
)}
</div>
<div className="flex flex-col gap-1">
<label htmlFor="claude-compaction-inline" className="text-sm font-medium text-stone-900">
Custom message (inline)
</label>
<textarea
id="claude-compaction-inline"
data-testid="claude-compaction-message-inline"
rows={4}
value={form.messageInline}
onChange={(e) => setForm((s) => ({ ...s, messageInline: e.target.value }))}
placeholder="Optional. Appended to the restore-instructions in the post-compaction system message."
className="border border-outline-variant px-2 py-1 font-mono text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="claude-compaction-file" className="text-sm font-medium text-stone-900">
Custom message (file path)
</label>
<input
id="claude-compaction-file"
data-testid="claude-compaction-message-file-path"
type="text"
value={form.messageFilePath}
onChange={(e) => setForm((s) => ({ ...s, messageFilePath: e.target.value }))}
placeholder="Optional. Path read at hook-fire time; ignored when inline is set."
className="border border-outline-variant px-2 py-1 font-mono text-sm"
/>
<span className="text-xs text-on-surface-variant">
When both fields are set, inline wins. When neither is set, only
the standard restore packet is injected.
</span>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
data-testid="claude-compaction-policy-submit"
disabled={setSetting.isPending}
className="border border-outline px-4 py-2 bg-stone-900 text-white font-medium text-sm disabled:opacity-60"
>
{setSetting.isPending ? "Saving…" : "Save policy"}
</button>
{submitOk && (
<span className="text-sm text-success" data-testid="claude-compaction-policy-saved">
Saved.
</span>
)}
{submitError && (
<span className="text-sm text-error" data-testid="claude-compaction-policy-submit-error">
{submitError}
</span>
)}
</div>
</form>
</section>
);
}
@@ -0,0 +1,31 @@
// Slice 27 — Policies page wrapper.
//
// The slice 26 spec lands `/settings/policies` as the destination route for
// operator-tunable policies; slice 27 provides the first form (Claude
// auto-compaction). Until slice 26 merges, this page is reachable directly
// at /settings/policies via the route added in this slice; on integration,
// slice 26's Policies tab/explorer is expected to host the same component
// without further changes.
import { SectionHeader } from "../ui/section-header.js";
import { ClaudeCompactionPolicyForm } from "./ClaudeCompactionPolicyForm.js";
export function PoliciesPage() {
return (
<div data-testid="policies-page" className="mx-auto w-full max-w-[960px] px-6 py-8">
<div className="border-b border-outline-variant pb-4 mb-4">
<SectionHeader tone="muted">Settings</SectionHeader>
<h1 className="font-headline text-headline-md font-bold tracking-tight uppercase text-stone-900 mt-1">
Policies
</h1>
<p className="mt-2 text-sm text-on-surface-variant max-w-prose">
Opt-in policies that affect agent runtime behavior. Each policy
ships disabled by default and can be turned on independently.
</p>
</div>
<div className="flex flex-col gap-6">
<ClaudeCompactionPolicyForm />
</div>
</div>
);
}
+6 -1
View File
@@ -34,7 +34,12 @@ export type SettingsKey =
| "feed.subscriptions.approvals"
| "feed.subscriptions.shipped"
| "feed.subscriptions.progress"
| "feed.subscriptions.audit_log";
| "feed.subscriptions.audit_log"
// Slice 27 — Claude auto-compaction policy keys (SC-29 EXCEPTION #10).
| "policies.claude_compaction.enabled"
| "policies.claude_compaction.threshold_percent"
| "policies.claude_compaction.message_inline"
| "policies.claude_compaction.message_file_path";
export interface SettingsResponse {
settings: Record<SettingsKey, ResolvedSetting>;
+16
View File
@@ -47,6 +47,9 @@ import { PluginsIndexPage } from "./components/specs/PluginsIndexPage.js";
import { PluginDetailPage } from "./components/specs/PluginDetailPage.js";
import { FilesWorkspace } from "./components/files/FilesWorkspace.js";
import { SettingsCenter } from "./components/system/SettingsCenter.js";
// Slice 27 — Policies page (temporary mount at /settings/policies until
// slice 26's Settings explorer ships the canonical route shell).
import { PoliciesPage } from "./components/system/PoliciesPage.js";
import {
HostScopePage,
RigScopePage,
@@ -216,6 +219,18 @@ const settingsRoute = createRoute({
component: SettingsCenter,
});
// Slice 27 — Policies page; mounts the Claude auto-compaction form. The
// route is a temporary integration point until slice 26's Settings
// explorer ships the canonical Policies destination — once it does, this
// route either delegates to the slice 26 shell or is removed in favor of
// slice 26's mount; either way, the form component (ClaudeCompactionPolicyForm)
// stays at its current path.
const settingsPoliciesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/settings/policies",
component: PoliciesPage,
});
const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/search",
@@ -438,6 +453,7 @@ const routeTree = rootRoute.addChildren([
filesRoute,
specsKindRoute,
settingsRoute,
settingsPoliciesRoute,
searchRoute,
projectGraphicsPreviewRoute,
cardPreviewsLabRoute,
@@ -0,0 +1,153 @@
// Slice 27 — Claude Compaction Policy form tests.
//
// HG-9: form renders + persists settings.
// Also covers:
// - opt-in default-off rendering (enabled toggle reflects current value)
// - threshold validation rejects out-of-range values without persisting
// - submit issues one POST per key against /api/config/:key
// - shows "Saved." indicator after successful submit
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { createTestRouter } from "./helpers/test-router.js";
import { ClaudeCompactionPolicyForm } from "../src/components/system/ClaudeCompactionPolicyForm.js";
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockReset();
});
afterEach(() => cleanup());
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}) as unknown as Response;
}
function makeSettingsResponse(overrides: Partial<{
enabled: boolean;
thresholdPercent: number;
messageInline: string;
messageFilePath: string;
}> = {}) {
return {
settings: {
"policies.claude_compaction.enabled": {
value: overrides.enabled ?? false,
source: "default",
defaultValue: false,
},
"policies.claude_compaction.threshold_percent": {
value: overrides.thresholdPercent ?? 80,
source: "default",
defaultValue: 80,
},
"policies.claude_compaction.message_inline": {
value: overrides.messageInline ?? "",
source: "default",
defaultValue: "",
},
"policies.claude_compaction.message_file_path": {
value: overrides.messageFilePath ?? "",
source: "default",
defaultValue: "",
},
},
};
}
describe("ClaudeCompactionPolicyForm — slice 27", () => {
it("HG-9: renders form with current settings populated (opt-in default-off shows disabled toggle, 80% threshold)", async () => {
mockFetch.mockResolvedValue(jsonResponse(makeSettingsResponse()));
render(createTestRouter({ component: () => <ClaudeCompactionPolicyForm />, path: "/" }));
// Form body waits for data; assert on input presence to confirm data
// has loaded before reading values.
await waitFor(() => expect(screen.getByTestId("claude-compaction-enabled")).toBeDefined());
const enabled = screen.getByTestId("claude-compaction-enabled") as HTMLInputElement;
expect(enabled.checked).toBe(false);
const threshold = screen.getByTestId("claude-compaction-threshold") as HTMLInputElement;
expect(threshold.value).toBe("80");
const inline = screen.getByTestId("claude-compaction-message-inline") as HTMLTextAreaElement;
expect(inline.value).toBe("");
const filePath = screen.getByTestId("claude-compaction-message-file-path") as HTMLInputElement;
expect(filePath.value).toBe("");
});
it("HG-9: loads existing non-default values from /api/config when present", async () => {
mockFetch.mockResolvedValue(jsonResponse(makeSettingsResponse({
enabled: true,
thresholdPercent: 65,
messageInline: "stay calm",
messageFilePath: "/tmp/m.txt",
})));
render(createTestRouter({ component: () => <ClaudeCompactionPolicyForm />, path: "/" }));
await waitFor(() => {
expect((screen.getByTestId("claude-compaction-enabled") as HTMLInputElement).checked).toBe(true);
});
expect((screen.getByTestId("claude-compaction-threshold") as HTMLInputElement).value).toBe("65");
expect((screen.getByTestId("claude-compaction-message-inline") as HTMLTextAreaElement).value).toBe("stay calm");
expect((screen.getByTestId("claude-compaction-message-file-path") as HTMLInputElement).value).toBe("/tmp/m.txt");
});
it("HG-9: submit POSTs each key to /api/config/:key with the user's value", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
mockFetch.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(url), init });
if (init?.method === "POST") {
return jsonResponse({ ok: true });
}
return jsonResponse(makeSettingsResponse());
});
render(createTestRouter({ component: () => <ClaudeCompactionPolicyForm />, path: "/" }));
await waitFor(() => expect(screen.getByTestId("claude-compaction-enabled")).toBeDefined());
fireEvent.click(screen.getByTestId("claude-compaction-enabled"));
fireEvent.change(screen.getByTestId("claude-compaction-threshold"), { target: { value: "70" } });
fireEvent.change(screen.getByTestId("claude-compaction-message-inline"), {
target: { value: "Reload the slice doc before resuming." },
});
expect((screen.getByTestId("claude-compaction-enabled") as HTMLInputElement).checked).toBe(true);
expect((screen.getByTestId("claude-compaction-threshold") as HTMLInputElement).value).toBe("70");
fireEvent.click(screen.getByTestId("claude-compaction-policy-submit"));
await waitFor(() => {
expect(calls.filter((c) => c.init?.method === "POST").length).toBeGreaterThanOrEqual(4);
});
const postCalls = calls.filter((c) => c.init?.method === "POST");
const findKey = (key: string) => postCalls.find((c) => c.url.includes(encodeURIComponent(key)));
expect(findKey("policies.claude_compaction.enabled")?.init?.body as string).toContain("true");
expect(findKey("policies.claude_compaction.threshold_percent")?.init?.body as string).toContain("70");
expect(findKey("policies.claude_compaction.message_inline")?.init?.body as string).toContain("Reload the slice doc before resuming.");
// message_file_path posted even when empty (clears any prior value).
expect(findKey("policies.claude_compaction.message_file_path")).toBeDefined();
await waitFor(() => expect(screen.getByTestId("claude-compaction-policy-saved")).toBeDefined());
});
it("HG-9: threshold validation rejects out-of-range values without issuing POSTs", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
mockFetch.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(url), init });
return jsonResponse(makeSettingsResponse());
});
render(createTestRouter({ component: () => <ClaudeCompactionPolicyForm />, path: "/" }));
await waitFor(() => expect(screen.getByTestId("claude-compaction-enabled")).toBeDefined());
fireEvent.change(screen.getByTestId("claude-compaction-threshold"), { target: { value: "101" } });
fireEvent.click(screen.getByTestId("claude-compaction-policy-submit"));
await waitFor(() => expect(screen.getByTestId("claude-compaction-threshold-error")).toBeDefined());
expect(calls.filter((c) => c.init?.method === "POST")).toEqual([]);
});
});