SLOPSHOPPER

agentic-qe-fleet

PACTS-based agentic quality engineering fleet — 11 specialized QE agents, sublinear coverage analysis, chaos/resilience testing, and TDD skills for Claude Code

newguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agentic-qe-fleet
› 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 › /aqe-mod ⎿ agentic-qe-fleet: /aqe-mod status guard mode and this session's counts ⎿ agentic-qe-fleet: /aqe-mod check <command> would the guard refuse this shell command? (nothing runs) ⎿ agentic-qe-fleet: /aqe-mod fleet AQE data files present and AQE MCP tools connected ⎿ agentic-qe-fleet: /aqe-mod gate what is known about the last quality gate ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

agentic-qe-fleet

PACTS-based agentic quality engineering fleet for Claude Code. This is a slim starter bundle of the AQE platform. It has 11 specialized QE agents, 9 core skills, 9 slash commands, and the agentic-qe MCP server, pinned to the plugin version.

Install

In one step (Claude Code 2.1.275 or later):

/plugin install agentic-qe-fleet --marketplace proffesor-for-testing/agentic-qe

Or add the marketplace first:

/plugin marketplace add proffesor-for-testing/agentic-qe
/plugin install agentic-qe-fleet@agentic-qe

The install registers the MCP server plugin:agentic-qe-fleet:agentic-qe, which runs npx -y agentic-qe@<plugin version> mcp. You don't need to run claude mcp add. Check it with claude mcp list.

Options (userConfig)

Set these with /plugin configure agentic-qe-fleet@agentic-qe, or pass --config KEY=VALUE to claude plugin install. Every option has a default, so the server starts even if you leave them unset.

OptionSetsDefaultEffect
llm_providerAQE_LLM_PROVIDERempty (auto)Pins the LLM provider, e.g. claude-code, claude, openrouter or ollama. If it's empty, the provider comes from llm-config.json or env auto-detection.
max_budget_usdAQE_MAX_BUDGET_USD0 (no cap)Per-run spend cap for metered providers.
memory_backendAQE_MEMORY_BACKENDhybridhybrid saves learning to .agentic-qe/memory.db. memory is database-free mode: same features, but nothing is written under .agentic-qe/.

What's Included

Agents (11)

Agents are routed by cognitive load: heavy reasoning runs on Opus, focused execution on Sonnet.

AgentModelPurpose
qe-test-architectopusAI-powered test generation with sublinear optimization
qe-fleet-commanderopusFleet lifecycle and workload distribution
qe-security-scanneropusSAST/DAST/dependency/secrets scanning
qe-chaos-engineeropusControlled fault injection and resilience testing
qe-regression-analyzeropusIntelligent test selection and change-impact scoring
qe-requirements-validatoropusTestability analysis and BDD scenario generation
qe-coverage-specialistsonnetO(log n) sublinear coverage analysis with risk-weighted gap detection
qe-flaky-huntersonnetFlaky test detection and auto-stabilization
qe-performance-testersonnetLoad, stress, endurance, regression detection
qe-quality-gatesonnetQuality gate enforcement with policy validation
qe-tdd-specialistsonnetRed-Green-Refactor (London + Chicago schools)

Skills (9)

Each skill has a trust tier and a scoped allowed-tools list with no wildcards.

  • Tier 3: full eval infrastructure (eval YAML, JSON schema and validator).
  • Tier 2: tested, but without an eval.

The bundle leaves out tier-1 (untested) skills, per AQE trust-tier policy.

SkillTrust tierMCP tools
qe-test-generation3test_generate_enhanced
qe-coverage-analysis3coverage_analyze_sublinear, qe_coverage_gaps
qe-test-execution3test_execute_parallel
qe-chaos-resilience3chaos_test
qe-quality-assessment3quality_assess
chaos-engineering-resilience3— (methodology guide)
mutation-testing3— (Stryker integration)
risk-based-testing3— (read-only analysis)
tdd-london-chicago2— (TDD school comparison)

MCP tool names. A server that comes from this plugin exposes its tools as mcp__plugin_agentic-qe-fleet_agentic-qe__<tool>. A project set up with aqe init registers the server under the name agentic-qe, which gives mcp__agentic-qe__<tool>. Each skill lists both forms in allowed-tools, so it works with either setup. See ADR-0001.

Commands (9)

/aqe-analyze, /aqe-benchmark, /aqe-chaos, /aqe-costs, /aqe-execute, /aqe-fleet-status, /aqe-generate, /aqe-optimize, /aqe-report

Requires

  • Node.js >= 22.13.0, with npx on PATH. The MCP server runs through npx.
  • Network access the first time the server starts, so npx can fetch the pinned agentic-qe package. After that it uses the npm cache.
  • No other plugin. The plugin ships its own MCP server.

Compatibility

  • Versioning: the plugin version always equals the agentic-qe npm package version, and .mcp.json pins the server to that exact version (agentic-qe@<version>, never @latest). The release flow bumps all of these together: npm version runs scripts/sync-plugin-versions.cjs.
  • Coexisting with aqe init: a project that has both the plugin and aqe init runs two agentic-qe server processes, one under each name. Both work. To keep only one, disable the plugin in that project or remove agentic-qe from the project .mcp.json.
  • Verification: bash plugins/agentic-qe-fleet/scripts/smoke.sh is the contract.

