SLOPSHOPPER

ruflo-mods

ruflo as a Claude Code mod (function hooks, ADR-404): in-process prompt routing, edit learning signals, tighten-only tool checks from ruflo policy, the…

newguardcommandtoaststatusprompt
★ 74,184v0.3.16MITupdated 2026-10-07ruvnet/ruflo/plugins/ruflo-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ruflo-mods
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ruflo-mods ⎿ ruflo-mods: ruflo mods (ADR-404) ⎿ ruflo-mods: owns: nothing (classic hooks keep every event) ⎿ ruflo-mods: routed: 0 prompt(s); last none yet ⎿ ruflo-mods: edits: 0 recorded, 0 pending write ⎿ ruflo-mods: policy: none; 0 call(s) tightened, 0 observed ⎿ ruflo-mods: budget: off (set the costBudgetUsd option) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ruflo-mods

ruflo as a Claude Code mod (function hooks): on by default in Claude Code >= 2.1.287; from 2.1.277 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. Design and evidence: ADR-404, including Amendment 1 (default-on in ruflo init).

ruflo init                          # new projects: mods on by default, in the committed .claude/settings.json
ruflo init upgrade --mods           # existing projects: merge the same keys (never overwrites yours) and install
ruflo mods install                  # just this checkout (.claude/settings.local.json); --scope project for the team
ruflo mods install --source local   # dogfooding: load the mods live from this ruflo checkout (no clone)
ruflo mods doctor                   # marketplace source and freshness, plugins loadable, function hooks, what it owns
ruflo mods uninstall                # claude plugin uninstall what ruflo installed; remove exactly the keys it added

These enable ruflo-mods@ruflo, ruflo-swarm@ruflo and ruflo-console@ruflo, the ruflo marketplace and CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. A key you already set is left as it is: a plugin you set to false stays off and is never installed. ruflo init --no-mods writes none of it.

What loads

  • ruflo-mods and ruflo-console run only where function hooks are on. With them off, or with Claude Code's rollout switch off, they do nothing, and the classic hooks keep every event.
  • ruflo-swarm loads its commands, skills and agents even without function hooks. With them on, it is also a mod that can run host commands (pane actions you start).
  • The ruflo marketplace is cloned on first use. Each teammate's first trusted, interactive Claude Code start clones github.com/ruvnet/ruflo. A headless claude -p on a fresh config loads nothing. Once the marketplace is cloned, which ruflo init does, claude -p loads the mods too.
  • Optional hardening: set pluginConfigs["ruflo-mods@ruflo"].options.modTrust = "refuse-risky" with modTrustAllow in user or managed settings (e.g. "ruflo-swarm@ruflo,ruflo-console@ruflo": both run host commands, so refuse-risky refuses them otherwise). Project settings cannot set it. The default is observe, which names what each later mod can do and blocks nothing. ruflo-protector@ruflo (Project Anatole, ADR-453) is an expected modTrustAllow entry: it hooks tool.check to deny rule-marked-block calls in enforce mode, so refuse-risky refuses it otherwise. When its status file exists, /ruflo-mods shows one protector: row (mode, open alerts, blocked).

Install and repair

When a claude binary is on PATH, init and install also do what you would do by hand, in the project directory:

claude plugin marketplace update ruflo                     # or, first time: claude plugin marketplace add ruvnet/ruflo --scope <scope>
claude plugin install ruflo-mods@ruflo --scope <scope>
claude plugin install ruflo-swarm@ruflo --scope <scope>

Settings alone are not always enough. Claude Code loads these plugins from its local clone of the ruflo marketplace (~/.claude/plugins/marketplaces/ruflo, or under $CLAUDE_CONFIG_DIR), with or without an install record.

  • No clone yet. An interactive, trusted session clones it on start. A headless claude -p run does not.
  • A stale clone. A clone from before ruflo-mods shipped has no plugins/ruflo-mods, so Claude Code skips the enabled plugin without a word, and /ruflo-mods is an unknown command. It does not refresh the clone on start. ruflo mods doctor and ruflo doctor report this as a failure, with the exact commands above.
  • Why claude plugin install too. It keeps a cached copy that still loads if the clone goes stale later.

ruflo mods uninstall runs claude plugin uninstall <id> --scope <scope> only for the plugins ruflo itself installed: ones that were not installed before and that ruflo enabled. It then removes exactly the settings keys ruflo added. Both are recorded in .claude-flow/mods/install.json.

Dogfooding: --source local

ruflo mods install --source local declares the ruflo marketplace as a directory source pointing at a ruflo checkout: --marketplace-path <dir>, or by default the project root, which must hold the ruflo .claude-plugin/marketplace.json.

  • Live from the working tree. Claude Code loads the plugins straight from that directory: no clone, nothing to go stale, and edits show on the next session. Verified on 2.1.287.
  • No claude plugin install. Installing would pin a cached snapshot instead of the live tree.
  • One marketplace per config dir. Claude Code keeps one ruflo marketplace per config dir, so switching it to a directory applies to every project on that machine. Switch back with claude plugin marketplace add ruvnet/ruflo.
  • Doctor reports the source type, and warns when a project declares one source but Claude Code knows ruflo by another.

Flags

  • --no-plugin-install writes settings only and runs no claude command.
  • --dry-run (mods install, mods uninstall) prints the settings and the claude commands without running either.
  • If claude is missing or a step fails, the manual commands are printed and the exit code is 0. Pass --strict (mods install) to exit 1 instead.
  • Under VITEST or CI, init skips the claude step and prints the commands.
  • Claude Code reformats .claude/settings.json (key order) when it installs at project scope. ruflo says so and leaves it: the content is unchanged.

