feat(projection): P20 atom-3b — record-at-apply on skill_install (manifest write side)

The discrimination (conflict-detector) + enable path (rigspec-instantiator
wiring pin) are inert unless the projector records what it wrote. This wires
the WRITE side: ClaudeCodeAdapter gains an optional recordProjection dep,
invoked at the skill_install write with the exact target + content it wrote;
startup constructs a ProjectionManifestStore on db and feeds
record({targetPath, lastHash: hashContent(content), writtenAt}).

Non-recorded targets safe-degrade to P17 (lookup null -> hash_conflict).
RED-first: record test fails with the call removed (recorded=0), passes with
it; a send_text delivery records nothing (not a projection).

daemon tsc clean; P20 suite 35/35.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-07 12:23:42 +00:00
1 parent 6827fd88ad
commit 9c0cfcd61e
3 files changed
+134 -2

No files matched your search

@@ -56,6 +56,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
private autoDriveProviderPrompts: boolean;
private activityRelayPath: string | null;
private claudeHooksManifestPath: string | null;
/** P20 — called after a projected file is written to a target, so the manifest
* records what we last wrote (→ operator-vs-stale discrimination). No-op by default. */
private recordProjection: (targetPath: string, content: string) => void;
constructor(deps: {
tmux: TmuxAdapter;
@@ -70,6 +73,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
/** DI source of the canonical claude.json hooks manifest — the event vocabulary
* is derived (filtered to relay events) from it, not a parallel constant. */
claudeHooksManifestPath?: string;
/** P20 — record-at-apply hook (startup wires it to the projection manifest store).
* Absent → no-op (the manifest stays empty → discrimination safe-degrades to P17). */
recordProjection?: (targetPath: string, content: string) => void;
}) {
this.tmux = deps.tmux;
this.fs = deps.fsOps;
@@ -80,6 +86,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
this.autoDriveProviderPrompts = deps.autoDriveProviderPrompts ?? false;
this.activityRelayPath = deps.activityRelayPath ?? null;
this.claudeHooksManifestPath = deps.claudeHooksManifestPath ?? null;
this.recordProjection = deps.recordProjection ?? (() => {});
}
async listInstalled(binding: NodeBinding): Promise<InstalledResource[]> {
@@ -173,7 +180,11 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
case "skill_install": {
const targetDir = nodePath.join(binding.cwd, ".claude", "skills", nodePath.basename(nodePath.dirname(file.absolutePath)));
this.fs.mkdirp(targetDir);
this.fs.writeFile(nodePath.join(targetDir, nodePath.basename(file.path)), content);
const skillTarget = nodePath.join(targetDir, nodePath.basename(file.path));
this.fs.writeFile(skillTarget, content);
// P20 — record what we just wrote so the next projection can tell a
// stale re-projection (safe overwrite) from an operator edit (protect).
this.recordProjection(skillTarget, content);
break;
}
case "send_text": {
+6 -1
View File
@@ -571,12 +571,17 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const { PodRigInstantiator } = await import("./domain/rigspec-instantiator.js");
const { StartupOrchestrator } = await import("./domain/startup-orchestrator.js");
const { ClaudeCodeAdapter } = await import("./adapters/claude-code-adapter.js");
// P20 — the projection manifest: record-at-apply so the projector can later
// discriminate a stale re-projection (safe overwrite) from an operator edit (protect).
const { ProjectionManifestStore } = await import("./domain/projection-manifest-store.js");
const { hashContent } = await import("./domain/conflict-detector.js");
const projectionManifestStore = new ProjectionManifestStore(db);
const { CodexRuntimeAdapter } = await import("./adapters/codex-runtime-adapter.js");
const { PiRuntimeAdapter } = await import("./adapters/pi-runtime-adapter.js");
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter, readFile: (p: string) => fs.readFileSync(p, "utf-8") });
const runtimeSettings = new ContextPackSettingsStore().resolveConfig();
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: (src: string, dest: string) => fs.copyFileSync(src, dest), listFiles: (dir: string) => { const r: string[] = []; function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json") });
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: (src: string, dest: string) => fs.copyFileSync(src, dest), listFiles: (dir: string) => { const r: string[] = []; function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const codexAdapter = new CodexRuntimeAdapter({ tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs") });
// OPR.0.4.6.PI1 — the RPC-first Pi adapter (runner-in-a-pane). Same fsOps
// shape as the Codex adapter; seat isolation roots under piStateRoot.
@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from "vitest";
import { ClaudeCodeAdapter, type ClaudeAdapterFsOps } from "../src/adapters/claude-code-adapter.js";
import type { NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
// P20 atom 3b — RECORD-AT-APPLY. The discrimination (conflict-detector) and the
// enable path (rigspec-instantiator wiring pin) are inert unless the projector
// actually records what it wrote. This pins the WRITE side: when the adapter
// installs a skill file, it must feed the manifest the exact target + content it
// wrote, so a later projection can classify stale_overwrite vs operator_conflict.
// A drop of the recordProjection call reverts every target to P17-fallback-forever
// (lookup null → hash_conflict), a silent dead invalidator — so this fails RED there.
function mockTmux(): TmuxAdapter {
return {
sendText: vi.fn(async () => ({ ok: true as const })),
hasSession: vi.fn(async () => true),
getPaneCommand: vi.fn(async () => "claude"),
capturePaneContent: vi.fn(async () => ""),
createSession: vi.fn(async () => ({ ok: true as const })),
killSession: vi.fn(async () => ({ ok: true as const })),
listSessions: vi.fn(async () => []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => []),
sendKeys: vi.fn(async () => ({ ok: true as const })),
} as unknown as TmuxAdapter;
}
function mockFs(files?: Record<string, string>): ClaudeAdapterFsOps & { _store: Record<string, string> } {
const store: Record<string, string> = { ...files };
return {
readFile: (p: string) => {
if (p in store) return store[p]!;
throw new Error(`Not found: ${p}`);
},
writeFile: (p: string, c: string) => {
store[p] = c;
},
exists: (p: string) => p in store,
mkdirp: () => {},
copyFile: () => {},
listFiles: (dir: string) => Object.keys(store).filter((k) => k.startsWith(dir + "/")).map((k) => k.slice(dir.length + 1)),
_store: store,
} as ClaudeAdapterFsOps & { _store: Record<string, string> };
}
function makeBinding(cwd = "/project"): NodeBinding {
return {
id: "b1",
nodeId: "n1",
tmuxSession: "r01-impl",
tmuxWindow: null,
tmuxPane: null,
cmuxWorkspace: null,
cmuxSurface: null,
updatedAt: "",
cwd,
} as NodeBinding;
}
function skillFile(): ResolvedStartupFile {
return {
path: "SKILL.md",
absolutePath: "/src/skills/my-skill/SKILL.md",
ownerRoot: "/src",
deliveryHint: "skill_install",
required: true,
appliesOn: ["fresh_start"],
kind: "file",
};
}
describe("P20 record-at-apply — the adapter records the manifest on skill_install", () => {
it("records the exact target path + content it wrote", async () => {
const CONTENT = "# my-skill\nprojected body v1\n";
const recorded: Array<{ target: string; content: string }> = [];
const adapter = new ClaudeCodeAdapter({
tmux: mockTmux(),
fsOps: mockFs({ "/src/skills/my-skill/SKILL.md": CONTENT }),
recordProjection: (target: string, content: string) => recorded.push({ target, content }),
});
await adapter.deliverStartup([skillFile()], makeBinding("/project"));
expect(recorded).toHaveLength(1);
// target == the actual write path (cwd/.claude/skills/<skill-dir>/SKILL.md),
// which is exactly what a later projection will classify.
expect(recorded[0]!.target).toBe("/project/.claude/skills/my-skill/SKILL.md");
// content == the bytes written (so hashContent(recorded) == the target's future hash).
expect(recorded[0]!.content).toBe(CONTENT);
});
it("does not record when there is no skill to install (send_text is not a projection)", async () => {
const recorded: Array<{ target: string; content: string }> = [];
const adapter = new ClaudeCodeAdapter({
tmux: mockTmux(),
fsOps: mockFs({ "/src/prompt.txt": "hello" }),
recordProjection: (target: string, content: string) => recorded.push({ target, content }),
});
await adapter.deliverStartup(
[{
path: "prompt.txt",
absolutePath: "/src/prompt.txt",
ownerRoot: "/src",
deliveryHint: "send_text",
required: false,
appliesOn: ["fresh_start"],
kind: "file",
}],
makeBinding("/project"),
);
expect(recorded).toHaveLength(0);
});
});