Namespace coordination

The fleet uses the AQE memory namespaces below. They are database identifiers, not filesystem paths, and other plugins MUST NOT shadow them.

  • aqe/v3/domains/<domain>/*: per-domain data, for example:
  • aqe/v3/domains/test-generation/patterns/
  • aqe/v3/domains/coverage-analysis/metrics/
  • aqe/v3/domains/test-execution/flaky/
  • aqe/v3/domains/security-compliance/scans/
  • aqe/v3/queen/*: fleet coordination (aqe/v3/queen/fleet/, aqe/v3/queen/tasks/).

With memory_backend=hybrid, they persist to .agentic-qe/memory.db in the project. With memory_backend=memory, they last only for the server process.

Test locally

claude --plugin-dir ./plugins/agentic-qe-fleet

Then run, for example, /aqe-fleet-status.

Verification

claude plugin validate plugins/agentic-qe-fleet
bash plugins/agentic-qe-fleet/scripts/smoke.sh     # or: npm run plugin:smoke
# Expected: "PASS: agentic-qe-fleet contract holds"
node scripts/sync-plugin-versions.cjs --check      # plugin versions == package.json

Architecture Decisions

As a mod (Claude Code >= 2.1.287)

The plugin ships a hooks module, aqe-mod (hooks/hooks.json -> hooks/register.ts), which loads with the plugin by default.

  • Learning-data guard (on by default). It refuses tool calls that would destroy AQE's learning data:
  • rm, mv, truncate, shred, dd, tee or a > redirect on .agentic-qe/*.db (and -wal, -shm, *.rvf)
  • rm -rf .agentic-qe, find .agentic-qe ... -delete, git clean -x
  • sqlite3 .agentic-qe/memory.db with DROP TABLE, DELETE FROM, TRUNCATE or .restore
  • Write/Edit on those files

Reads and backups pass: cp .agentic-qe/memory.db x.bak, SELECT, PRAGMA integrity_check, .backup. The guard only refuses and never loosens anything. Set the plugin option guardMode to enforce (default), notify or off.

  • /aqe-mod: status, check <command> (a dry run of the guard's verdict), fleet (the learning-data files present and the AQE MCP tools connected), gate. Anything only the AQE MCP server knows is reported as unknown, never estimated.
  • Console status. Writes .claude-flow/aqe-mod/status.json (version 1: mode, calls, blocked, lastDenied) for the ruflo console's Mods section. When ruflo-mods is loaded, it also adds an aqe segment to ruflo's status bar.

Tests: claude plugin test plugins/agentic-qe-fleet and bash plugins/agentic-qe-fleet/scripts/smoke-mod.sh.

Source 6 files
hooks/register.ts 114 lines
1import type { Hook, Register } from 'claude-code'
2
3import { answer, type DataFile } from './command'
4import { isGuarded, judge } from './guard'
5import { readOptions, type GuardMode } from './options'
6import { MOD_NAME, newStats, segmentText, STATUS_PATH, statusText, type Stats } from './status'
7
8type Dollar = Parameters<Hook<'session.start'>>[0]
9
10/** One session of the mod: its mode, its counters, the project root, the segment last drawn. */
11type Session = { readonly mode: GuardMode; readonly stats: Stats; root?: string; segment?: string }
12
13/** Writes the console's status file; a failure only costs the console a row. */
14async function flush($: Dollar, s: Session): Promise<void> {
15  if (s.root === undefined) return
16  try {
17    await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, s.mode, await $.clock.now()))
18  } catch {
19    /* the status file is a courtesy */
20  }
21  const text = segmentText(s.stats, s.mode)
22  if (text === s.segment) return
23  try {
24    await $.ruflo.segment({ id: 'aqe', text })
25    s.segment = text
26  } catch {
27    /* ruflo-mods is not seated: no status-bar segment */
28  }
29}
30
31async function aqeDir($: Dollar, root: string | undefined): Promise<readonly DataFile[] | null> {
32  if (root === undefined) return null
33  const dir = `${root}/.agentic-qe`
34  if (!(await $.fs.exists(dir))) return null
35  return (await $.fs.list(dir)).filter(f => f.kind === 'file').map(f => ({ name: f.name, size: f.size, mtimeMs: f.mtimeMs }))
36}
37
38/**
39 * agentic-qe as a mod (aqe-mod): a tighten-only guard for AQE's irreplaceable
40 * learning data, `/aqe-mod`, and the status file the ruflo console reads.
41 * Everything goes through `$`; no network, no process, no MCP calls.
42 */
43export const register: Register = (on, options) => {
44  const s: Session = { mode: readOptions(options).mode, stats: newStats() }
45
46  on('session.start', async ($, e, next) => {
47    const result = await next(e)
48    try {
49      s.root = (await $.session.root()) as string | undefined
50      s.stats.startedMs = await $.clock.now()
51    } catch {
52      /* no root: no status file, the guard still runs */
53    }
54    try {
55      await $.command.register({ name: MOD_NAME, description: 'AQE mod: status, check <command>, fleet, gate' })
56    } catch {
57      /* a name taken by another plugin must not stop the guard */
58    }
59    await flush($, s)
60    return result
61  })
62
63  // Tighten-only: a deny or the event unchanged; never a rewrite, never an allow over another hook.
64  on('tool.call', async ($, e, next) => {
65    if (s.mode === 'off' || !isGuarded(e.tool)) return next(e)
66    s.stats.calls++
67    const refusal = judge(e.tool, e)
68    if (refusal === undefined) return next(e)
69    s.stats.lastDenied = refusal.cls
70    if (s.mode === 'enforce') {
71      s.stats.blocked++
72      await flush($, s)
73      return { deny: refusal.reason }
74    }
75    s.stats.flagged++
76    try {
77      $.ui.toast('aqe-mod (notify): this call would delete or overwrite .agentic-qe learning data')
78    } catch {
79      /* a refused toast changes nothing */
80    }
81    await flush($, s)
82    return next(e)
83  }).catch(($, e, next) => {
84    // A guard that failed refuses only what its own pure check refuses; everything else goes on.
85    if (next.called) return next(e)
86    if (s.mode !== 'enforce' || !isGuarded(e.tool)) return next(e)
87    try {
88      const refusal = judge(e.tool, e)
89      return refusal === undefined ? next(e) : { deny: refusal.reason }
90    } catch {
91      return { deny: 'aqe-mod: the learning-data guard failed on this call, so it was refused.' }
92    }
93  })
94
95  on('turn.complete', async ($, e, next) => {
96    const result = await next(e)
97    await flush($, s)
98    return result
99  })
100
101  /** `/aqe-mod`, answered here: read-only verbs, no model turn. */
102  on('command.run', { command: 'aqe-mod' }, async ($, e) => {
103    const args = typeof e.args === 'string' ? e.args : ''
104    const text = await answer(args, {
105      mode: s.mode,
106      stats: s.stats,
107      tools: async () => (await $.tool.list()).map(t => t.name),
108      aqeDir: () => aqeDir($, s.root),
109    })
110    await flush($, s)
111    return { text }
112  })
113}
114
hooks/command.ts 102 lines
1import { judgeBash } from './guard'
2import type { GuardMode } from './options'
3import { DATA_FILE } from './paths'
4import { STATUS_PATH, type Stats } from './status'
5
6/** A learning-data file as `$.fs.list` reports it. */
7export type DataFile = { readonly name: string; readonly size: number; readonly mtimeMs: number }
8
9/**
10 * What `/aqe-mod` reads: all of it local and cheap. Anything only the AQE MCP
11 * server knows (the last gate verdict, live fleet health, pattern counts) is
12 * reported as unknown, never estimated: this sandboxed mod does not call MCP.
13 */
14export type CommandDeps = {
15  readonly mode: GuardMode
16  readonly stats: Stats
17  /** Names of the tools connected now. */
18  readonly tools: () => Promise<readonly string[]>
19  /** The entries of `<root>/.agentic-qe`, or null when it is missing or unreadable. */
20  readonly aqeDir: () => Promise<readonly DataFile[] | null>
21}
22
23const HELP = [
24  '/aqe-mod status          guard mode and this session\'s counts',
25  '/aqe-mod check <command> would the guard refuse this shell command? (nothing runs)',
26  '/aqe-mod fleet           AQE data files present and AQE MCP tools connected',
27  '/aqe-mod gate            what is known about the last quality gate',
28].join('\n')
29
30/** AQE's MCP tools, by server name (`mcp__agentic-qe__*`, or the plugin-scoped `mcp__plugin_agentic-qe-fleet_...__*`). */
31export const isAqeTool = (name: string): boolean => /^mcp__(plugin_agentic-qe[^_]*_)?[^_]*agentic[-_]qe[^_]*__/i.test(name)
32
33const kb = (n: number) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`)
34
35async function aqeToolCount(deps: CommandDeps): Promise<string> {
36  try {
37    const n = (await deps.tools()).filter(isAqeTool).length
38    return n === 0 ? 'AQE MCP tools connected: none (is the agentic-qe MCP server running?)' : `AQE MCP tools connected: ${n}`
39  } catch {
40    return 'AQE MCP tools connected: unknown (the tool list was not readable)'
41  }
42}
43
44function status(deps: CommandDeps): string {
45  const { mode, stats } = deps
46  const line =
47    mode === 'enforce'
48      ? 'guard: enforce (refuses destructive operations on .agentic-qe learning data)'
49      : mode === 'notify'
50        ? 'guard: notify (allows, but warns on destructive operations on .agentic-qe learning data)'
51        : 'guard: off (nothing is checked)'
52  return [
53    line,
54    `this session: ${stats.calls} call${stats.calls === 1 ? '' : 's'} checked · ${stats.blocked} blocked · ${stats.flagged} flagged`,
55    stats.lastDenied === undefined ? 'last refusal: none' : `last refusal: ${stats.lastDenied}`,
56    `status file: ${STATUS_PATH}`,
57  ].join('\n')
58}
59
60function check(command: string): string {
61  if (command === '') return 'Usage: /aqe-mod check <shell command>'
62  const r = judgeBash(command)
63  return r === undefined ? 'allowed: the guard would let this run.' : `refused: ${r.reason}`
64}
65
66async function fleet(deps: CommandDeps): Promise<string> {
67  let files: readonly DataFile[] | null
68  try {
69    files = await deps.aqeDir()
70  } catch {
71    files = null
72  }
73  const data = (files ?? []).filter(f => DATA_FILE.test(f.name))
74  const lines =
75    files === null
76      ? ['.agentic-qe: not found in this project (run `aqe init`)']
77      : data.length === 0
78        ? ['.agentic-qe: present, no learning-data files yet']
79        : ['.agentic-qe learning data:', ...data.map(f => `  ${f.name}  ${kb(f.size)}  modified ${new Date(f.mtimeMs).toISOString()}`)]
80  return [...lines, await aqeToolCount(deps), 'fleet health: unknown here (ask the fleet_status MCP tool or run /aqe-fleet-status)'].join('\n')
81}
82
83async function gate(deps: CommandDeps): Promise<string> {
84  return [
85    'last quality-gate verdict: unknown (it is held by the AQE MCP server; this mod does not call MCP)',
86    await aqeToolCount(deps),
87    'to evaluate one: the quality_assess MCP tool, or /aqe-report',
88  ].join('\n')
89}
90
91/** `/aqe-mod`, answered locally: no model turn, and nothing it says is estimated. */
92export async function answer(args: string, deps: CommandDeps): Promise<string> {
93  const trimmed = args.trim()
94  const [verb = '', ...rest] = trimmed.split(/\s+/)
95  if (verb === '' || verb === 'help') return HELP
96  if (verb === 'status') return status(deps)
97  if (verb === 'check') return check(trimmed.slice(verb.length).trim())
98  if (verb === 'fleet') return fleet(deps)
99  if (verb === 'gate') return gate(deps)
100  return `Unknown: ${verb.slice(0, 40)}${rest.length > 0 ? ' …' : ''}\n${HELP}`
101}
102
hooks/guard.ts 251 lines
1/**
2 * The learning-data guard: decides whether one tool call would destroy or
3 * overwrite AQE's irreplaceable learning data (the CLAUDE.md "Data Protection"
4 * rule, made mechanical).
5 *
6 * Pure and synchronous: no `$`, no I/O, no imports outside this folder, so it
7 * runs unchanged in the sandbox, under `claude plugin test` and under vitest.
8 *
9 * Tighten-only by construction: `judge` returns a refusal reason or undefined.
10 * It never rewrites a call and never allows something another hook refused.
11 *
12 * Reads and backups pass: `cp .agentic-qe/memory.db x.bak`, `sqlite3 ... "SELECT ..."`,
13 * `PRAGMA integrity_check`, `.backup`, `ls`, `du`.
14 */
15import { baseName, DATA_FILE, globReachesData, protectedKind, underAqe } from './paths'
16
17/** A refusal: the reason shown to the model, and the console's fixed class for it. */
18export type Refusal = { readonly reason: string; readonly cls: 'destructive' }
19
20/** The tools whose input the guard reads; every other tool passes untouched. */
21export const GUARDED_TOOLS = ['Bash', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit'] as const
22
23const PREFIX = 'aqe-mod refused this: '
24const SUFFIX = ' AQE learning data (.agentic-qe/*.db, -wal, -shm, *.rvf) is irreplaceable. Back it up with `cp .agentic-qe/memory.db .agentic-qe/memory.db.bak-$(date +%s)` and ask the user to run this themselves, or set the plugin option guardMode to notify/off.'
25
26const refuse = (what: string): Refusal => ({ reason: `${PREFIX}${what}.${SUFFIX}`, cls: 'destructive' })
27
28const field = (input: unknown, key: string): string => {
29  const v = typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[key] : undefined
30  return typeof v === 'string' ? v : ''
31}
32
33/** Commands that take a command after them (their own options skipped). */
34const WRAPPERS = new Set(['sudo', 'doas', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'ionice', 'stdbuf', 'builtin', 'xargs', 'timeout', 'chronic', 'unbuffer'])
35/** Wrapper options that take a separate value (`sudo -u root rm ...`). */
36const VALUED = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U', '-r', '-t', '-n', '-I', '-L', '-P', '-s', '-k', '--signal', '--kill-after', '--user', '--group'])
37const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'eval'])
38const DELETERS = new Set(['rm', 'unlink', 'shred', 'srm', 'trash', 'trash-put', 'gio', 'rmdir', 'wipe'])
39const COPIERS = new Set(['cp', 'install', 'ln', 'rsync', 'scp'])
40/** Every command word the guard judges. */
41const KNOWN = new Set([...DELETERS, ...COPIERS, ...SHELLS, 'mv', 'truncate', 'dd', 'tee', 'find', 'git', 'cd', 'sqlite3'])
42/** SQL that destroys rows or schema, and sqlite dot-commands that replace the file. */
43const DESTRUCTIVE_SQL = /\b(drop\s+(table|index|view|trigger)|delete\s+from|truncate(\s+table)?\s+\w)|(^|[\s"';])\.(restore|drop)\b/i
44/** In-language deletes and overwrites (node/python/perl one-liners). */
45const CODE_DESTROY = /\b(unlinkSync|unlink|rmSync|rmdirSync|writeFileSync|truncateSync|os\.remove|os\.unlink|shutil\.rmtree|shutil\.move|Path\([^)]*\)\.unlink|File\.delete)\s*\(/i
46
47const strip = (w: string) => w.replace(/^[({]+|[)};]+$/g, '').replace(/['"\\]/g, '')
48const isOption = (w: string) => w.startsWith('-') && w !== '-'
49
50/** Splits a command line into simple commands at `;`, `&&`, `||`, `|`, `&`, newlines, backticks and `$( )`. */
51export function segments(command: string): string[] {
52  return command
53    .split(/\|\||&&|\$\(|[;|&\n`()]/)
54    .map(s => s.trim())
55    .filter(s => s !== '')
56}
57
58/** Whitespace-separated words of one simple command, quotes removed. */
59export const words = (segment: string): string[] => segment.split(/\s+/).map(strip).filter(w => w !== '')
60
61/** The command word's index, past assignments, wrappers and their options. */
62function commandStart(ws: readonly string[], from = 0): number {
63  let i = from
64  while (i < ws.length) {
65    const w = ws[i] as string
66    const name = baseName(w)
67    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) i++
68    else if (WRAPPERS.has(name)) {
69      i++
70      while (i < ws.length && (isOption(ws[i] as string) || /^\d+[smhd]?$/.test(ws[i] as string))) {
71        // `sudo -n rm x`: a valued-looking flag followed by a verb the guard judges does not swallow the verb.
72        const after = ws[i + 1]
73        i += VALUED.has(ws[i] as string) && after !== undefined && !KNOWN.has(baseName(after)) ? 2 : 1
74      }
75    } else return i
76  }
77  return i
78}
79
80/** Redirect targets (`>`, `>>`, `>|`, `&>`) anywhere in the line. */
81function redirectTargets(command: string): string[] {
82  const out: string[] = []
83  const re = />{1,2}\|?\s*(['"]?)([^\s'";|&<>()]+)\1/g
84  for (let m = re.exec(command); m !== null; m = re.exec(command)) {
85    const t = m[2] as string
86    if (!t.startsWith('&')) out.push(t)
87  }
88  return out
89}
90
91type Ctx = { inAqe: boolean; mentionsData: boolean }
92
93const isData = (w: string, ctx: Ctx) => protectedKind(w, ctx.inAqe) !== undefined
94const isDataFileOrGlob = (w: string, ctx: Ctx) => {
95  const k = protectedKind(w, ctx.inAqe)
96  return k === 'file' || k === 'glob'
97}
98
99/** One simple command, from its command word on. */
100function judgeSimple(ws: readonly string[], ctx: Ctx, viaXargs: boolean): string | undefined {
101  const start = commandStart(ws)
102  if (start >= ws.length) return undefined
103  const verb = baseName(ws[start] as string)
104  const args = ws.slice(start + 1)
105  const operands = args.filter(a => !isOption(a))
106  const piped = viaXargs || ws.slice(0, start).some(w => baseName(w) === 'xargs')
107
108  if (verb === 'cd') {
109    if (operands[0] !== undefined && underAqe(operands[0])) ctx.inAqe = true
110    return undefined
111  }
112
113  if (SHELLS.has(verb)) {
114    const c = args.indexOf('-c')
115    const inner = verb === 'eval' ? args : c === -1 ? [] : args.slice(c + 1)
116    return inner.length === 0 ? undefined : judgeSimple(inner, ctx, piped)
117  }
118
119  if (DELETERS.has(verb) || verb === 'truncate') {
120    const hit = operands.find(a => (verb === 'truncate' ? isDataFileOrGlob(a, ctx) : isData(a, ctx)))
121    if (hit !== undefined) return `\`${verb}\` on ${hit}`
122    if (piped && ctx.mentionsData) return `\`xargs ${verb}\` fed learning-data paths`
123    return undefined
124  }
125
126  if (verb === 'mv') {
127    const dest = operands[operands.length - 1]
128    const sources = operands.slice(0, -1)
129    const moved = sources.find(a => isData(a, ctx))
130    if (moved !== undefined) return `\`mv\` moves ${moved} away`
131    if (dest !== undefined && overwrites(dest, sources, ctx)) return `\`mv\` overwrites ${dest}`
132    if (piped && ctx.mentionsData) return '`xargs mv` fed learning-data paths'
133    return undefined
134  }
135
136  if (COPIERS.has(verb)) {
137    const dest = operands[operands.length - 1]
138    if (dest === undefined) return undefined
139    if (overwrites(dest, operands.slice(0, -1), ctx)) return `\`${verb}\` overwrites ${dest}`
140    if (verb === 'rsync' && args.some(a => a.startsWith('--delete')) && underAqe(dest, ctx.inAqe)) return `\`rsync --delete\` into ${dest}`
141    return undefined
142  }
143
144  if (verb === 'dd') {
145    const of = args.find(a => a.startsWith('of=') && isDataFileOrGlob(a.slice(3), ctx))
146    return of === undefined ? undefined : `\`dd ${of}\``
147  }
148
149  if (verb === 'tee') {
150    const hit = operands.find(a => isDataFileOrGlob(a, ctx))
151    return hit === undefined ? undefined : `\`tee\` overwrites ${hit}`
152  }
153
154  if (verb === 'find') return judgeFind(args, ctx)
155
156  if (verb === 'git') return judgeGit(args, ctx)
157
158  return undefined
159}
160
161/** Whether writing to `dest` replaces learning data: a data file or glob, or the directory with a data-named source. */
162function overwrites(dest: string, sources: readonly string[], ctx: Ctx): boolean {
163  const kind = protectedKind(dest, ctx.inAqe)
164  if (kind === 'file' || kind === 'glob') return true
165  return kind === 'dir' && sources.some(s => DATA_FILE.test(baseName(s)))
166}
167
168/** `find <under .agentic-qe> ... -delete` or `-exec rm`, unless every `-name` excludes data files. */
169function judgeFind(args: readonly string[], ctx: Ctx): string | undefined {
170  const roots = args.filter((a, i) => !isOption(a) && (i === 0 || !isOption(args[i - 1] as string)))
171  if (!roots.some(r => underAqe(r, ctx.inAqe))) return undefined
172  const execAt = args.findIndex(a => a === '-exec' || a === '-execdir' || a === '-ok' || a === '-okdir')
173  const execVerb = execAt === -1 ? '' : baseName(args[execAt + 1] ?? '')
174  const destroys = args.includes('-delete') || DELETERS.has(execVerb) || execVerb === 'truncate' || execVerb === 'mv' || execVerb === 'shred'
175  if (!destroys) return undefined
176  const names = args.flatMap((a, i) => (a === '-name' || a === '-iname' ? [args[i + 1] ?? '*'] : []))
177  if (names.length > 0 && !names.some(globReachesData)) return undefined
178  return '`find ... -delete/-exec` under .agentic-qe'
179}
180
181/** `git clean -x/-X` (deletes the ignored .agentic-qe) and `git checkout/restore/rm` on data files. */
182function judgeGit(args: readonly string[], ctx: Ctx): string | undefined {
183  const sub = args.find(a => !isOption(a))
184  if (sub === 'clean') {
185    const flags = args.filter(a => /^-[A-Za-z]+$/.test(a)).join('')
186    const dry = flags.includes('n') || args.includes('--dry-run')
187    const excluded = args.some((a, i) => (a === '-e' || a.startsWith('--exclude')) && /agentic-qe/i.test(a + (args[i + 1] ?? '')))
188    if (/[xX]/.test(flags) && !dry && !excluded) return '`git clean -x` deletes the git-ignored .agentic-qe directory'
189    return undefined
190  }
191  if (sub === 'checkout' || sub === 'restore' || sub === 'rm') {
192    const hit = args.find(a => isData(a, ctx))
193    return hit === undefined ? undefined : `\`git ${sub}\` on ${hit}`
194  }
195  return undefined
196}
197
198/** The refusal for one shell command line, or undefined to let it run. */
199export function judgeBash(command: string): Refusal | undefined {
200  if (command.trim() === '') return undefined
201  const segs = segments(command)
202  const allWords = segs.flatMap(words)
203  const ctx: Ctx = { inAqe: false, mentionsData: false }
204
205  // A first pass for `cd .agentic-qe` and for whether any learning-data path is named at all.
206  for (const seg of segs) {
207    const ws = words(seg)
208    const s = commandStart(ws)
209    if (baseName(ws[s] ?? '') === 'cd' && ws[s + 1] !== undefined && underAqe(ws[s + 1] as string)) ctx.inAqe = true
210  }
211  ctx.mentionsData = allWords.some(w => protectedKind(w, ctx.inAqe) !== undefined)
212  const inAqeAnywhere = ctx.inAqe
213  ctx.inAqe = false
214
215  const redirect = redirectTargets(command).find(t => {
216    const k = protectedKind(strip(t), inAqeAnywhere)
217    return k === 'file' || k === 'glob'
218  })
219  if (redirect !== undefined) return refuse(`a shell redirect overwrites ${redirect}`)
220
221  const namesDb = allWords.some(w => {
222    const k = protectedKind(w, inAqeAnywhere)
223    return k === 'file' || k === 'glob' || k === 'dir'
224  })
225  if (namesDb && DESTRUCTIVE_SQL.test(command)) return refuse('destructive SQL (DROP/DELETE FROM/TRUNCATE/.restore) against an AQE learning database')
226  if (namesDb && CODE_DESTROY.test(command)) return refuse('a script deletes or overwrites an AQE learning database')
227
228  for (const seg of segs) {
229    const what = judgeSimple(words(seg), ctx, false)
230    if (what !== undefined) return refuse(what)
231  }
232  return undefined
233}
234
235/** The refusal for a file tool writing to a learning-data file, or undefined. */
236export function judgeFileTool(path: string): Refusal | undefined {
237  const k = protectedKind(path)
238  return k === 'file' || k === 'glob' ? refuse(`a file tool writes to ${path}`) : undefined
239}
240
241/** True for the tools the guard reads. */
242export const isGuarded = (tool: string): boolean => (GUARDED_TOOLS as readonly string[]).includes(tool)
243
244/** The guard's verdict on one tool call (`input` the call's fields), or undefined to let it run. */
245export function judge(tool: string, input: unknown): Refusal | undefined {
246  if (tool === 'Bash') return judgeBash(field(input, 'command'))
247  if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') return judgeFileTool(field(input, 'file_path'))
248  if (tool === 'NotebookEdit') return judgeFileTool(field(input, 'notebook_path'))
249  return undefined
250}
251
hooks/options.ts 27 lines
1import type { PluginOptions } from 'claude-code'
2
3/**
4 * The guard's mode. `enforce` (default): refuse. `notify`: let the call run,
5 * toast and count it. `off`: read nothing.
6 */
7export type GuardMode = 'off' | 'notify' | 'enforce'
8export const GUARD_MODES: readonly GuardMode[] = ['off', 'notify', 'enforce']
9
10/** The plugin's `userConfig`, validated: a missing or unknown value is the default, enforce. */
11export type ModOptions = { readonly mode: GuardMode }
12
13/** The userConfig key this mod reads. */
14export const OPTION_KEY = 'guardMode'
15
16/** `on`/`true` read as enforce and `false` as off, so a boolean-style setting still means what it says. */
17export function asMode(value: unknown): GuardMode | undefined {
18  if (value === true || value === 'on' || value === 'true') return 'enforce'
19  if (value === false || value === 'false') return 'off'
20  return GUARD_MODES.find(m => m === value)
21}
22
23export function readOptions(options: PluginOptions | Readonly<Record<string, unknown>> | undefined): ModOptions {
24  const o = (options ?? {}) as Readonly<Record<string, unknown>>
25  return { mode: asMode(o[OPTION_KEY]) ?? 'enforce' }
26}
27
hooks/status.ts 60 lines
1import type { GuardMode } from './options'
2
3/**
4 * The status file the ruflo console's Mods section reads (ADR-446; contract in
5 * ruflo-console hooks/data/mods.ts): `.claude-flow/<name>-mod/status.json`,
6 * `version: 1`, at most 8 KB, stale after 6 hours without a write.
7 */
8export const MOD_NAME = 'aqe-mod'
9export const STATUS_DIR = `.claude-flow/${MOD_NAME}`
10export const STATUS_PATH = `${STATUS_DIR}/status.json`
11/** The console's folder rule; the smoke script and tests hold the name to it. */
12export const STATUS_DIR_RULE = /^[a-z0-9][a-z0-9-]{0,40}-mod$/
13export const STATUS_MAX_BYTES = 8192
14/** The mod's own version (the console shows it when it matches ^[0-9][0-9A-Za-z.+-]{0,15}$). */
15export const MOD_VERSION = '0.1.0'
16/** One line (at most 120 characters) for the console row. */
17export const SUMMARY = 'Guards AQE learning data (.agentic-qe/*.db, -wal, -shm, *.rvf) from rm, overwrite, truncate and DROP/DELETE'
18
19/** Counters for this session. `calls`: tool calls the guard read; `blocked`: refused; `flagged`: would have refused (notify). */
20export type Stats = { calls: number; blocked: number; flagged: number; lastDenied?: 'destructive'; startedMs?: number }
21
22export const newStats = (): Stats => ({ calls: 0, blocked: 0, flagged: 0 })
23
24/** What the file holds. Only fixed classes and counts: never a command, a path or a reason text. */
25export type StatusPayload = {
26  version: 1
27  modVersion: string
28  summary: string
29  guard: boolean
30  mode: GuardMode
31  calls: number
32  blocked: number
33  flagged: number
34  lastDenied?: 'destructive'
35  startedMs?: number
36  updatedMs: number
37}
38
39export function statusPayload(stats: Stats, mode: GuardMode, nowMs: number): StatusPayload {
40  return {
41    version: 1,
42    modVersion: MOD_VERSION,
43    summary: SUMMARY,
44    guard: mode === 'enforce',
45    mode,
46    calls: stats.calls,
47    blocked: stats.blocked,
48    flagged: stats.flagged,
49    ...(stats.lastDenied !== undefined && { lastDenied: stats.lastDenied }),
50    ...(stats.startedMs !== undefined && { startedMs: stats.startedMs }),
51    updatedMs: nowMs,
52  }
53}
54
55export const statusText = (stats: Stats, mode: GuardMode, nowMs: number): string => `${JSON.stringify(statusPayload(stats, mode, nowMs), null, 2)}\n`
56
57/** The ruflo status-bar segment's text (ruflo cuts it to 48 characters). */
58export const segmentText = (stats: Stats, mode: GuardMode): string =>
59  mode === 'off' ? 'aqe guard off' : `aqe ${mode}${stats.blocked > 0 ? ` · ${stats.blocked} blocked` : ''}${stats.flagged > 0 ? ` · ${stats.flagged} flagged` : ''}`
60
hooks/paths.ts 106 lines
1/**
2 * Which paths are AQE's irreplaceable learning data.
3 *
4 * Pure: no `$`, no imports. Shared by the guard (hooks/guard.ts), the
5 * `/aqe-mod fleet` verb and the tests.
6 *
7 * Protected, case-insensitively, anywhere in a path:
8 * - the `.agentic-qe` directory itself (deleting or moving it loses everything);
9 * - a direct child of `.agentic-qe/` named `*.db`, `*.db-wal`, `*.db-shm`,
10 *   `*.db-journal` or `*.rvf` (memory.db and its WAL/SHM, brain/pattern stores);
11 * - a glob directly under `.agentic-qe/` that could match one of those.
12 *
13 * Not protected: backups (`memory.db.bak-<ts>` does not end in `.db`),
14 * subdirectories (`.agentic-qe/agents/`), config, logs.
15 */
16
17/** A learning-data file's name: what the CLAUDE.md Data Protection rule guards. */
18export const DATA_FILE = /\.(db(-wal|-shm|-journal)?|rvf)$/i
19
20/** Names a glob is tried against to decide whether it could reach learning data. */
21const SAMPLES = ['memory.db', 'memory.db-wal', 'memory.db-shm', 'memory.db-journal', 'brain.rvf', 'patterns.rvf', 'x.db']
22
23/** `.agentic-qe` as a path component, preceded by start, `/`, `=` or `:` (so `--db=.agentic-qe/x.db` counts). */
24const COMPONENT = /(^|[/=:])\.agentic-qe(\/|$)/i
25
26export type ProtectedKind = 'dir' | 'file' | 'glob'
27
28/** Collapses `//`, `/./` and `a/../` so `.agentic-qe/./memory.db` and `x/../.agentic-qe/memory.db` read plainly. */
29export function normalisePath(raw: string): string {
30  let p = raw.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
31  const parts: string[] = []
32  for (const part of p.split('/')) {
33    if (part === '.' && parts.length > 0) continue
34    if (part === '..' && parts.length > 0 && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') {
35      parts.pop()
36      continue
37    }
38    parts.push(part)
39  }
40  p = parts.join('/')
41  return p
42}
43
44const hasGlob = (s: string) => /[*?[]/.test(s)
45
46/** A shell glob as an anchored regex (`*`, `?`, `[...]`; braces are taken literally). */
47export function globToRegex(glob: string): RegExp {
48  let out = ''
49  for (let i = 0; i < glob.length; i++) {
50    const c = glob[i] as string
51    if (c === '*') out += '[^/]*'
52    else if (c === '?') out += '[^/]'
53    else if (c === '[') {
54      const end = glob.indexOf(']', i + 1)
55      if (end === -1) out += '\\['
56      else {
57        out += `[${glob.slice(i + 1, end).replace(/^!/, '^').replace(/\\/g, '\\\\')}]`
58        i = end
59      }
60    } else out += c.replace(/[.+^${}()|\\]/g, '\\$&')
61  }
62  return new RegExp(`^${out}$`, 'i')
63}
64
65/** Whether a glob (a file-name pattern, no directory part) could match a learning-data file. */
66export const globReachesData = (glob: string): boolean => {
67  try {
68    const re = globToRegex(glob)
69    return SAMPLES.some(name => re.test(name))
70  } catch {
71    return true
72  }
73}
74
75/**
76 * How a path touches learning data, or undefined when it does not.
77 * `inAqe`: the command already changed into `.agentic-qe`, so a bare `memory.db` is the store.
78 */
79export function protectedKind(raw: string, inAqe = false): ProtectedKind | undefined {
80  const path = normalisePath(raw.trim())
81  if (path === '') return undefined
82  const m = COMPONENT.exec(path)
83  let rest: string
84  if (m === null) {
85    if (!inAqe || path.includes('/')) return undefined
86    rest = path
87  } else {
88    rest = path.slice(m.index + m[0].length)
89  }
90  if (rest === '' || rest === '.') return 'dir'
91  // Only direct children are the stores; `.agentic-qe/agents/x.db` is someone's fixture.
92  if (rest.includes('/')) return undefined
93  if (hasGlob(rest)) return globReachesData(rest) ? 'glob' : undefined
94  return DATA_FILE.test(rest) ? 'file' : undefined
95}
96
97/** The last path component. */
98export const baseName = (p: string): string => {
99  const parts = normalisePath(p).split('/').filter(s => s !== '')
100  return parts[parts.length - 1] ?? ''
101}
102
103/** True when a path names something under `.agentic-qe` at any depth (for `find` roots and `rsync --delete`). */
104export const underAqe = (raw: string, inAqe = false): boolean =>
105  COMPONENT.test(normalisePath(raw.trim())) || (inAqe && (raw === '.' || raw === './' || !raw.startsWith('/')))
106