In a session, /ruflo mods (through ruflo-console's /ruflo) reports what the mod owns, routed, recorded and tightened. /ruflo-mods stays as an alias of it (ADR-406: no command is removed or renamed).

What it does

  • Routing: in an initialized Ruflo project (an existing .claude-flow/ directory), prompt.submit routes each prompt in-process and hands the route, plus ranked memory, to the model as context. The text is the same as the classic route hook produces.
  • Edit learning: tool.call records finished edits for the intelligence consolidator, once per turn.
  • Tool checks: tool.check only tightens. It applies the dangerous-command list and ruflo policy rules that name claude-code.* actions (written by the CLI to .claude-flow/policy/claude-code.json). It never loosens a verdict. A projection whose mode is legacy (or anything but observe/enforce) is rejected as unreadable, because the CLI never writes one (it deletes the file), so the call is put to you; /ruflo-mods shows policy: <mode> (projection read) or policy: unreadable (ADR-450 T10).
  • Trust gate: plugin.register names what a later-installed mod can do (host commands, network, environment, tool verdicts, agent spawns, prompt changes) and, under modTrust: refuse-risky, refuses it unless allow-listed.
  • $.ruflo: other mods add a status segment with $.ruflo.segment({ id, text }) instead of drawing a second bar; lastRoute() and snapshot() read what the mod measured. Contract: types/index.d.ts.
  • Budget: session.measure applies the cost-tracker budget ladder to live session cost (costBudgetUsd); Each rung is announced once per session, even if cost falls and rises again. costHardStop halts new subagents at 100%.

The classic hook-handler.cjs hooks stay installed and remain the fallback. The mod takes an event only where the classic helper hands it over (RUFLO_MODS_OWNS), so nothing fires twice. When the mod is not loaded, every classic hook runs as before.

At user scope, an unrelated project receives no routing context, edit-learning writes or .claude-flow/mods/session.json heartbeat. The mod does not initialize projects itself. Tool guards, trust checks and explicitly configured budgets remain active there.

Toasts (ADR-477)

ruflo-mods draws its toasts through the shared toast policy (one copy of hooks/toast-policy.ts, kept identical across the ruflo mods by scripts/sync-toast-policy.mjs): a level prefix (› info, ✓ ok, ⚠ warn, ✗ error), one clean line of at most 120 characters with secrets masked, an identical toast not repeated for a minute, and at most four a minute per source (errors are held and counted, never dropped). The budget ladder is info, warn, error, error (INFO, WARNING, CRITICAL, HARD_STOP); a dropped or held-back delivery is an error; agentTrim's one-time note is info.

The person's choice is the console's Settings → Interface and updates → Toasts: all, important (warnings and errors) or off, and a mute chip per source. Every toast, drawn or not, is kept as a digest the console shows on its Events page, flagged with what became of it; without the console nothing is written and the defaults apply.

Options

Claude Code reads a plugin's options from pluginConfigs["ruflo-mods@ruflo"].options in user settings, --settings or managed settings. Project settings are not read for this. Every option has a default:

OptionDefaultEffect and evidence
routeContexttrueInclude ranked memory with each prompt's route. Evidence: live paths
statusLinetrueOne-line ruflo status under the prompt; skipped where the ruflo statusLine helper is configured. Evidence: live paths
costBudgetUsd0 (off)Session budget for the 50/75/90/100% ladder; each rung is announced once. Evidence: capability review
costHardStopfalseRefuse new subagents at 100% of the budget. No live evidence doc yet; design in ADR-404
toolHintsfalseAdd one short static hint, restating the project's own CLAUDE.md, to a few ruflo MCP tool descriptions. Live: hints appear on exactly the hinted tools, none when off. Evidence: live validation
agentTrimfalseKeep agent types the project does not use out of the agent listing. Measured saving about 4,000 tokens per request (4,236 on a 69-type catalogue, one model); a hidden type the prompt does not name is refused at dispatch, one the prompt names can still be dispatched. Evidence: trim measurement
agentTrimKeepemptyComma-separated agent type names agentTrim must keep offered. Evidence: live validation
deliveryScreenfalseADR-451 screen: drops peer, relay or webhook deliveries that carry injection phrasing and refuses outgoing messages that carry a secret shape; never screens your own Remote Control prompts; names the rule, never the text. Caught 76% of its own 50-injection test set before the rules were tuned on it (figure from the PR #3741 description, not a repo doc); the real-world rate is unknown. No live evidence doc yet; design in ADR-451 item 3
compactCarryfalsesession.compact (ADR-451 item 7): appends at most 600 characters to what the compaction summary keeps: swarm id, topology word, agent count, open claim ids and status, last route agent, budget and policy words. Fixed words and identifiers only, screened, fails open, skipped when no swarm or claim is open. No live evidence doc yet; design in ADR-451
capabilityProbefalseObservability only (ADR-451 item 5): /ruflo-mods gains probe: engine <version> · events fired n/m · never fired: ...; reads no payload and never denies, rewrites or delays. Live run: ruflo-mods-options-live-2026-10.md; design in ADR-451
sessionRollupfalseObservability only (ADR-451 item 6): at session end one counter record (no prompt text, inputs or paths) goes to a user-global ledger capped at 50 sessions and 32 KB; /ruflo-mods shows the last few. Live run: ruflo-mods-options-live-2026-10.md; design in ADR-451
modTrustobserveobserve / refuse-risky / off: the mod trust gate. Evidence: capability review
modTrustAllowemptyComma-separated plugin ids (name@marketplace, e.g. ruflo-swarm@ruflo,ruflo-console@ruflo) the gate never refuses. Evidence: capability review
guidanceContextfalseScreened lexical excerpts (at most five, 4096 characters) from a compiler-exported guidance projection; advisory, cannot authorize tool calls. Design in ADR-447; evidence: native guidance
guidanceLearningfalseUnverified activity observations for independent review; no training or promotion. Evidence: native guidance

Task guidance and observation review (ADR-447)

Export reviewed guidance with a CLI built from this revision. Supply the full 40 or 64 character commit ID containing the reviewed source. The exporter checks that root and every explicitly supplied local overlay are regular tracked files whose raw bytes match that commit. It compiles those checked snapshots and binds their full SHA256 digests into the projection. JSON output also reports each source's relative path, blob ID and byte length.

ruflo guidance compile --mod-projection --revision "$(git rev-parse HEAD)" --root ./CLAUDE.md
ruflo guidance compile --mod-projection --revision "$(git rev-parse HEAD)" --root ./CLAUDE.md --local ./CLAUDE.local.md --json

Changed source bytes, missing or untracked explicit overlays, symlinks, sources in another repository, abbreviated IDs and missing local objects reject before replacing an existing projection. Each source is limited to 1 MiB of valid UTF8. An empty committed overlay is supported. Unrelated workspace edits are allowed; the exporter verifies source snapshots, not the Git index or the entire worktree. Use a full or ordinary shallow checkout. Partial clones and promisor repositories are refused so older Git cannot fetch missing objects during export. Git overrides and replacement refs cannot substitute another repository or commit. These checks run only in the explicit CLI export, never in native prompt or tool hooks.

The checked export is provenance, not a signed acceptance receipt. Project writable projections remain untrusted advisory data; an independent evaluator must still bind and verify source, task and artifact evidence before accepting learning.

This reuses the Guidance Control Plane compiler. The default destination is .claude-flow/mods/guidance/projection.json. --output overrides its directory; the native mod reads only the default project path. No embeddings, optimizer or model calls are used. Retrieval is lexical, at most five excerpts and 4096 characters. Missing, oversized, symlinked or corrupt advisory data adds no context. Screening reduces exposure to credentials and injection; it does not grant trust.

Enable the two independent options through Claude Code user settings:

{
  "pluginConfigs": {
    "ruflo-mods@ruflo": {
      "options": { "guidanceContext": true, "guidanceLearning": true }
    }
  }
}

guidanceLearning records observations, despite its compatibility-oriented name. A project that does not own route records none (it still gets guidanceContext). Tool ids past 256 per task are not counted; /ruflo-mods reports how many. Each registration lifetime owns a separate queue under .claude-flow/mods/guidance/observations/. Records contain generated task IDs, bundle digest, source revision, displayed rule IDs, permission counters, tool execution counters and completion class. They contain no prompt, answer, command, tool output or file path. A successful tool call and a completed turn stay verified: false and learningEligible: false. With guidanceContext disabled, observations correctly contain no displayed rule IDs.

ruflo guidance mod-candidates --json
ruflo guidance mod-candidates --bundle-id <64-character-bundle-digest> --json

The report groups observations into review priorities, rejecting forged verified flags, unknown fields, replayed records and mismatched queue namespaces. It writes no accepted guidance ledger, memory, policy or source file. Task-bound independent acceptance evidence, a held out baseline comparison and authorized promotion are required before these observations can support trusted learning. Keep candidates outside CLAUDE.md and CLAUDE.local.md until that review completes.

Review reasons identify observed tool errors, denied checks or calls, and aborted or interrupted turns. Each rule reports exposure counts, affected observations, completed turns and rates with explicit denominators: errors divided by executed tools (ok + error), and denials divided by permission checks (allow + ask + deny). An empty denominator produces null. Global totals count each observation once, even when it displayed several rules, and include turns without guidance. Ties use the full version identity for deterministic reports. These are correlations: completion is not accepted success and a displayed rule is not a proven cause.

Queues retain at most 128 observations per registration lifetime and 256 KiB. Flushes serialize in the process and retry refused writes without overwriting unreadable or corrupt existing bytes. Native filesystem writes cannot guarantee atomic persistence through a crash; a hot reload can lose an unfinished turn. The review command accepts at most 128 queue files per batch. Archive reviewed queues explicitly outside the active directory. /ruflo mods reports saved, pending and dropped observations. These counts are activity, not correctness.

Tests

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugins/ruflo-mods   # real engine; needs the rollout switch on
cd v3/@claude-flow/cli && npx vitest run __tests__/mods/                      # declaration-faithful harness, parity tests
bash plugins/ruflo-mods/scripts/smoke.sh                                       # static security contract
bash plugins/ruflo-mods/scripts/native-guidance-smoke.sh                       # three configured native guidance contracts

To typecheck, load the plugin once (Claude Code >= 2.1.287 writes its declarations into .claude-plugin/types/), then run npx tsc -p plugins/ruflo-mods.

The separate CLI adapter check is tsc -p v3/@claude-flow/cli/tsconfig.mod-guidance.json. The Native mod guidance source contracts workflow runs the adapter types, security smoke and source mod suites on every PR to main and every main push, using locked dependencies without lifecycle scripts. It uses the CLI workspace compiler aliases. The new native kit file stays outside ordinary root Vitest; the source E2E suite runs in this dedicated workflow. Native engine declarations, the complete native suite and built CLI tests remain separate provisioned gates.

The focused native guidance smoke uses a disposable plugin copy with explicit feature options because the 2.1.283 test kit ignores per-test option overrides. It runs the three feature contracts against unchanged production hooks. The default settings contract belongs to the standard suite. A rollout gate still applies, and the focused result must not be reported as the complete native suite.

Source 40 files
hooks/register.ts 51 lines
1import type { Register } from 'claude-code'
2
3import { registerAgents } from './agents'
4import { registerCompact } from './compact'
5import { registerCost } from './cost'
6import { registerDelivery } from './delivery'
7import { registerDescribe } from './describe'
8import { registerGuard } from './guard'
9import { createGuidance } from './guidance'
10import { registerLearn } from './learn'
11import { registerNoun } from './noun'
12import { readOptions } from './options'
13import { registerProbe } from './probe'
14import { registerRollup } from './rollup'
15import { registerRoute } from './route'
16import { registerSession } from './session'
17import { createState } from './state'
18import { registerTrust } from './trust'
19
20/**
21 * ruflo as a Claude Code mod (ADR-404).
22 *
23 * In-process equivalents of the classic hook-handler.cjs events that fire on
24 * every prompt and every edit, tighten-only tool checks, the cost ladder, the
25 * `$.ruflo` noun other mods compose with, and the mod trust gate. Additive
26 * and opt-in: the classic hooks stay the default, keep every event the mod
27 * does not own, and take everything back whenever the mod is not loaded.
28 *
29 * Registration order is nesting order (the first registered wraps the rest):
30 * the trust gate first, so it judges modules before anything else of ours runs.
31 */
32export const register: Register = (on, options) => {
33  const state = createState()
34  const opts = readOptions(options)
35  const guidance = createGuidance(state, opts)
36
37  registerTrust(on, opts.modTrust, opts.modTrustAllow)
38  if (opts.capabilityProbe) registerProbe(on, state, opts)
39  if (opts.sessionRollup) registerRollup(on, state)
40  registerNoun(on, state)
41  registerSession(on, state, opts, guidance)
42  registerRoute(on, state, opts, guidance)
43  registerGuard(on, state, guidance)
44  registerLearn(on, state, guidance)
45  registerCost(on, state, opts)
46  if (opts.toolHints) registerDescribe(on, state)
47  if (opts.agentTrim) registerAgents(on, state, opts)
48  if (opts.deliveryScreen) registerDelivery(on, state)
49  if (opts.compactCarry) registerCompact(on, state)
50}
51
hooks/agents/index.ts 50 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import type { ModOptions } from '../options'
4import type { ModState } from '../state'
5import { isKept, readUsage, type Usage } from './trim'
6
7const LEDGER = 'agentUse'
8
9/**
10 * `agent.offer` (ADR-451 item 2): keeps agent types the project does not use
11 * out of the model's listing. Off unless `agentTrim` is
12 * on. A type stays when it is built in, a pinned core role, named in
13 * `agentTrimKeep`, used in the last 30 days (the `$.store` ledger, fed by
14 * `agent.spawn`), or named in the current prompt. Tighten-only on what the
15 * model sees (measured live, 2026-10: a hidden type the prompt does NOT name is refused at dispatch, one the prompt names plainly is accepted; the
16 * listing is built before the first prompt in a headless session, so a prompt naming a type does not put it back in the listing there). Any failure offers the type (the hook is skipped).
17 */
18export function registerAgents(on: On, state: ModState, options: ModOptions) {
19  state.agentTrim.enabled = true
20
21  on('agent.spawn', { subagentType: /^.+$/ }, async ($, e, next) => {
22    try {
23      const usage = await loadLedger($, state)
24      usage[e.subagentType] = await $.clock.now()
25      await $.store.set(LEDGER, usage)
26    } catch {
27      // an unwritable ledger only means the type may be hidden next session
28    }
29    return next(e)
30  })
31
32  on('agent.offer', async ($, e, next) => {
33    const result = await next(e)
34    if (!result.isOffered) return result
35    const kept = isKept(e, { keep: options.agentTrimKeep, usage: await loadLedger($, state), prompt: state.agentTrim.prompt, now: await $.clock.now() })
36    if (kept) return result
37    const first = state.agentTrim.hidden.size === 0
38    state.agentTrim.hidden.add(e.agent)
39    if (first) {
40      await state.say({ level: 'info', text: 'ruflo agentTrim: unused agent types are hidden from the model (see /ruflo-mods)' })
41    }
42    return { ...result, isOffered: false }
43  }).catch(($, e, next) => next(e)) // fail open: a broken trim offers the type
44}
45
46/** The usage ledger, read once per process; a failed read is an empty ledger. */
47function loadLedger($: EngineInterface, state: ModState): Promise<Usage> {
48  return (state.agentTrim.ledger ??= $.store.get(LEDGER).then(readUsage, () => readUsage(undefined)))
49}
50
hooks/compact/index.ts 30 lines
1import type { On } from 'claude-code'
2
3import type { ModState } from '../state'
4import { carryBlock } from './block'
5
6/**
7 * `session.compact` (ADR-451 item 7): appends one short, bounded block to what the summary is told to keep, naming the live swarm
8 * and open claims, so a compaction does not forget them. Off unless `compactCarry` is on.
9 *
10 * Append only: the person's own `/compact` text and every other hook's change stay in front, the messages are never touched, and a
11 * subagent's or fork's own compaction (`agentId`) is left alone. The block is built from validated words and counts, never prompt
12 * text, tool input, paths or file contents. Any failure passes the compaction on unchanged.
13 */
14export function registerCompact(on: On, state: ModState) {
15  const c = state.compact
16  c.enabled = true
17
18  on('session.compact', async ($, e, next) => {
19    if (e.agentId !== undefined) return next(e)
20    const block = await carryBlock(
21      { stat: path => $.fs.stat(path), read: path => $.fs.read(path) },
22      state.root,
23      { agent: state.lastRoute?.agent, routed: state.routed, budget: state.budget.level, policy: state.policy },
24    )
25    if (!block) return next(e)
26    c.carried += 1
27    return next({ ...e, instructions: e.instructions ? `${e.instructions}\n\n${block}` : block })
28  }).catch(($, e, next) => next(e)) // fail open
29}
30
hooks/cost/index.ts 57 lines
1import type { On } from 'claude-code'
2
3import type { ModOptions } from '../options'
4import { redraw, type ModState } from '../state'
5import { alertLevel, isRaised, type BudgetLevel } from './budget'
6
7/**
8 * ruflo-cost-tracker's budget ladder on the session's live cost:
9 * `session.measure` pushes the figure after each turn, so no transcript parse
10 * and no node spawn. Crossing a rung up says so once. With `costHardStop`,
11 * the HARD_STOP rung does what budget.mjs recommends at 100%: new subagent
12 * spawns are refused (a deny on `agent.spawn`; tightening only).
13 */
14export function registerCost(on: On, state: ModState, options: ModOptions) {
15  const limit = options.costBudgetUsd
16  if (limit === undefined) return
17  state.budget = { level: 'OK', limit }
18  let announced: BudgetLevel = 'OK' // the highest rung told this session; a fall and a second rise stay quiet
19
20  // The budget is per session. /clear ends one (session.end, reason clear; no
21  // session.start follows) and the process goes on with the next one's cost
22  // counted from zero, so the ladder and the hard stop start over. Registered
23  // after the rollup, which records this session's rung before this runs.
24  on('session.end', { reason: /^clear$/ }, ($, e, next) => {
25    announced = 'OK'
26    state.budget = { level: 'OK', limit }
27    redraw(state)
28    return next(e)
29  })
30
31  on('session.measure', async ($, e, next) => {
32    const result = await next(e)
33    const usd = e.cost?.usd
34    if (typeof usd !== 'number' || !Number.isFinite(usd) || usd < 0) return result
35
36    const level = alertLevel(usd / limit)
37    const raised = isRaised(announced, level)
38    if (raised) announced = level
39    state.budget = { level, usd, limit }
40    if (isRaised(state.rollup.rung, level)) state.rollup.rung = level // the session rollup keeps the highest rung reached
41    if (raised) {
42      // INFO and WARNING are heads-up; CRITICAL and HARD_STOP are errors, which the toast policy never drops (ADR-477).
43      await state.say({ level: level === 'INFO' ? 'info' : level === 'WARNING' ? 'warn' : 'error', text: `ruflo budget ${level}: $${usd.toFixed(2)} of $${limit.toFixed(2)} this session`, timeoutMs: 8000 })
44      redraw(state)
45    }
46    return result
47  })
48
49  if (options.costHardStop) {
50    on('agent.spawn', ($, e, next) =>
51      state.budget.level === 'HARD_STOP'
52        ? { deny: `ruflo budget: $${(state.budget.usd ?? 0).toFixed(2)} of $${limit.toFixed(2)} spent; new agents are halted (costHardStop)` }
53        : next(e),
54    )
55  }
56}
57
hooks/delivery/index.ts 39 lines
1import type { On } from 'claude-code'
2
3import type { ModState } from '../state'
4import { isScreenedOrigin, screenInbound, screenOutbound } from './screen'
5
6/**
7 * `session.receive` and `session.send` (ADR-451 item 3): an in-process
8 * pattern screen for what reaches the session from a peer or relay, and for
9 * what leaves it for another agent. Off unless `deliveryScreen` is on.
10 *
11 * Tighten-only: an inbound hit is consumed (nothing is queued, shown or read
12 * by the model), an outbound hit is refused; text is never rewritten and never
13 * allowed past another hook's refusal (the screen only answers without `next`
14 * on a hit). The person's own prompts (Remote Control, scheduled triggers) and
15 * the lead's messages are not screened. The rule id is named, never the
16 * matched text. Any failure passes the message on.
17 */
18export function registerDelivery(on: On, state: ModState) {
19  const d = state.delivery
20  d.enabled = true
21
22  on('session.receive', async ($, e, next) => {
23    const rule = isScreenedOrigin(e.origin.kind) ? screenInbound(e.text) : undefined
24    if (!rule) return next(e)
25    d.consumed += 1
26    await state.say({ level: 'error', text: `ruflo deliveryScreen: dropped a ${e.origin.kind} delivery (rule: ${rule})` })
27    return { consumed: `ruflo deliveryScreen: ${rule}` }
28  }).catch(($, e, next) => next(e)) // fail open
29
30  on('session.send', async ($, e, next) => {
31    const rule = screenOutbound(e.text)
32    if (!rule) return next(e)
33    d.blocked += 1
34    await state.say({ level: 'error', text: `ruflo deliveryScreen: held back an outgoing message (rule: ${rule})` })
35    return { isDelivered: false, reason: `ruflo deliveryScreen refused to send this message (${rule}); remove it and send again` }
36  }).catch(($, e, next) => next(e))
37}
38
39
hooks/describe/index.ts 24 lines
1import type { On } from 'claude-code'
2
3import type { ModState } from '../state'
4import { withHint } from './hints'
5
6/**
7 * `tool.describe` (ADR-451): adds one static usage hint to the description of
8 * a few ruflo MCP tools, once per tool per session. Off unless the
9 * `toolHints` option is on. It only appends text: placement (`isDeferred`)
10 * and the engine's own description are kept, and a tool not in the hint
11 * table, or from any other server, is left exactly as it came.
12 */
13export function registerDescribe(on: On, state: ModState) {
14  state.toolHints.enabled = true
15
16  on('tool.describe', { tool: /^mcp__(?:plugin_ruflo-core_ruflo|claude-flow|ruflo)__/ }, async (_$, e, next) => {
17    const result = await next(e)
18    const description = withHint(e.tool, result.description)
19    if (description === result.description || typeof description !== 'string') return result
20    state.toolHints.described.add(e.tool)
21    return { ...result, description }
22  })
23}
24
hooks/guard/index.ts 87 lines
1import type { On } from 'claude-code'
2
3import { cachedFile, type Read } from '../files'
4import type { GuidanceHooks } from '../guidance'
5import { redraw, under, type ModState } from '../state'
6import { dangerousCommandVerdict } from './dangerous-command'
7import { parseProjection, policyOpinion, PROJECTION_PATH, type Projection } from './policy'
8import { researchCheck } from './research'
9import { stricter, type Verdict } from './verdict'
10
11/** What `tool.check` answers when ruflo's own check could not run. */
12export const CHECK_FAILED: Verdict = {
13  decision: 'ask',
14  reason: 'ruflo: its policy check could not run, so this call is put to you instead of allowed',
15}
16
17/**
18 * Ruflo's opinion on one call, from the dangerous-command list and the read
19 * policy projection; `observed` is set when observe mode would have acted.
20 */
21export function opinionOf(
22  state: ModState,
23  read: Read<Projection>,
24  tool: string,
25  input: unknown,
26): { verdict?: Verdict; observed?: string } {
27  const danger = dangerousCommandVerdict(tool, input)
28  if (danger) return { verdict: danger }
29
30  if (read.kind === 'absent') {
31    state.policy = 'none'
32    return {}
33  }
34  if (read.kind === 'error') {
35    // The CLI writes a projection only where policy is in force: one that
36    // exists and cannot be read fails closed, by one step.
37    state.policy = 'unreadable'
38    return { verdict: { ...CHECK_FAILED, reason: `ruflo: policy projection unreadable (${read.message}); asking instead of allowing` } }
39  }
40  state.policy = read.value.mode
41  const { verdict, wouldBe } = policyOpinion(read.value, tool, input)
42  return wouldBe ? { observed: wouldBe } : { verdict }
43}
44
45/**
46 * `tool.check`: tighten only. The chain runs first (engine rules, mode, the
47 * PreToolUse hooks, every plugin beneath); ruflo's opinion is merged with
48 * `stricter`, so an allow may become an ask or a deny and nothing ever
49 * becomes looser. The dangerous-command list always applies; ruflo policy
50 * applies when the CLI projected rules for Claude Code tools.
51 */
52export function registerGuard(on: On, state: ModState, guidance?: GuidanceHooks) {
53  const projection = cachedFile(() => under(state, PROJECTION_PATH), parseProjection)
54  const research = researchCheck(state)
55
56  on('tool.check', async ($, e, next) => {
57    const task = guidance?.active()
58    const chain = await next(e)
59    const tool = typeof e.tool === 'string' ? e.tool : ''
60    const read = await projection({ stat: path => $.fs.stat(path), read: path => $.fs.read(path) })
61    const { verdict, observed } = opinionOf(state, read, tool, e.input)
62    if (observed) {
63      state.observed++
64      try {
65        $.ui.log(`ruflo policy (observe): ${tool} ${observed}`, { to: 'debug' })
66      } catch {
67        // a refused log never turns an observation into a failure
68      }
69    }
70    // The research guard (ADR-440) sees what ruflo's own opinion left standing.
71    const merged = await research({ stat: path => $.fs.stat(path), read: path => $.fs.read(path) }, tool, stricter(chain, verdict))
72    if (merged !== chain) {
73      state.tightened++
74      redraw(state)
75    }
76    guidance?.check(task, merged.decision)
77    return merged
78  }).catch(async ($, e, next) => {
79    // ruflo could not judge. Fail closed by one step: the chain's verdict
80    // stands where it is already ask or deny; an allow is put to the person.
81    const chain = await next(e).catch(() => undefined)
82    const result = chain ? stricter(chain, CHECK_FAILED) : CHECK_FAILED
83    guidance?.check(guidance.active(), result.decision)
84    return result
85  })
86}
87
hooks/guidance/index.ts 87 lines
1import { cachedFile, type FileHost } from '../files'
2import type { ModOptions } from '../options'
3import { under, type ModState } from '../state'
4import { finishTask, flushObservations, newRunId, OBSERVATIONS_DIR, type ActiveTask } from './observations'
5import { MAX_PROJECTION_BYTES, parseProjection, PROJECTION_PATH, selectGuidance } from './projection'
6
7export type GuidanceHooks = {
8  start: () => void
9  prompt: (text: string, fs: FileHost) => Promise<string | undefined>
10  active: () => ActiveTask | undefined
11  check: (task: ActiveTask | undefined, decision: unknown) => void
12  tool: (task: ActiveTask | undefined, event: unknown, result: { deny?: unknown; isError?: boolean }) => void
13  complete: (turnId: unknown, isAborted?: boolean) => void
14  end: () => void
15  flush: (fs: { read: (path: string) => Promise<string>; write: (path: string, text: string) => Promise<void> }) => Promise<void>
16}
17
18/**
19 * Optional feature helpers composed INSIDE the existing hooks. Claude Code
20 * permits one unmatched handler per event per plugin. No second tool, prompt,
21 * session or turn handler is registered, and no new host capability is added.
22 */
23export function createGuidance(state: ModState, options: ModOptions): GuidanceHooks | undefined {
24  if (!options.guidanceContext && !options.guidanceLearning) return undefined
25  const s = state.guidance
26  const projection = cachedFile(() => under(state, PROJECTION_PATH), parseProjection)
27
28  return {
29    start() {
30      if (!s.runId) { s.runId = newRunId(); s.status = 'missing' }
31    },
32    async prompt(text, fs) {
33      if (!s.runId) return undefined
34      if (options.guidanceLearning) finishTask(s, 'interrupted')
35      const read = await projection({
36        stat: async path => {
37          const stat = await fs.stat(path)
38          if (stat.isLink || stat.size > MAX_PROJECTION_BYTES) throw new Error('invalid guidance file')
39          return stat
40        },
41        read: fs.read,
42      })
43      s.status = read.kind === 'ok' ? 'ready' : read.kind === 'absent' ? 'missing' : 'unreadable'
44      if (read.kind !== 'ok') return undefined
45      const selected = selectGuidance(text, read.value)
46      if (options.guidanceLearning) {
47        s.active = { taskId: ++s.taskSeq, projection: read.value, ruleIds: options.guidanceContext ? selected.ids : [], checks: { allow: 0, ask: 0, deny: 0 }, tools: { ok: 0, error: 0, denied: 0 }, toolIds: new Set(), idsDropped: 0 }
48      }
49      return options.guidanceContext ? selected.context : undefined
50    },
51    active: () => s.active,
52    check(task, decision) {
53      if (task && (decision === 'allow' || decision === 'ask' || decision === 'deny')) task.checks[decision]++
54    },
55    tool(task, event, result) {
56      if (!task) return
57      try {
58        const id = (event as { tool_use_id?: unknown }).tool_use_id
59        if (typeof id === 'string') {
60          if (task.toolIds.has(id)) return
61          if (task.toolIds.size >= 256) { task.idsDropped++; return }
62          task.toolIds.add(id)
63        }
64        if (result.deny !== undefined) task.tools.denied++
65        else if (result.isError === true) task.tools.error++
66        else task.tools.ok++
67      } catch {
68        // Observation failure preserves the underlying tool's settled result.
69      }
70    },
71    complete(turnId, isAborted) {
72      if (!options.guidanceLearning || !s.runId) return
73      if (typeof turnId === 'string') {
74        if (s.seenTurns.has(turnId)) return
75        if (s.seenTurns.size < 256) s.seenTurns.add(turnId)
76      }
77      finishTask(s, isAborted ? 'aborted' : 'completed')
78    },
79    end() {
80      if (options.guidanceLearning && s.runId) finishTask(s, 'interrupted')
81    },
82    async flush(fs) {
83      if (options.guidanceLearning && s.runId) await flushObservations(s, under(state, `${OBSERVATIONS_DIR}/${s.runId}.json`), fs)
84    },
85  }
86}
87
hooks/learn/index.ts 83 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { isMissing } from '../files'
4import type { GuidanceHooks } from '../guidance'
5import { redraw, under, type ModState } from '../state'
6import { appendRecords, EDIT_TOOLS, editedFile, MAX_LINES, PENDING_PATH, rufloSessionId, SESSION_PATH } from './insights'
7
8/** Written early once this many edits wait, so a long turn holds little. */
9const FLUSH_AT = 50
10
11/**
12 * Writes the waiting edit records to pending-insights.jsonl. A file that
13 * exists but cannot be read is never overwritten: the records wait (capped)
14 * and the classic consolidator's file stays as it was.
15 */
16export async function flushEdits($: EngineInterface, state: ModState): Promise<void> {
17  if (state.edits.length === 0) return
18  const records = state.edits.splice(0)
19  try {
20    const session = await $.fs.read(under(state, SESSION_PATH)).catch(() => undefined)
21    const sessionId = rufloSessionId(session)
22    const existing = await $.fs.read(under(state, PENDING_PATH)).catch((error: unknown) => {
23      if (isMissing(error)) return ''
24      throw error
25    })
26    await $.fs.write(under(state, PENDING_PATH), appendRecords(existing, records.map(r => ({ ...r, sessionId }))))
27  } catch (error) {
28    state.edits.unshift(...records)
29    state.edits.splice(0, Math.max(0, state.edits.length - MAX_LINES))
30    try {
31      $.ui.log(`ruflo mods: edit records not written yet (${String((error as Error)?.message ?? error)})`, { to: 'debug' })
32    } catch {
33      // nothing more to do: the records wait for the next turn
34    }
35  }
36}
37
38/**
39 * `tool.call` on the edit tools: hook-handler.cjs `post-edit` in-process,
40 * recording each finished edit (a failed one with `success: false`, ADR-174;
41 * a denied one never ran, as PostToolUse never fires for it). Written per
42 * turn at `turn.complete`, and at `session.end`.
43 */
44export function registerLearn(on: On, state: ModState, guidance?: GuidanceHooks) {
45  on('tool.call', async ($, e, next) => {
46    const task = guidance?.active()
47    const result = await next(e)
48    guidance?.tool(task, e, result)
49    if (!state.owned.has('post-edit') || !EDIT_TOOLS.has(e.tool) || result.deny !== undefined) return result
50
51    state.edits.push({
52      type: 'edit',
53      file: editedFile(e),
54      success: result.isError !== true,
55      timestamp: Date.now(),
56      sessionId: null,
57    })
58    state.editCount++
59    redraw(state)
60    if (state.edits.length >= FLUSH_AT) await flushEdits($, state)
61    return result
62  })
63
64  on('turn.complete', async ($, e, next) => {
65    const result = await next(e)
66    if (guidance) {
67      guidance.complete(e.turnId, e.isAborted)
68      await guidance.flush({ read: path => $.fs.read(path), write: (path, text) => $.fs.write(path, text) })
69    }
70    await flushEdits($, state)
71    return result
72  })
73
74  on('session.end', async ($, e, next) => {
75    if (guidance) {
76      guidance.end()
77      await guidance.flush({ read: path => $.fs.read(path), write: (path, text) => $.fs.write(path, text) })
78    }
79    await flushEdits($, state)
80    return next(e)
81  })
82}
83
hooks/noun.ts 68 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import type { RufloSnapshot } from '../types'
4import { redraw, setSegment, sortedSegments, type ModState } from './state'
5import { bindToasts } from './toast'
6
7/** The snapshot `$.ruflo.snapshot()` answers: copies, never live state. */
8export function snapshotOf(s: ModState): RufloSnapshot {
9  return {
10    owned: [...s.owned],
11    routed: s.routed,
12    lastRoute: s.lastRoute ? { ...s.lastRoute } : null,
13    policy: s.policy,
14    tightened: s.tightened,
15    observed: s.observed,
16    edits: s.editCount,
17    ...(s.budget.limit !== undefined
18      ? { budget: { level: s.budget.level, ...(s.budget.usd !== undefined ? { usd: s.budget.usd } : {}), limit: s.budget.limit } }
19      : {}),
20    segments: sortedSegments(s).map(([id, text]) => ({ id, text })),
21  }
22}
23
24/**
25 * `engine.create`: adds `$.ruflo` (contract: ../types/index.d.ts) and takes
26 * the status line drawer from the `$` built beneath, so every redraw goes
27 * through one place and a refused `ui.status` never fails a hook.
28 *
29 * Another plugin's `$.ruflo.<method>(input)` runs as an event through the
30 * chain, these methods its core; where this mod is not seated the noun is
31 * absent and such a call rejects, which its callers catch.
32 */
33export function registerNoun(on: On, state: ModState) {
34  on('engine.create', async ($, e, next) => {
35    const beneath = await next(e)
36
37    state.draw = text => {
38      try {
39        beneath.ui.status(text)
40      } catch {
41        // an admin may withhold ui.status: the line simply is not drawn
42      }
43    }
44
45    bindToasts(state, {
46      now: () => beneath.clock.now(),
47      show: (line, options) => beneath.ui.toast(line, options),
48      after: (ms, fn) => beneath.clock.after(ms, fn),
49      read: path => beneath.fs.read(`${state.root}/${path}`),
50      write: (path, text) => beneath.fs.write(`${state.root}/${path}`, text),
51      exists: path => beneath.fs.exists(`${state.root}/${path}`),
52    })
53
54    const ruflo: EngineInterface['ruflo'] = {
55      segment: async input => {
56        setSegment(state, input)
57        redraw(state)
58      },
59      lastRoute: async () => (state.lastRoute ? { ...state.lastRoute } : null),
60      snapshot: async () => snapshotOf(state),
61    }
62
63    // Beneath wins: if another step already added `ruflo`, ours never replaces it.
64    const added = { ruflo }
65    return { ...added, ...beneath }
66  })
67}
68
hooks/options.ts 62 lines
1import type { PluginOptions } from 'claude-code'
2
3import { budgetOf } from './cost/budget'
4import type { TrustPolicy } from './trust'
5
6/** The plugin's `userConfig` options, validated: a bad value is the default. */
7export type ModOptions = {
8  readonly routeContext: boolean
9  readonly guidanceContext: boolean
10  readonly guidanceLearning: boolean
11  readonly statusLine: boolean
12  readonly costBudgetUsd?: number
13  readonly costHardStop: boolean
14  readonly toolHints: boolean
15  readonly agentTrim: boolean
16  readonly agentTrimKeep: ReadonlySet<string>
17  readonly deliveryScreen: boolean
18  readonly compactCarry: boolean
19  readonly capabilityProbe: boolean
20  readonly sessionRollup: boolean
21  readonly modTrust: TrustPolicy
22  readonly modTrustAllow: ReadonlySet<string>
23}
24
25const bool = (value: unknown, fallback: boolean) =>
26  value === true || value === 'true' ? true : value === false || value === 'false' ? false : fallback
27
28const TRUST: readonly TrustPolicy[] = ['observe', 'refuse-risky', 'off']
29
30/** Plugin ids (`name@marketplace`), from a comma list or a string array; anything else is none. */
31function names(value: unknown): ReadonlySet<string> {
32  const list = typeof value === 'string' ? value.split(',') : Array.isArray(value) ? value : []
33  return new Set(list.filter((v): v is string => typeof v === 'string').map(v => v.trim()).filter(v => /^[A-Za-z0-9._-]{1,64}@[A-Za-z0-9._-]{1,64}$/.test(v)))
34}
35
36export function readOptions(options: PluginOptions | undefined): ModOptions {
37  const o = options ?? {}
38  return {
39    routeContext: bool(o.routeContext, true),
40    guidanceContext: bool(o.guidanceContext, false),
41    guidanceLearning: bool(o.guidanceLearning, false),
42    statusLine: bool(o.statusLine, true),
43    costBudgetUsd: budgetOf(o.costBudgetUsd),
44    costHardStop: bool(o.costHardStop, false),
45    toolHints: bool(o.toolHints, false),
46    agentTrim: bool(o.agentTrim, false),
47    agentTrimKeep: keepNames(o.agentTrimKeep),
48    deliveryScreen: bool(o.deliveryScreen, false),
49    compactCarry: bool(o.compactCarry, false),
50    capabilityProbe: bool(o.capabilityProbe, false),
51    sessionRollup: bool(o.sessionRollup, false),
52    modTrust: TRUST.includes(o.modTrust as TrustPolicy) ? (o.modTrust as TrustPolicy) : 'observe',
53    modTrustAllow: names(o.modTrustAllow),
54  }
55}
56
57/** Agent type names to never hide, from a comma list or a string array; lower case, plain names only. */
58function keepNames(value: unknown): ReadonlySet<string> {
59  const list = typeof value === 'string' ? value.split(',') : Array.isArray(value) ? value : []
60  return new Set(list.filter((v): v is string => typeof v === 'string').map(v => v.trim().toLowerCase()).filter(v => /^[a-z0-9._:-]{1,64}$/.test(v)))
61}
62
hooks/probe/index.ts 88 lines
1import type { On } from 'claude-code'
2
3import type { ModOptions } from '../options'
4import type { ModState } from '../state'
5
6/**
7 * Capability probe (ADR-451 item 5): observability only. Off unless
8 * `capabilityProbe` is on. It counts which of the events this module registered
9 * fired, and keeps the engine version `$.session.version()`
10 * reports, so `/ruflo-mods` can say "registered but never fired" when a build
11 * renames or withholds an event. It reads no event payload, answers nothing,
12 * writes nothing of its own and never changes, delays or denies a hook: each
13 * event is handed on unchanged.
14 */
15export type ProbeState = {
16  enabled: boolean
17  /** Engine version as `$.session.version()` gave it; undefined when the build does not expose it. */
18  version?: string
19  /** Event names the module registered (pattern strings, ours, never payload). */
20  registered: Set<string>
21  fired: Map<string, number>
22}
23
24export const probeState = (): ProbeState => ({ enabled: false, registered: new Set(), fired: new Map() })
25
26const MAX_LINE = 240
27
28/**
29 * The events this module registers, by name. The engine reads `on` calls
30 * statically (it refuses a wrapped or aliased `on`), so the list is kept here;
31 * tests/probe.test.ts fails when it drifts from what register() hooks under any option combination.
32 */
33const ALWAYS = ['command.run', 'engine.create', 'prompt.submit', 'session.end', 'session.start', 'tool.call', 'tool.check', 'turn.complete'] as const
34
35type EventOptions = Pick<ModOptions, 'toolHints' | 'agentTrim' | 'deliveryScreen'> &
36  Partial<Pick<ModOptions, 'compactCarry' | 'costBudgetUsd' | 'costHardStop' | 'sessionRollup' | 'modTrust'>>
37
38export function registeredEvents(opts: EventOptions): string[] {
39  const names: string[] = [...ALWAYS]
40  // Registered only under these options, so "never fired" is not reported for an event the mod did not hook.
41  if (opts.modTrust !== 'off') names.push('plugin.register')
42  if (opts.costBudgetUsd !== undefined) names.push('session.measure')
43  if ((opts.costBudgetUsd !== undefined && opts.costHardStop) || opts.sessionRollup || opts.agentTrim) names.push('agent.spawn')
44  if (opts.toolHints) names.push('tool.describe')
45  if (opts.agentTrim) names.push('agent.offer')
46  if (opts.deliveryScreen) names.push('session.receive', 'session.send')
47  if (opts.compactCarry) names.push('session.compact')
48  return names.sort()
49}
50
51/**
52 * One pass-through hook on every event: it counts the events the module
53 * registered and hands the event on unchanged, answering nothing. Any failure
54 * of its own passes the event on.
55 */
56export function registerProbe(on: On, state: ModState, opts: ModOptions) {
57  state.probe.enabled = true
58  for (const name of registeredEvents(opts)) state.probe.registered.add(name)
59  on('*', ($, e, next) => {
60    try {
61      const name = (next as { event?: unknown }).event
62      if (typeof name === 'string' && state.probe.registered.has(name)) state.probe.fired.set(name, (state.probe.fired.get(name) ?? 0) + 1)
63    } catch {
64      // counting must not change what any hook does
65    }
66    return next(e)
67  }).catch(($, e, next) => next(e))
68}
69
70/** The version string kept from `$.session.version()`: printable, short; anything else is "not exposed". */
71export function versionText(value: unknown): string | undefined {
72  const { version, base } = (value ?? {}) as { version?: unknown; base?: unknown }
73  const text = typeof base === 'string' && base ? base : version
74  return typeof text === 'string' && /^[A-Za-z0-9._+-]{1,40}$/.test(text) ? text : undefined
75}
76
77/** The bounded status text the `probe:` row of `/ruflo-mods` shows (no label of its own: the report adds it). */
78export function probeLine(probe: ProbeState): string {
79  if (!probe.enabled) return 'off (set the capabilityProbe option)'
80  const names = [...probe.registered].sort()
81  const fired = names.filter(n => (probe.fired.get(n) ?? 0) > 0).length
82  const never = names.filter(n => !probe.fired.get(n))
83  const engine = probe.version ? `engine ${probe.version}` : 'engine version not exposed'
84  const tail = never.length ? ` · never fired: ${never.join(', ')}` : ''
85  const line = `${engine} · events fired ${fired}/${names.length}${tail}`
86  return line.length > MAX_LINE ? `${line.slice(0, MAX_LINE - 1)}…` : line
87}
88