diff --git a/plugin/scripts/anticipate.sh b/plugin/scripts/anticipate.sh index b8bb585..aebb53b 100644 --- a/plugin/scripts/anticipate.sh +++ b/plugin/scripts/anticipate.sh @@ -64,28 +64,67 @@ set +e SELF_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) [ -n "$SELF_DIR" ] || exit 0 -# /plugin/scripts/anticipate.sh → . Resolved from THIS file's own location, so -# it is correct under the Stable Spine (an immutable ~/.cache/ruvnet-brain/versions/ tree) and -# in a dev checkout alike, without reading active.json — hook-shim.mjs has already chosen the tree -# by the time it executes this body. -CODE_ROOT=$(CDPATH='' cd -- "$SELF_DIR/../.." 2>/dev/null && pwd) -[ -n "$CODE_ROOT" ] || exit 0 +# WHERE THE THREE MODULES LIVE — resolved by PROBE, not by assertion (2026-08-04). +# +# This block used to be one line — `CODE_ROOT=$SELF_DIR/../..` — justified by the claim, repeated in +# the ADVOCACY_MODULE comment below, that a dev checkout and the Stable Spine "both keep `scripts/` +# as a sibling of `plugin/`". MEASURED on a real install, that is FALSE in both shipped layouts, and +# true only in the one layout that never reaches a user: +# +# /ruvnet-brain/4.0.7/scripts/anticipate.sh → ../.. = /ruvnet-brain +# ~/.cache/ruvnet-brain/versions//scripts/anticipate.sh → ../.. = .../versions +# /plugin/scripts/anticipate.sh → ../.. = ← only this +# +# Both distribution channels FLATTEN the `plugin/` level, so `../..` overshoots by one directory and +# $CODE_ROOT/scripts/goal-match.mjs never existed. The `[ -f "$GOAL_MATCH" ] || exit 0` guard below +# then did exactly what it promises — it stayed silent — so the L4 surface shipped permanently and +# INVISIBLY inert. A hook whose only failure mode is silence cannot report its own breakage; that is +# precisely why this needs a probe and not a comment. +# +# THE FIX IS TO ASK THE FILESYSTEM. Try each candidate directory and take the first that actually +# holds the matcher. That is the same discipline the rest of this file already applies to the +# machine's state: never render an assumption as a fact. Candidates, in order: +# +# 1. $SELF_DIR — modules shipped as SIBLINGS of this script (the flattened plugin +# zip and the Stable Spine, once packaging ships them; see below) +# 2. $SELF_DIR/../../scripts — the source/dev layout /plugin/scripts → /scripts +# 3. $SELF_DIR/../scripts — a root that keeps scripts/ one level up from this file +# +# PACKAGING IS THE OTHER HALF, and neither half works alone. goal-match.mjs, capability-registry.mjs +# and advocacy-outcomes.mjs currently live ONLY in the repo-root `scripts/`, which the plugin zip +# does not carry; this resolver finds nothing until they are shipped into `plugin/scripts/` beside +# this file. Fixing the path without the packaging leaves it silent, and vice versa. +# +# If NO candidate holds the matcher we deliberately fall through to the historical path, so the +# "missing module" diagnostics below still name the location a maintainer would expect — and the +# no-node fast path keeps this hook free. Silence on doubt, never a guess: rule 4, unchanged. +resolve_module_dir() { + for _cand in "$SELF_DIR" "$SELF_DIR/../../scripts" "$SELF_DIR/../scripts"; do + _r=$(CDPATH='' cd -- "$_cand" 2>/dev/null && pwd) || continue + if [ -n "$_r" ] && [ -f "$_r/goal-match.mjs" ]; then printf '%s' "$_r"; return 0; fi + done + _r=$(CDPATH='' cd -- "$SELF_DIR/../.." 2>/dev/null && pwd) || return 1 + [ -n "$_r" ] && printf '%s' "$_r/scripts" +} +MODULE_DIR=$(resolve_module_dir) +[ -n "$MODULE_DIR" ] || exit 0 # All THREE module paths are env-overridable, matching the RUVNET_LESSON_STORE / RUVNET_SETTINGS_FILE # idiom already used across this repo — so tests never load the real registry or touch a real user's # state, and a future relocation needs no edit here. -GOAL_MATCH="${RUVNET_GOAL_MATCH:-$CODE_ROOT/scripts/goal-match.mjs}" -CAP_REGISTRY="${RUVNET_CAPABILITY_REGISTRY:-$CODE_ROOT/scripts/capability-registry.mjs}" +GOAL_MATCH="${RUVNET_GOAL_MATCH:-$MODULE_DIR/goal-match.mjs}" +CAP_REGISTRY="${RUVNET_CAPABILITY_REGISTRY:-$MODULE_DIR/capability-registry.mjs}" # THE SINGLE SUPPRESSION POLICY (2026-07-23). Until this build this file decided "is X suppressed" # with its OWN local dismissed-Set in anticipate-state.json — one dismissal muted forever, no # severity, no budget — while advocacy-outcomes.mjs's shouldStillOffer()/DISMISSAL_BUDGET (a nag dies # on 1 dismissal, a high-severity finding needs 3, with a state-change reprieve) sat completely # uncalled. Two thresholds for one decision is the exact hazard named below for the confidence floor; # the fix is the same shape — wire this module the IDENTICAL env-var-with-a-default way as the two -# above, NOT a hardcoded `../scripts` path, so it resolves correctly whether this file runs from a dev -# checkout or the Stable Spine's `/plugin/scripts/anticipate.sh` (both keep `scripts/` as a -# sibling of `plugin/`, per this file's own CODE_ROOT comment above). -ADVOCACY_MODULE="${RUVNET_ADVOCACY_OUTCOMES_MODULE:-$CODE_ROOT/scripts/advocacy-outcomes.mjs}" +# above, NOT a hardcoded `../scripts` path, so it resolves wherever this file actually runs from. +# (CORRECTED 2026-08-04: this comment previously asserted that a dev checkout and the Spine "both +# keep `scripts/` as a sibling of `plugin/`". They do not — see the measured table above. The three +# modules now resolve through MODULE_DIR, which probes rather than assumes.) +ADVOCACY_MODULE="${RUVNET_ADVOCACY_OUTCOMES_MODULE:-$MODULE_DIR/advocacy-outcomes.mjs}" # ── Subcommands (dismiss/undismiss/status) run the same node program in a different mode ───────── MODE="suggest" diff --git a/tests/integration/anticipate-module-resolution.test.mjs b/tests/integration/anticipate-module-resolution.test.mjs new file mode 100644 index 0000000..16999a6 --- /dev/null +++ b/tests/integration/anticipate-module-resolution.test.mjs @@ -0,0 +1,162 @@ +// tests/integration/anticipate-module-resolution.test.mjs +// +// THE GAP THIS CLOSES. tests/integration/anticipate.test.mjs is 678 lines and thorough, but every +// one of its runs injects the module path — see its `run()` helper: +// +// ...(matcher ? { RUVNET_GOAL_MATCH: matcher } : {}), +// +// so the suite never exercises the DEFAULT path anticipate.sh derives from its own location. Even +// "exits 0 and stays quiet when the matcher module does not exist" passes an explicit path to a +// missing file. The default resolution therefore had no coverage at all, and shipped wrong: +// +// CODE_ROOT=$SELF_DIR/../.. → $CODE_ROOT/scripts/goal-match.mjs +// +// That is correct only for the source layout /plugin/scripts/. Both layouts a user actually +// receives FLATTEN the `plugin/` level — the Claude Code plugin directory and the Stable Spine's +// versions// tree both place this script at /scripts/ — so `../..` overshoots by one +// directory, the file is never found, and the `[ -f "$GOAL_MATCH" ] || exit 0` guard silently +// disables the entire L4 surface. +// +// This is the fifth instance of the defect class already documented in +// tests/unit/installer-sibling-imports-packaged.test.mjs: "A capability that silently does not +// exist on real installs is worse than one that was never built, because the team believes it is +// running and stops looking." Same shape, different file — and the same reason nobody noticed: the +// only symptom is silence, which is also the correct behaviour on the vast majority of prompts. +// +// So these tests deliberately set NO module env vars. They assert the invariant the overrides hide: +// dropped into a directory layout, anticipate.sh finds its own modules. +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const HOOK_SRC = path.join(ROOT, 'plugin', 'scripts', 'anticipate.sh'); +const MODULES = ['goal-match.mjs', 'capability-registry.mjs', 'advocacy-outcomes.mjs']; + +// A registry with exactly one dormant capability, so there is something legitimate to advocate for. +// Written into each layout rather than injected, because injecting is the very thing under test. +const STUB_REGISTRY = `export function auditAll() { + return [{ + key: 'cheap-model-routing', + state: 'off', + label: 'Cheap-model routing', + whatItBuysYou: 'Mechanical work runs on a cheap model instead of a frontier one.', + evidence: 'test fixture', + scope: 'machine', + }]; +} +export const STATE = { ON: 'on', OFF: 'off', IDLE: 'idle', UNKNOWN: 'unknown', ABSENT: 'absent' }; +export const CAPABILITIES = []; +`; + +// Corroborated on purpose: BASE (0.55) sits below CONFIDENCE_FLOOR (0.6), so a single cue is silent +// by design. Two cues is what a user in this situation actually writes. +const PROMPT = 'my Claude bill is expensive, can my agent use a cheaper model for simple work'; + +let work, home; + +/** + * Materialise one directory layout and return the path to its anticipate.sh. + * + * @param name subdirectory to build under the temp workspace + * @param nested true → source layout /plugin/scripts/anticipate.sh + /scripts/ + * false → shipped layout /scripts/anticipate.sh with the modules as siblings + */ +function makeLayout(name, nested) { + const root = path.join(work, name); + const hookDir = nested ? path.join(root, 'plugin', 'scripts') : path.join(root, 'scripts'); + const modDir = nested ? path.join(root, 'scripts') : hookDir; + fs.mkdirSync(hookDir, { recursive: true }); + fs.mkdirSync(modDir, { recursive: true }); + fs.copyFileSync(HOOK_SRC, path.join(hookDir, 'anticipate.sh')); + for (const m of MODULES) fs.copyFileSync(path.join(ROOT, 'scripts', m), path.join(modDir, m)); + // The registry is the one module we stub, so the assertion is about resolution, not about the + // state of the machine running the suite. + fs.writeFileSync(path.join(modDir, 'capability-registry.mjs'), STUB_REGISTRY); + return path.join(hookDir, 'anticipate.sh'); +} + +/** Run the hook the way Claude Code does — and with NO module env vars. That is the whole point. */ +function run(hook, prompt, sessionId) { + const res = spawnSync('/bin/sh', [hook], { + input: JSON.stringify({ session_id: sessionId, prompt }), + cwd: work, + encoding: 'utf8', + timeout: 30_000, + env: { PATH: process.env.PATH, HOME: home }, + }); + return { status: res.status, stdout: res.stdout ?? '' }; +} + +beforeAll(() => { + work = fs.mkdtempSync(path.join(os.tmpdir(), 'anticipate-resolution-')); + home = path.join(work, 'home'); + fs.mkdirSync(home, { recursive: true }); +}); +afterAll(() => { try { fs.rmSync(work, { recursive: true, force: true }); } catch {} }); + +describe('anticipate.sh — finds its own modules, with nothing injected', () => { + it('resolves them in the SOURCE layout (/plugin/scripts)', () => { + const { status, stdout } = run(makeLayout('src-layout', true), PROMPT, 'res-src'); + expect(status).toBe(0); + expect(stdout).toContain('Cheap-model routing'); + }); + + // THE REGRESSION. This is the layout every real user receives, and the one that was broken. + it('resolves them in the SHIPPED layout (modules as siblings)', () => { + const { status, stdout } = run(makeLayout('flat-layout', false), PROMPT, 'res-flat'); + expect(status).toBe(0); + expect(stdout).toContain('Cheap-model routing'); + }); +}); + +describe('anticipate.sh — resolution does not weaken the silence contract', () => { + it('is still SILENT on an unrelated prompt once the modules ARE reachable', () => { + // The failure mode of any "make it speak" fix is a hook that now speaks when it should not. + // Same layout, same reachable modules, ordinary software prompt: still nothing. + const hook = makeLayout('flat-silent', false); + for (const p of [ + 'fix the memory leak in my C++ parser', + 'our nightly build failed again', + 'this query is expensive, can we add an index', + 'we are burning through our AWS credits', + ]) { + const { status, stdout } = run(hook, p, `neg-${Buffer.from(p).toString('hex').slice(0, 8)}`); + expect(status).toBe(0); + expect(stdout).toBe(''); + } + }); + + it('still exits 0 and stays quiet when the modules are genuinely absent', () => { + // The documented degradation must survive the fix: no modules anywhere, no work, no noise — + // and above all no invented failure on a turn the user is waiting for. + const root = path.join(work, 'no-modules'); + const hookDir = path.join(root, 'scripts'); + fs.mkdirSync(hookDir, { recursive: true }); + fs.copyFileSync(HOOK_SRC, path.join(hookDir, 'anticipate.sh')); + const { status, stdout } = run(path.join(hookDir, 'anticipate.sh'), PROMPT, 'res-none'); + expect(status).toBe(0); + expect(stdout).toBe(''); + }); + + it('an explicit RUVNET_GOAL_MATCH still wins over resolution', () => { + // The override is how the rest of the suite works; resolution must not shadow it. + const hook = makeLayout('override-layout', false); + const res = spawnSync('/bin/sh', [hook], { + input: JSON.stringify({ session_id: 'res-override', prompt: PROMPT }), + cwd: work, + encoding: 'utf8', + timeout: 30_000, + env: { + PATH: process.env.PATH, + HOME: home, + RUVNET_GOAL_MATCH: path.join(work, 'override-layout', 'scripts', 'does-not-exist.mjs'), + }, + }); + expect(res.status).toBe(0); + expect(res.stdout ?? '').toBe(''); + }); +});