From 9c0cfcd61ef2a32f4e8e0f222d7a06fbdb2c2afd Mon Sep 17 00:00:00 2001 From: v-openrig-build Date: Fri, 7 Aug 2026 11:31:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(projection):=20P20=20atom-3b=20=E2=80=94?= =?UTF-8?q?=20record-at-apply=20on=20skill=5Finstall=20(manifest=20write?= =?UTF-8?q?=20side)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/adapters/claude-code-adapter.ts | 13 +- packages/daemon/src/startup.ts | 7 +- .../claude-adapter-record-projection.test.ts | 116 ++++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 packages/daemon/test/claude-adapter-record-projection.test.ts diff --git a/packages/daemon/src/adapters/claude-code-adapter.ts b/packages/daemon/src/adapters/claude-code-adapter.ts index 96b6e51f0..5f9ca5dc2 100644 --- a/packages/daemon/src/adapters/claude-code-adapter.ts +++ b/packages/daemon/src/adapters/claude-code-adapter.ts @@ -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 { @@ -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": { diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index 5929f8f6a..882abcf16 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -571,12 +571,17 @@ export async function createDaemon(opts?: DaemonOptions): Promise 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. diff --git a/packages/daemon/test/claude-adapter-record-projection.test.ts b/packages/daemon/test/claude-adapter-record-projection.test.ts new file mode 100644 index 000000000..ab6fb4373 --- /dev/null +++ b/packages/daemon/test/claude-adapter-record-projection.test.ts @@ -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): ClaudeAdapterFsOps & { _store: Record } { + const store: Record = { ...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 }; +} + +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.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); + }); +});