From 4b52fd016e5b48057bb675b54492776722680e2f Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Wed, 15 Jul 2026 09:57:00 +0800 Subject: [PATCH] fix(observe): no-op the Claude hook when .insta/observe/hook.js is absent .claude/settings.json is often committed while ./.insta stays local-only, so a fresh clone (cloud code session, teammate checkout) gets the observe hook registered without the script. Every tool call then fails with 'PostToolUse hook error: node:internal/modules/cjs/loader:1386' (MODULE_NOT_FOUND). Guard the command on file existence so those checkouts no-op instead of erroring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015PdkPrifSPAYEEX4QF89z4 --- src/observe/install.ts | 5 ++++- test/observe-install.test.ts | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/observe/install.ts b/src/observe/install.ts index c632340..865e5da 100644 --- a/src/observe/install.ts +++ b/src/observe/install.ts @@ -59,8 +59,11 @@ function claudeEntry(): Group { // Claude Code executes `command` as ONE shell string with $CLAUDE_PROJECT_DIR in the env — // there is no `args` field in its hooks schema, so a ${…} template in args reaches node // verbatim and throws MODULE_NOT_FOUND after every tool call. + // .claude/settings.json is often committed while ./.insta stays local-only, so a fresh + // clone (cloud session, teammate) gets the hook without the script — no-op there. + const hook = '"$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"' return { matcher: '*', hooks: [{ type: 'command', - command: 'node "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"', timeout: 15, _insta: MARKER }] } + command: `[ ! -f ${hook} ] || node ${hook}`, timeout: 15, _insta: MARKER }] } } function codexEntry(cwd: string): Group { const abs = join(cwd, '.insta', 'observe', 'hook.js') // Codex doesn't expand ${CLAUDE_PROJECT_DIR}; use an absolute path diff --git a/test/observe-install.test.ts b/test/observe-install.test.ts index 3f3cc5f..d086ccf 100644 --- a/test/observe-install.test.ts +++ b/test/observe-install.test.ts @@ -4,6 +4,7 @@ // tool call in every linked project. Found live (user report, 2026-07-12). import { test, expect } from 'vitest' import { mkdtempSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { installObserve } from '../src/observe/install.js' @@ -22,7 +23,26 @@ test('claude hook entry is a single shell-string command with $CLAUDE_PROJECT_DI const settings = JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')) const hook = settings.hooks.PostToolUse.at(-1).hooks[0] expect(hook.args).toBeUndefined() - expect(hook.command).toBe('node "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"') + expect(hook.command).toBe( + '[ ! -f "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js" ] || node "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"') +}) + +// .claude/settings.json is often committed while ./.insta stays local-only, so a fresh clone +// (cloud code session, teammate checkout) has the hook registered but no hook.js — the command +// must no-op there, not spam MODULE_NOT_FOUND after every tool call. Found live (2026-07-15). +test('hook command exits 0 when .insta/observe/hook.js is absent, runs it when present', () => { + const cwd = mkdtempSync(join(tmpdir(), 'obs-proj-')) + mkdirSync(join(cwd, '.claude'), { recursive: true }) + installObserve({ cwd, assetDir: fakeAssets() }) + const settings = JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')) + const cmd = settings.hooks.PostToolUse.at(-1).hooks[0].command + const run = (projectDir: string) => + spawnSync('sh', ['-c', cmd], { env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir } }) + const bare = mkdtempSync(join(tmpdir(), 'obs-clone-')) // fresh clone: no .insta at all + expect(run(bare).status).toBe(0) + expect(run(bare).stderr.toString()).toBe('') + writeFileSync(join(cwd, '.insta', 'observe', 'hook.js'), 'process.stdout.write("ran")') + expect(run(cwd).stdout.toString()).toBe('ran') }) test('re-install replaces a broken legacy args-array entry instead of stacking', () => {