plugin-primitive Phase 3a slice 3.3 fix-iteration: rig-cwd id self-resolve

redo-guard-2 BLOCK on 965398a raised 2 items. Forward-fix on both per
banked discipline (TDD red→green; loop redo-guard-2 for re-verify).

═══ Item 1 (functional): rig-cwd: id list→detail 404 ═══

Symptom: `GET /api/plugins?cwd=<path>` returns rig-cwd plugin ids;
`GET /api/plugins/<encoded-id>` 404s because
`PluginDiscoveryService.getPlugin(id)` calls `listPlugins()` with no
opts — cwdScanRoots defaults to empty, so the rig-cwd plugin isn't in
the list and `find((p) => p.id === id)` returns undefined → 404.

Fix: `getPlugin(id)` self-resolves rig-cwd: ids by parsing the cwd
out of the id prefix (constructed in `scanCwdBundledPlugins` as
`rig-cwd:<cwd>/.claude/plugins/<plugin>` or
`rig-cwd:<cwd>/.codex/plugins/<plugin>`). New helper
`extractCwdFromRigCwdId(id)` returns `[cwd]` for valid rig-cwd: ids
and `null` otherwise. Same shape as the other source kinds where
the id already encodes everything needed to re-resolve — keeps the
detail-call contract self-contained without adding `?cwd=` to the
detail endpoint.

Coverage added:
- 3 daemon-service tests: claude roundtrip, codex roundtrip, malformed
  id negative.
- 1 daemon-routes test: list-with-?cwd= → detail roundtrip through the
  HTTP layer (would have caught the original 404).

═══ Item 2 (process): ACK provenance stale ═══

The 965398a fix-ack had `(TBD at commit)` and `7 files / +~200` for
its net-diff line. Actual `git diff --stat 695ca74..965398a` is
**9 files / +448 / -9**. ACK §2 corrected; fix-iteration section
added at §9 covering both items.

═══ Verification ═══

- 62 tests across 7 files all green (was 58 at 965398a; +4 fix-iter
  coverage: 3 daemon-service + 1 daemon-routes).
- Daemon tsc clean.
- UI tsc clean for touched files (pre-existing @tanstack/react-table
  baseline on main c0fcccd unchanged).
- SC-29 #8 boundary unchanged (service-internal id-parse; no new
  state, no new endpoint, no mutation).

Substrate fix-ack updated at <work-tree>/missions/plugin-primitive/
attempt-1-redo-driver-2-phase-3a-batch-2-slice-3-3-fix-ack.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-10 17:42:21 -07:00
1 parent 965398afa1
commit 995d28f120
3 files changed
+138 -1

No files matched your search

