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

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.
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.
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.
| Option | Sets | Default | Effect |
|---|---|---|---|
llm_provider | AQE_LLM_PROVIDER | empty (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_usd | AQE_MAX_BUDGET_USD | 0 (no cap) | Per-run spend cap for metered providers. |
memory_backend | AQE_MEMORY_BACKEND | hybrid | hybrid saves learning to .agentic-qe/memory.db. memory is database-free mode: same features, but nothing is written under .agentic-qe/. |
Agents are routed by cognitive load: heavy reasoning runs on Opus, focused execution on Sonnet.
| Agent | Model | Purpose |
|---|---|---|
qe-test-architect | opus | AI-powered test generation with sublinear optimization |
qe-fleet-commander | opus | Fleet lifecycle and workload distribution |
qe-security-scanner | opus | SAST/DAST/dependency/secrets scanning |
qe-chaos-engineer | opus | Controlled fault injection and resilience testing |
qe-regression-analyzer | opus | Intelligent test selection and change-impact scoring |
qe-requirements-validator | opus | Testability analysis and BDD scenario generation |
qe-coverage-specialist | sonnet | O(log n) sublinear coverage analysis with risk-weighted gap detection |
qe-flaky-hunter | sonnet | Flaky test detection and auto-stabilization |
qe-performance-tester | sonnet | Load, stress, endurance, regression detection |
qe-quality-gate | sonnet | Quality gate enforcement with policy validation |
qe-tdd-specialist | sonnet | Red-Green-Refactor (London + Chicago schools) |
Each skill has a trust tier and a scoped allowed-tools list with no wildcards.
The bundle leaves out tier-1 (untested) skills, per AQE trust-tier policy.
| Skill | Trust tier | MCP tools |
|---|---|---|
qe-test-generation | 3 | test_generate_enhanced |
qe-coverage-analysis | 3 | coverage_analyze_sublinear, qe_coverage_gaps |
qe-test-execution | 3 | test_execute_parallel |
qe-chaos-resilience | 3 | chaos_test |
qe-quality-assessment | 3 | quality_assess |
chaos-engineering-resilience | 3 | — (methodology guide) |
mutation-testing | 3 | — (Stryker integration) |
risk-based-testing | 3 | — (read-only analysis) |
tdd-london-chicago | 2 | — (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.
/aqe-analyze, /aqe-benchmark, /aqe-chaos, /aqe-costs, /aqe-execute, /aqe-fleet-status, /aqe-generate, /aqe-optimize, /aqe-report
npx on PATH. The MCP server runs through npx.npx can fetch the pinned agentic-qe package. After that it uses the npm cache.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.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.bash plugins/agentic-qe-fleet/scripts/smoke.sh is the contract.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.
claude --plugin-dir ./plugins/agentic-qe-fleet
Then run, for example, /aqe-fleet-status.
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
The plugin ships a hooks module, aqe-mod (hooks/hooks.json -> hooks/register.ts), which loads with the plugin by default.
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 -xsqlite3 .agentic-qe/memory.db with DROP TABLE, DELETE FROM, TRUNCATE or .restoreReads 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..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.
hooks/register.ts 114 lines1import 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}
114hooks/command.ts 102 lines1import { 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}
102hooks/guard.ts 251 lines1/**
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}
251hooks/options.ts 27 lines1import 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}
27hooks/status.ts 60 lines1import 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` : ''}`
60hooks/paths.ts 106 lines1/**
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