diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fc984e63..ea28d88c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,6 +170,14 @@ claude plugin update oh-my-claudecode@oh-my-claudecode /setup ``` +**Known limitation**: the plugin cache copy runs at the version you last +updated through the marketplace — hooks (including the SessionEnd chain +enqueuer) execute the cached code, not your checkout. If session-end behavior +looks stale after rebuilding, re-run `claude plugin update` first. OMC already +mitigates dangling references to older cache versions at setup time via +`scripts/repair-plugin-cache.mjs` (registry rewrite + symlink to the latest +valid cache root), but it does not re-copy content into the cache. + ### Flow C: `omc setup --no-plugin` (fallback, bundled skills) **Advantages**: Forces local bundled skills to `~/.claude/skills/`, no plugin system. diff --git a/inventory/inventory-graph.json b/inventory/inventory-graph.json index 1ec61f8f2..b88feed09 100644 --- a/inventory/inventory-graph.json +++ b/inventory/inventory-graph.json @@ -5,15 +5,15 @@ "provenance": { "base": "05c800f40d1ad53b42a78609d2667ef4f726808b", "planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398", - "head": "b5f923fc326c26344b8288dbc0b2c688cb33c9f1", - "sourceSha256": "3e1826809a13a4a0efbfa96c70b0cce9a29505656d943e0c5838825bda2990cf", + "head": "5ce58e58497298b6e99a23c70f311204c40bde5a", + "sourceSha256": "61520b8ca140e3673bc6815fb4b32c0f296e0e7898dc6efb7439484c431f1f9c", "generatedAt": null, "generator": "scripts/generate-inventory-graph.mjs" }, "base": "05c800f40d1ad53b42a78609d2667ef4f726808b", "planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398", - "head": "b5f923fc326c26344b8288dbc0b2c688cb33c9f1", - "sourceSha256": "3e1826809a13a4a0efbfa96c70b0cce9a29505656d943e0c5838825bda2990cf", + "head": "5ce58e58497298b6e99a23c70f311204c40bde5a", + "sourceSha256": "61520b8ca140e3673bc6815fb4b32c0f296e0e7898dc6efb7439484c431f1f9c", "counts": { "public": { "skills": 47, @@ -23,13 +23,13 @@ "total": 96 }, "internal": { - "hookFiles": 329, + "hookFiles": 330, "featureFiles": 84, "toolFiles": 61, "libFiles": 47, "templateHooks": 23, - "srcFiles": 1414, - "total": 1437 + "srcFiles": 1415, + "total": 1438 }, "generated": { "distFiles": 5066, @@ -41,7 +41,7 @@ }, "skills": 47, "commands": 21, - "hookFiles": 329, + "hookFiles": 330, "workflows": 8, "agentDefinitions": 18, "promptLikeFiles": 61 @@ -398,6 +398,7 @@ "src/hooks/session-end/__tests__/chain-enqueuer.test.ts", "src/hooks/session-end/__tests__/cleanup-manifest.test.ts", "src/hooks/session-end/__tests__/duplicate-notifications.test.ts", + "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", "src/hooks/session-end/__tests__/guardrails.test.ts", "src/hooks/session-end/__tests__/mode-state-cleanup.test.ts", "src/hooks/session-end/__tests__/openclaw-session-end.test.ts", @@ -6219,6 +6220,7 @@ "src/hooks/session-end/__tests__/chain-enqueuer.test.ts", "src/hooks/session-end/__tests__/cleanup-manifest.test.ts", "src/hooks/session-end/__tests__/duplicate-notifications.test.ts", + "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", "src/hooks/session-end/__tests__/guardrails.test.ts", "src/hooks/session-end/__tests__/mode-state-cleanup.test.ts", "src/hooks/session-end/__tests__/openclaw-session-end.test.ts", @@ -38620,6 +38622,11 @@ "kind": "hook", "path": "src/hooks/session-end/__tests__/duplicate-notifications.test.ts" }, + { + "id": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "kind": "hook", + "path": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts" + }, { "id": "src/hooks/session-end/__tests__/guardrails.test.ts", "kind": "hook", @@ -64032,6 +64039,51 @@ "to": "src/notifications/index.ts", "kind": "imports" }, + { + "from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "to": "external:child_process", + "kind": "imports" + }, + { + "from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "to": "external:fs", + "kind": "imports" + }, + { + "from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "to": "external:os", + "kind": "imports" + }, + { + "from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "to": "external:path", + "kind": "imports" + }, + { + "from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "to": "external:vitest", + "kind": "imports" + }, + { + "from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "to": "src/hooks/session-end/chain-enqueuer.ts", + "kind": "imports" + }, + { + "from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "to": "src/hooks/session-end/cleanup-manifest.ts", + "kind": "imports" + }, + { + "from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "to": "src/hooks/session-end/foreground-bootstrap.ts", + "kind": "imports" + }, + { + "from": "src/hooks/session-end/__tests__/foreground-bootstrap.test.ts", + "to": "src/hooks/session-end/worker.ts", + "kind": "imports" + }, { "from": "src/hooks/session-end/__tests__/guardrails.test.ts", "to": "external:fs", @@ -64577,6 +64629,11 @@ "to": "src/lib/worktree-paths.ts", "kind": "imports" }, + { + "from": "src/hooks/session-end/foreground-bootstrap.ts", + "to": "src/hooks/session-end/chain-enqueuer.ts", + "kind": "imports" + }, { "from": "src/hooks/session-end/foreground-bootstrap.ts", "to": "src/hooks/session-end/cleanup-manifest.ts", @@ -82604,12 +82661,12 @@ } ], "stats": { - "nodeCount": 7121, - "edgeCount": 8093, - "importEdgeCount": 6681, + "nodeCount": 7122, + "edgeCount": 8103, + "importEdgeCount": 6691, "registerEdgeCount": 68 } }, - "inventorySha256": "b631163acbd3652003f89ebfcca76c81a69633ffa707d8e3269fd1fe83892a49", - "manifestSha256": "6bf8d25a67b6bdca18f1950bc5915f4825a7cbfab5c9665a377a9225c9e8e205" + "inventorySha256": "ab6da542a11531649af7965ae0b3a575b88ab49eb2c4eb2c865bc6ad97f425b9", + "manifestSha256": "5069b758519b84e68cb6ed5ac423646bf28f235be080b29cab0a58bbc09a6788" } diff --git a/src/__tests__/chain-enqueuer-registration.test.ts b/src/__tests__/chain-enqueuer-registration.test.ts index 6da357e2b..2b0e62225 100644 --- a/src/__tests__/chain-enqueuer-registration.test.ts +++ b/src/__tests__/chain-enqueuer-registration.test.ts @@ -72,9 +72,10 @@ describe('chain-enqueuer SessionEnd registration (factory third link)', () => { expect(existsSync(templatePath)).toBe(true); const source = readFileSync(templatePath, 'utf8'); - // Must delegate to --hook=session-end (bridge), the only entry that runs - // processSessionEnd → planChainEnqueue. publishSessionEndBootstrap (the - // plugin scripts/session-end.mjs path) does not enqueue chains. + // Must delegate to --hook=session-end (bridge), which runs + // processSessionEnd → planChainEnqueue. The plugin path + // (scripts/session-end.mjs → publishSessionEndBootstrap) enqueues the + // chain itself in foreground-bootstrap.ts. expect(source).toContain("'--hook=session-end'"); expect(source).toContain('bridge'); }); diff --git a/src/hooks/session-end/__tests__/foreground-bootstrap.test.ts b/src/hooks/session-end/__tests__/foreground-bootstrap.test.ts new file mode 100644 index 000000000..39d62eaa8 --- /dev/null +++ b/src/hooks/session-end/__tests__/foreground-bootstrap.test.ts @@ -0,0 +1,84 @@ +/** + * Tests for the plugin-path SessionEnd bootstrap chain wiring (defect: plugin + * installs register hooks/hooks.json → scripts/session-end.mjs → + * publishSessionEndBootstrap, which is the ONLY SessionEnd entry when plugin + * hooks are enabled — the standalone settings.json forwarder is skipped). + * The bootstrap must therefore plan the chain enqueue and merge it into the + * durable manifest payload exactly like processSessionEnd does. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; + +vi.mock('../worker.js', () => ({ spawnSessionEndWorker: vi.fn(() => true) })); + +import { publishSessionEndBootstrap } from '../foreground-bootstrap.js'; +import { readSessionEndJob } from '../cleanup-manifest.js'; +import { factoryStateDir } from '../chain-enqueuer.js'; +import { spawnSessionEndWorker } from '../worker.js'; + +const tempRoots: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'omc-foreground-bootstrap-')); + tempRoots.push(dir); + execFileSync('git', ['init', '--quiet'], { cwd: dir, stdio: 'ignore' }); + return dir; +} + +afterEach(() => { + for (const dir of tempRoots) rmSync(dir, { recursive: true, force: true }); + tempRoots.length = 0; + vi.clearAllMocks(); +}); + +function writeLedger(directory: string, sessionId: string, ledger: Record): void { + mkdirSync(factoryStateDir(directory), { recursive: true }); + writeFileSync(join(factoryStateDir(directory), `chain-${sessionId}.json`), JSON.stringify(ledger), 'utf8'); +} + +function bootstrapInput(directory: string, sessionId: string) { + return { + session_id: sessionId, + transcript_path: '', + cwd: directory, + permission_mode: '', + hook_event_name: 'SessionEnd' as const, + reason: 'prompt_input_exit' as const, + }; +} + +describe('publishSessionEndBootstrap chain wiring (plugin session-end path)', () => { + it('merges the planned chain into the durable manifest payload when a ledger exists', async () => { + const dir = tempDir(); + writeLedger(dir, 'sess-a', { intentId: 'intent-a' }); + mkdirSync(join(dir, '.omc'), { recursive: true }); + writeFileSync(join(dir, '.omc', 'factory-routes.json'), JSON.stringify({ + 'success:*': { stage: 'review', skill: 'code-review' }, + }), 'utf8'); + + const result = await publishSessionEndBootstrap(bootstrapInput(dir, 'sess-a')); + + expect(result).toEqual({ continue: true }); + const manifest = readSessionEndJob(dir, 'sess-a'); + expect(manifest).not.toBeNull(); + const chain = manifest?.actions['spawn-next']?.payload?.chain as Record | undefined; + expect(chain).toBeDefined(); + expect(chain?.outcome).toBe('success'); + expect(chain?.intentId).toBe('intent-a'); + // The detached worker must be launched so the spawn-next action executes. + expect(spawnSessionEndWorker).toHaveBeenCalled(); + }); + + it('records no chain payload when the session has no factory ledger', async () => { + const dir = tempDir(); + await publishSessionEndBootstrap(bootstrapInput(dir, 'sess-b')); + const manifest = readSessionEndJob(dir, 'sess-b'); + expect(manifest).not.toBeNull(); + expect(manifest?.actions['spawn-next']?.payload?.chain).toBeUndefined(); + expect(spawnSessionEndWorker).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/session-end/foreground-bootstrap.ts b/src/hooks/session-end/foreground-bootstrap.ts index 4aaccd097..b2e11982e 100644 --- a/src/hooks/session-end/foreground-bootstrap.ts +++ b/src/hooks/session-end/foreground-bootstrap.ts @@ -1,4 +1,5 @@ import { prepareCoreManifest } from './cleanup-manifest.js'; +import { planChainEnqueue } from './chain-enqueuer.js'; import { resolveToWorktreeRoot, validateSessionId } from '../../lib/worktree-paths.js'; export interface SessionEndBootstrapInput { session_id: string; transcript_path: string; cwd: string; permission_mode: string; hook_event_name: 'SessionEnd'; reason: 'clear' | 'logout' | 'prompt_input_exit' | 'other'; } @@ -15,7 +16,13 @@ export interface SessionEndBootstrapResult { continue: true; } export async function publishSessionEndBootstrap(input: SessionEndBootstrapInput): Promise { validateSessionId(input.session_id); const directory = resolveToWorktreeRoot(input.cwd); - const payload = { transcriptPath: input.transcript_path, cwd: input.cwd, reason: input.reason, input, initialTeamNames: [] }; + // Plugin installs route SessionEnd here (hooks/hooks.json → scripts/session-end.mjs), + // so the chain enqueue must happen on this path — the standalone settings.json + // forwarder (processSessionEnd) is not registered when plugin hooks are enabled. + const chain = planChainEnqueue(directory, input.session_id, input.reason); + const payload = chain + ? { transcriptPath: input.transcript_path, cwd: input.cwd, reason: input.reason, input, initialTeamNames: [], chain } + : { transcriptPath: input.transcript_path, cwd: input.cwd, reason: input.reason, input, initialTeamNames: [] }; const prepared = prepareCoreManifest(directory, input.session_id, payload); if (prepared) { const { spawnSessionEndWorker } = await import('./worker.js');