@@ -295,7 +295,18 @@ export class PluginDiscoveryService {
}
getPlugin(id: string): PluginDetail | null {
const entry = this.listPlugins().find((p) => p.id === id);
// Slice 3.3 fix-iteration — rig-cwd: IDs are self-resolvable.
// Pre-fix, getPlugin called this.listPlugins() (no opts), which
// excluded rig-cwd entries because cwdScanRoots is empty by default.
// Result: /api/plugins?cwd= returned a rig-cwd id, /api/plugins/:id
// 404'd on the same id (redo-guard-2 BLOCK item 1). Fix: parse the
// cwd out of the rig-cwd: prefix and pass it as cwdScanRoots so
// the entry is in the list. ID format constructed in
// scanCwdBundledPlugins:
// rig-cwd:<cwd>/.claude/plugins/<plugin>
// rig-cwd:<cwd>/.codex/plugins/<plugin>
const cwdScanRoots = extractCwdFromRigCwdId(id);
const entry = this.listPlugins(cwdScanRoots ? { cwdScanRoots } : {}).find((p) => p.id === id);
if (!entry) return null;
const claudeManifestPath = join(entry.path, CLAUDE_MANIFEST_REL);
@@ -416,6 +427,31 @@ export class PluginDiscoveryService {
}
}
// Slice 3.3 fix-iteration — parse cwd from a rig-cwd: id so getPlugin
// can re-scan that cwd before the lookup. Returns the cwd in a
// single-element array (caller passes as cwdScanRoots) or null when
// the id is not a rig-cwd: id, can't be parsed, or both manifest dir
// markers are absent. Tolerant of both `/.claude/plugins/` and
// `/.codex/plugins/` markers (whichever appears first wins; in the
// canonical id one of them is always present).
const RIG_CWD_PREFIX = "rig-cwd:";
const CLAUDE_MARKER = "/.claude/plugins/";
const CODEX_MARKER = "/.codex/plugins/";
function extractCwdFromRigCwdId(id: string): string[] | null {
if (!id.startsWith(RIG_CWD_PREFIX)) return null;
const rest = id.slice(RIG_CWD_PREFIX.length);
const claudeIdx = rest.indexOf(CLAUDE_MARKER);
const codexIdx = rest.indexOf(CODEX_MARKER);
let cwd: string | null = null;
if (claudeIdx >= 0 && (codexIdx < 0 || claudeIdx < codexIdx)) {
cwd = rest.slice(0, claudeIdx);
} else if (codexIdx >= 0) {
cwd = rest.slice(0, codexIdx);
}
if (!cwd) return null;
return [cwd];
}
function safeReaddir(dir: string): string[] {
try {
return readdirSync(dir);
@@ -367,6 +367,74 @@ describe("PluginDiscoveryService", () => {
expect(service.getPlugin("openrig-no-mcp")?.mcpServers).toEqual([]);
});
it("slice 3.3 fix-iteration — getPlugin self-resolves rig-cwd: IDs (claude side) without external cwd state", () => {
// redo-guard-2 BLOCK item 1: /api/plugins?cwd=... lists rig-cwd
// plugins but /api/plugins/:id detail call 404s because listPlugins()
// without cwdScanRoots doesn't re-scan the cwd, so getPlugin can't
// find the entry by id. Fix: parse the cwd out of the rig-cwd: id
// prefix, re-scan that cwd, resolve the entry. ID format:
// rig-cwd:<cwd>/.claude/plugins/<plugin>
// rig-cwd:<cwd>/.codex/plugins/<plugin>
const rigCwd = join(dirs.root, "rig-cwd-self-resolve");
const claudeBundleDir = join(rigCwd, ".claude", "plugins");
mkdirSync(claudeBundleDir, { recursive: true });
writeClaudePluginManifest(join(claudeBundleDir, "rig-tool"), {
name: "rig-tool",
version: "1.0.0",
description: "rig-bundled tool",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const listed = service.listPlugins({ cwdScanRoots: [rigCwd] });
const rigToolEntry = listed.find((p) => p.source === "rig-cwd");
expect(rigToolEntry).toBeDefined();
const rigToolId = rigToolEntry!.id;
// Call getPlugin with that id directly (no cwd opts) — must resolve.
const detail = service.getPlugin(rigToolId);
expect(detail).not.toBeNull();
expect(detail?.entry.name).toBe("rig-tool");
expect(detail?.entry.source).toBe("rig-cwd");
});
it("slice 3.3 fix-iteration — getPlugin self-resolves rig-cwd: codex IDs too", () => {
const rigCwd = join(dirs.root, "rig-cwd-self-resolve-codex");
const codexBundleDir = join(rigCwd, ".codex", "plugins");
mkdirSync(codexBundleDir, { recursive: true });
writeCodexPluginManifest(join(codexBundleDir, "codex-tool"), {
name: "codex-tool",
version: "2.0.0",
description: "rig-bundled codex tool",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const listed = service.listPlugins({ cwdScanRoots: [rigCwd] });
const codexToolEntry = listed.find((p) => p.name === "codex-tool");
expect(codexToolEntry).toBeDefined();
const detail = service.getPlugin(codexToolEntry!.id);
expect(detail).not.toBeNull();
expect(detail?.entry.name).toBe("codex-tool");
});
it("slice 3.3 fix-iteration — getPlugin returns null for malformed rig-cwd: ids", () => {
// Negative: garbled prefix or unresolvable cwd → null (not throw).
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
expect(service.getPlugin("rig-cwd:notapath")).toBeNull();
expect(service.getPlugin("rig-cwd:/nonexistent/.claude/plugins/x")).toBeNull();
});
it("returns full manifest + tree summary for a discovered plugin", () => {
const corePluginDir = join(dirs.openrigPluginsDir, "openrig-core");
writeClaudePluginManifest(corePluginDir, {
@@ -161,6 +161,39 @@ describe("plugins HTTP routes", () => {
expect(rigCwdSubset[0]?.name).toBe("rig-tool");
});
it("slice 3.3 fix-iteration — list ?cwd= + detail roundtrip for rig-cwd plugin (no 404)", async () => {
// redo-guard-2 BLOCK item 1: /api/plugins?cwd= lists;
// /api/plugins/<encoded-rig-cwd-id> must 200 not 404. Fix lands in
// PluginDiscoveryService.getPlugin via self-resolve from id prefix.
const rigCwd = join(env.root, "rig-cwd-roundtrip");
const claudeBundleDir = join(rigCwd, ".claude", "plugins");
mkdirSync(claudeBundleDir, { recursive: true });
writeClaudePluginManifest(join(claudeBundleDir, "roundtrip-tool"), {
name: "roundtrip-tool",
version: "1.0.0",
description: "roundtrip",
});
// Step 1: list with ?cwd= — captures id.
const listRes = await createApp(env.service).request(
`/api/plugins?cwd=${encodeURIComponent(rigCwd)}`,
);
expect(listRes.status).toBe(200);
const list = await listRes.json() as Array<{ id: string; source: string }>;
const rigCwdEntry = list.find((p) => p.source === "rig-cwd");
expect(rigCwdEntry).toBeDefined();
const rigCwdId = rigCwdEntry!.id;
// Step 2: detail call with that id — must 200 (was 404 pre-fix).
const detailRes = await createApp(env.service).request(
`/api/plugins/${encodeURIComponent(rigCwdId)}`,
);
expect(detailRes.status).toBe(200);
const detail = await detailRes.json() as { entry: { name: string; source: string } };
expect(detail.entry.name).toBe("roundtrip-tool");
expect(detail.entry.source).toBe("rig-cwd");
});
it("supports source filter via query string", async () => {
writeClaudePluginManifest(join(env.openrigPluginsDir, "vended"), {
name: "vended",