Skip to content
Merged
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
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: CI

on:
push:
branches: [master]
pull_request:
workflow_dispatch: # Allow manual trigger

# Least privilege: this workflow only ever reads the tree. Declared
# explicitly rather than inherited, so a change to the repository's default
# token permissions cannot silently widen what a PR-triggered run can do.
permissions:
contents: read

# A newer push to the same branch supersedes an in-flight run.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: node ${{ matrix.node }}${{ matrix.bun && ' + bun' || '' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Floor of the supported range (package.json engines) and current.
node: ['20', '24']
# Buddy's rescue path reproduces Claude Code's Bun.hash() by shelling
# out to `bun`, and falls back to FNV-1a when Bun is absent. A bare
# runner only ever exercises the fallback, so the Bun branch shipped
# untested — and shipped broken: `bun -e <script> <arg>` drops the
# trailing argument, so every rescued companion rolled identical
# bones. One leg with Bun installed executes that branch for real.
bun: [false, true]
exclude:
# The Bun branch does not vary with the Node version; one leg is
# enough to cover it.
- node: '20'
bun: true
# The suite runs file-serially (vitest.config.ts sets fileParallelism:
# false, because several files share one BUDDY_DB_PATH) and takes well
# under a minute. The ceiling is a hang guard, not a budget: a test that
# reads unbounded local state can stall indefinitely, and that should
# fail the job rather than occupy a runner.
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm

- if: matrix.bun
uses: oven-sh/setup-bun@v2

# `npm ci` builds better-sqlite3 from source when no prebuild matches
# the runner's Node ABI — that is the install path users hit too.
- run: npm ci

- run: npm run build

