mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user