Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 51 additions & 12 deletions plugin/scripts/anticipate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,28 +64,67 @@ set +e
SELF_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
[ -n "$SELF_DIR" ] || exit 0

# <codeRoot>/plugin/scripts/anticipate.sh → <codeRoot>. Resolved from THIS file's own location, so
# it is correct under the Stable Spine (an immutable ~/.cache/ruvnet-brain/versions/<gen> 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:
#
# <plugin-cache>/ruvnet-brain/4.0.7/scripts/anticipate.sh → ../.. = <plugin-cache>/ruvnet-brain
# ~/.cache/ruvnet-brain/versions/<gen>/scripts/anticipate.sh → ../.. = .../versions
# <src>/plugin/scripts/anticipate.sh → ../.. = <src> ← 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 <src>/plugin/scripts → <src>/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 `<gen>/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"
Expand Down
162 changes: 162 additions & 0 deletions tests/integration/anticipate-module-resolution.test.mjs
Original file line number Diff line number Diff line change
@@ -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 <root>/plugin/scripts/. Both layouts a user actually
// receives FLATTEN the `plugin/` level — the Claude Code plugin directory and the Stable Spine's
// versions/<gen>/ tree both place this script at <root>/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 <root>/plugin/scripts/anticipate.sh + <root>/scripts/<modules>
* false → shipped layout <root>/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 (<root>/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('');
});
});