- run: npm test
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ All notable changes to this project will follow [Semantic Versioning](https://se
- **Guard-mode graph stays alive in long sessions (Claude Code + Codex)** — the extraction instruction normally rides home only in the `buddy_observe` response, so when the host stops calling `buddy_observe` past ~100k tokens of context it also stops receiving the reminder, and the reasoning graph goes silent mid-session. Buddy's `UserPromptSubmit` hook (now registered **synchronously** so the host folds its stdout into context — the installer upgrades older async registrations) re-injects the extraction instruction after `REASONING_CONFIG.REINJECT_AFTER_SILENT_TURNS` consecutive turns with no new claims, pulling the host back. Turn-driven and scoped to the current project session (not observe-seq, which freezes when the host goes silent); resets the moment a claim lands; gated behind a cheap status-file check so guard-mode-off users pay no DB cost. **No second LLM call, no API key, no outbound dependency.** A re-injection→recovery metric is recorded and surfaced in `buddy_doctor`. The same compiled hook is wired into both Claude Code (`~/.claude/settings.json`) and Codex (`~/.codex/hooks.json`), which route hook stdout to model context identically. Cursor/Copilot don't yet expose that contract, so on those hosts long-session silence is surfaced via the doctor's inert-guard warning rather than auto-recovered (the same handler drops in when they add the hook). Validated on real transcripts (3 projects × 2 Anthropic models, N=15, claims graded for substantiveness, controls valid incl. at 150k): with the instruction only in the distant system block, substantive extraction falls to ~0% at 150k (opus emits nothing at any length); re-injection in the shipped placement recovers it to 60–100%. Directional, not universal — see `src/lib/reasoning/DESIGN.md`. Note: an existing guard-mode-on companion's re-injection activates on the next `buddy_observe`/status write (when `guard_mode` is first mirrored into the status file) — a one-cycle self-heal, not a regression. The shared `buddy.db` now uses **WAL journaling** so the hook and MCP-server processes don't block each other; this adds `buddy.db-wal`/`buddy.db-shm` sidecar files — if you sync `~/.buddy` across machines, they must travel with the main DB.
- **`convention` basis** (ported from slimemold) — for stipulated practice/policy by a named actor ("this project uses X", "agents must Y"), correct-by-fiat for its scope; distinct from `definition` (what a term *means*) and `vibes`/`research` (factual claims about a named thing). The observer instruction now carries slimemold's ordered basis decision tree and the v8 precision distinctions, so the host classifies claims more reliably.

### Fixed
- **`resolveSessionTrace` read every transcript whole, hanging `npm test` and `buddy graph`** — resolving a session id walks `~/.claude/projects`, `~/.claude/sessions` and `~/.codex/sessions` and needs only the first JSONL record (Claude) or first dozen (Codex), but it `readFileSync`'d each file entirely. Cost scaled with total transcript volume rather than with the header being parsed: on a machine with real history — 646 files, 8.5 GB — `reasoning/session-trace.test.ts` never finished, taking the whole suite with it. Fresh clones and CI runners were unaffected because they have no transcript history, which is why this survived. Reads are now bounded to a 32 KiB head per file (whole-file `.json` parsing keeps a separate 256 KiB ceiling), and truncated trailing lines are discarded rather than half-parsed. Same resolution result; the test goes from a >90s hang to 2s.
- **Every rescued companion rolled identical bones when Bun was installed** — the CC-compat rescue path shells out to `bun -e` to reproduce Claude Code's `Bun.hash()`, passing the userId as a trailing argument. `bun -e '<script>' <arg>` does **not** forward trailing arguments: inside the eval, `process.argv` is `['bun', '<cwd>/[eval]']` and nothing else, so `process.argv[1]` was a constant and the hash input never varied. Measured on a 300-userId sample: one distinct stat vector. Rarity, species, eye and stats were the same for everyone whose machine had Bun (the FNV-1a fallback path was unaffected). The input now travels through the environment, keeping the no-string-interpolation property that made argv attractive. Already-rescued companions keep their stored bones — no migration — but existing rescues were all clones of one roll. The regression test now samples 40 userIds rather than comparing two, since a two-sample assertion reads as an unlucky fixture in exactly the case where the bug is total.
- **`echo_chamber` could never surface; `grounded_premise_adopted` was shadowed** (#150, reported by @InfinitePortaldev) — some detectors are specializations of others: every `echo_chamber` case is also a `load_bearing_vibes` case (same anchor, plus a user speaker, assistant-only supports, and no pushback). Selection takes the first candidate in detector order, so the general finding always won; and because the per-anchor cooldown is type-agnostic, emitting it also blocked the specific finding on that anchor. `echo_chamber` was therefore unreachable in practice, not merely rare. `runAllDetectors` now drops a general finding when a more specific one fired on the same anchor (`SUBSUMES` in `types.ts`). Exposed by #129 lowering `LOAD_BEARING_MIN_DOWNSTREAM` 3→2 to match `ECHO_CHAMBER_MIN_SUPPORTS`; the thresholds are unchanged here, since they were tuned against real session density.
- **`unchallenged_chain` fired on the unchallenged *suffix* of a challenged chain** — the detector walked from every node, so a chain whose premise had been questioned still produced a finding anchored mid-chain, where no challenge sits between the remaining endpoints. It now starts only from maximal chains (nodes nothing else depends on), matching its documented "anchors on the head" intent. Also surfaced by #129's minimum-length 4→3.
- **Detector threshold tests no longer go green by accident** — the "does not fire below threshold" / "below minimum length" fixtures hardcoded edge counts that #129's retuning quietly turned into *at*-threshold cases, so they stopped testing anything. They now derive their fixtures from `REASONING_CONFIG`.

### Changed
- **Cross-host graph workflow is CLI-backed, not MCP-backed** — graph visualization ships as a Buddy CLI + host wrappers (Codex skill and Claude commands), avoiding any new MCP tool/schema overhead.

Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/oldBuddy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,22 @@ describe('rollWithCCCompat', () => {
expect(a.bones.stats).not.toEqual(b.bones.stats);
}, 15000);

// A two-userId comparison reads as "unlucky fixture" when it fails. It
// wasn't: `bun -e <script> <arg>` silently drops trailing arguments, so the
// hash input was a constant and EVERY rescued companion rolled identical
// bones. Sampling many ids makes that failure mode unmistakable.
// Sample size is a deliberate tradeoff. Each roll spawns a `bun`
// subprocess whose cost is ~9ms warm but seconds under load, and these
// tests are already timing-fragile for that reason. A dozen ids is far more
// than enough discrimination: the bug collapsed EVERY id to one vector, so
// it fails at 1-of-12 with enormous margin, while adding a third of the
// subprocess churn a larger sample would.
it('spreads across many userIds rather than collapsing to one roll', () => {
const ids = Array.from({ length: 12 }, (_, i) => `spread-user-${i}`);
const vectors = new Set(ids.map(id => JSON.stringify(rollWithCCCompat(id).bones.stats)));
expect(vectors.size).toBeGreaterThan(ids.length / 2);
}, 30000);

it('species is from CC list (18 species, short names)', () => {
const CC_SPECIES = [
'duck', 'goose', 'blob', 'cat', 'dragon', 'octopus', 'owl', 'penguin',
Expand Down
57 changes: 33 additions & 24 deletions src/__tests__/reasoning/detectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function withFiller(claims: FixtureClaim[], edges: FixtureEdge[], padTo: number
// ── Load-bearing vibes ──────────────────────────────────────────────────────

describe('detectLoadBearingVibes', () => {
it('fires when vibes claim has ≥3 downstream', () => {
it('fires when vibes claim has ≥2 downstream (threshold tuned in #129)', () => {
const g = withFiller([
{ id: 'v1', basis: 'vibes', text: 'we need auth' },
{ id: 'd1', basis: 'deduction' },
Expand All @@ -76,15 +76,16 @@ describe('detectLoadBearingVibes', () => {
expect(findings[0].claim_text).toBe('we need auth');
});

// Derived from config rather than hardcoded: a literal edge count silently
// stops testing "below threshold" the moment the threshold is retuned, which
// is how #129 left this green-by-accident.
it('does not fire below threshold', () => {
const g = withFiller([
{ id: 'v1', basis: 'vibes' },
{ id: 'd1', basis: 'deduction' },
{ id: 'd2', basis: 'deduction' },
], [
{ from: 'd1', to: 'v1', type: 'depends_on' },
{ from: 'd2', to: 'v1', type: 'supports' },
]);
const below = REASONING_CONFIG.LOAD_BEARING_MIN_DOWNSTREAM - 1;
const supporters = Array.from({ length: below }, (_, i) => ({ id: `d${i}`, basis: 'deduction' as const }));
const g = withFiller(
[{ id: 'v1', basis: 'vibes' }, ...supporters],
supporters.map(s => ({ from: s.id, to: 'v1', type: 'depends_on' as const })),
);
expect(detectLoadBearingVibes(g)).toHaveLength(0);
});

Expand Down Expand Up @@ -147,12 +148,18 @@ describe('detectUnchallengedChain', () => {
{ from: 'c3', to: 'c4', type: 'depends_on' },
{ from: 'q1', to: 'c2', type: 'questions' },
]);
// The chain-detection only flags chains where NO node in the chain has a
// challenge edge. But q1 is not in the chain — the challenge is FROM q1 TO c2.
// chainHasChallenge inspects edges where both endpoints are in the chain,
// so a challenge from outside doesn't count. To properly challenge, the
// question edge needs both endpoints in the chain.
// Reconfigure: have c2 questions c1 within the chain.
// chainHasChallenge only counts challenges with BOTH endpoints in the
// chain, so q1 (outside the chain) does not suppress `g`. That is the
// documented behaviour, not the case under test — keep `g` as the
// contrast and assert on `g2`, where the challenge is internal.
expect(detectUnchallengedChain(g).length).toBeGreaterThan(0);

// g2 challenges every link. #151 needed that because the detector walked
// from every node, so the sub-chain below the challenged pair (c2→c3→c4)
// qualified on its own; the detector now only starts from maximal chains,
// so the c1→c2 challenge alone would suffice. Kept as belt-and-braces —
// it pins the invariant regardless of which end the walk starts from.

const g2 = withFiller([
{ id: 'c1', basis: 'assumption' },
{ id: 'c2', basis: 'deduction' },
Expand All @@ -163,19 +170,21 @@ describe('detectUnchallengedChain', () => {
{ from: 'c2', to: 'c3', type: 'depends_on' },
{ from: 'c3', to: 'c4', type: 'depends_on' },
{ from: 'c2', to: 'c1', type: 'questions' },
{ from: 'c3', to: 'c2', type: 'questions' },
]);
expect(detectUnchallengedChain(g2)).toHaveLength(0);
});

it('does not fire below minimum length', () => {
const g = withFiller([
{ id: 'c1', basis: 'assumption' },
{ id: 'c2', basis: 'deduction' },
{ id: 'c3', basis: 'deduction' },
], [
{ from: 'c1', to: 'c2', type: 'depends_on' },
{ from: 'c2', to: 'c3', type: 'depends_on' },
]);
// Config-derived for the same reason as the load-bearing threshold test.
const nodes = REASONING_CONFIG.UNCHALLENGED_CHAIN_MIN_LENGTH - 1;
const chain = Array.from({ length: nodes }, (_, i) => ({
id: `c${i}`, basis: (i === 0 ? 'assumption' : 'deduction') as Basis,
}));
const edges = chain.slice(0, -1).map((c, i) => ({
from: c.id, to: chain[i + 1].id, type: 'depends_on' as const,
}));
const g = withFiller(chain, edges);
expect(detectUnchallengedChain(g)).toHaveLength(0);
});

Expand Down Expand Up @@ -238,7 +247,7 @@ describe('detectEchoChamber', () => {
// ── Bright: well-sourced load-bearer ────────────────────────────────────────

describe('detectWellSourcedLoadBearer', () => {
it('fires on research/empirical/deduction basis with ≥3 downstream', () => {
it('fires on research/empirical/deduction basis with ≥2 downstream (threshold tuned in #129)', () => {
const g = withFiller([
{ id: 'r1', basis: 'research', text: 'OWASP ranks XSS #3' },
{ id: 'd1', basis: 'deduction' }, { id: 'd2', basis: 'deduction' }, { id: 'd3', basis: 'deduction' },
Expand Down
7 changes: 5 additions & 2 deletions src/__tests__/reasoning/observer-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,13 @@ const SID = 'ws-20260422';

function primeClaims(db: Database.Database) {
// Build a graph that triggers load-bearing vibes:
// v1 (vibes, user) ← supports by d1, d2, d3
// v1 (vibes, assistant) ← supports by d1, d2, d3
// Plus filler to pass cold-start.
// v1 is assistant-authored deliberately: a *user* vibes claim backed by
// assistants with no pushback is an echo_chamber case, which subsumes
// load-bearing (SUBSUMES in types.ts). #150.
const claims: any[] = [
{ text: 'we need auth', basis: 'vibes', speaker: 'user', confidence: 'medium', external_id: 'v1' },
{ text: 'we need auth', basis: 'vibes', speaker: 'assistant', confidence: 'medium', external_id: 'v1' },
{ text: 'so we need sessions', basis: 'deduction', speaker: 'assistant', confidence: 'medium', external_id: 'd1' },
{ text: 'so we need token rotation', basis: 'deduction', speaker: 'assistant', confidence: 'medium', external_id: 'd2' },
{ text: 'so we need a rate limiter', basis: 'deduction', speaker: 'assistant', confidence: 'medium', external_id: 'd3' },
Expand Down
16 changes: 14 additions & 2 deletions src/__tests__/reasoning/pipeline-per-detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,15 @@ function seedAndRun(fixture: { claims: any[]; edges: any[] }): FindingType | nul
describe('pipeline integration — all 6 detectors end-to-end', () => {
beforeEach(() => { telemetry.reset(); resetGraphCache(); });

// The anchor is an ASSISTANT vibes claim on purpose. A *user* vibes claim
// with assistant supports and no pushback is an echo_chamber case, which
// subsumes load-bearing (see SUBSUMES in types.ts) — this fixture used to
// be exactly that, and only read as load_bearing_vibes because selection
// happened to evaluate that detector first. #150.
it('fires load_bearing_vibes', () => {
const t = seedAndRun({
claims: [
{ text: 'we need auth', basis: 'vibes', speaker: 'user', confidence: 'medium', external_id: 'v' },
{ text: 'we need auth', basis: 'vibes', speaker: 'assistant', confidence: 'medium', external_id: 'v' },
{ text: 'a', basis: 'deduction', speaker: 'assistant', confidence: 'medium', external_id: 'a' },
{ text: 'b', basis: 'deduction', speaker: 'assistant', confidence: 'medium', external_id: 'b' },
{ text: 'c', basis: 'deduction', speaker: 'assistant', confidence: 'medium', external_id: 'c' },
Expand Down Expand Up @@ -81,6 +86,11 @@ describe('pipeline integration — all 6 detectors end-to-end', () => {
expect(t).toBe('unchallenged_chain');
});

// #151 pinned this as load_bearing_vibes and flagged the open question. The
// answer: echo_chamber is a strict specialization of load_bearing_vibes, so
// the two are one situation at two resolutions rather than rivals, and the
// specific reading is the one worth surfacing. runAllDetectors now drops the
// subsumed general finding, so this asserts echo_chamber again. #150.
it('fires echo_chamber', () => {
const t = seedAndRun({
claims: [
Expand Down Expand Up @@ -139,6 +149,9 @@ describe('pipeline integration — all 6 detectors end-to-end', () => {
});

it('fires grounded_premise_adopted', () => {
// Exactly one assistant support: enough for grounded_premise_adopted
// (min 1 since #129) while staying below well_sourced_load_bearer's
// downstream minimum, so the grounded finding is the one that surfaces.
const t = seedAndRun({
claims: [
{ text: 'OWASP XSS #3', basis: 'research', speaker: 'user', confidence: 'high', external_id: 'u' },
Expand All @@ -150,7 +163,6 @@ describe('pipeline integration — all 6 detectors end-to-end', () => {
],
edges: [
{ from: 'a', to: 'u', type: 'supports' },
{ from: 'b', to: 'u', type: 'depends_on' },
],
});
expect(t).toBe('grounded_premise_adopted');
Expand Down
6 changes: 5 additions & 1 deletion src/__tests__/reasoning/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ function memDb(companionIds: string[] = ['c1']): Database.Database {
}

// Build a payload that pre-primes the graph with load-bearing vibes.
// The anchor is assistant-authored: a *user* vibes claim with assistant
// supports and no pushback is an echo_chamber case, which subsumes
// load-bearing (SUBSUMES in types.ts) and would change this fixture's
// meaning. #150.
function primingPayload() {
const claims = [
{ text: 'we need auth', basis: 'vibes' as const, speaker: 'user' as const, confidence: 'medium' as const, external_id: 'v1' },
{ text: 'we need auth', basis: 'vibes' as const, speaker: 'assistant' as const, confidence: 'medium' as const, external_id: 'v1' },
{ text: 'so we need sessions', basis: 'deduction' as const, speaker: 'assistant' as const, confidence: 'medium' as const, external_id: 'd1' },
{ text: 'so we need tokens', basis: 'deduction' as const, speaker: 'assistant' as const, confidence: 'medium' as const, external_id: 'd2' },
{ text: 'so we need rate limits', basis: 'deduction' as const, speaker: 'assistant' as const, confidence: 'medium' as const, external_id: 'd3' },
Expand Down
Loading
Loading