Two rules no settings hook enforces, refused at the tool call: wiki pages and their generated ADR mirrors are written only through the Cortex wiki tool, and…

Claude Code mods for the ai-architect.tools harness, one concern per mod, state shared through dependencies:
| Mod | Owns | Depends on |
|---|---|---|
cortex-guard | refusals: wiki pages only through wiki_write, worktrees inside <repo>/.claude/worktrees/ | nothing |
cortex-wiki | /wiki <wiki/**.md> to PDF | nothing |
zetetic-genius | state: the request grade (task class → effort), the genius patterns and skills it matches | nothing |
zetetic-autopilot | context, policy: effort ladder, model routing, pressure, lean results | zetetic-genius |
cortex-cockpit | stats, ledger, tally, stages, hygiene; the /cortex pane draws the rest | the three above |
harness-fleet | fleet: the owner's plugins, installed vs offered version, open PRs with CI, open issues (a defect is taken and fixed, a feature request goes to the owner); /fleet. Reads only (gh pr list, gh issue list, git remote): every outward action is a button the owner presses, which puts a prompt in front of the model | guard, genius, autopilot |
Each mod is validated, tested and type-checked on its own:
cd mods/<mod> && claude plugin validate . && claude plugin test && npx -y -p typescript tsc -p .
tsc needs the engine to have loaded the mod once (it lays .claude-plugin/types/). For hot reload in a session, link the mod into that session's ~/.claude/dev-mods/<session>/ folder; the engine watches the folder a link names.
.claude-plugin/marketplace.json lists each mod as "source": "./mods/<mod>", so the repository is the marketplace. From another machine:
/plugin install <mod> --marketplace cdeust/claude-mods
y adds the marketplace, then the user scope. On this machine a folder marketplace reads the mods from the checkout, with no copy: claude plugin marketplace add <this folder>, then /plugin install <mod> and /reload-plugins after an edit.
hooks/register.ts 87 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Refusal } from '../types'
5import { type CallInput, judgeCall, judgePath, worktreeAddPath } from './guard'
6
7const REFUSALS_CAP = 200 // source: bounds $.state size; a viewer shows the last rows only
8
9const refusals = atom({ plugin: 'cortex-guard', key: 'refusals' } as const, [] as Refusal[])
10
11// Module state: where the repo is and which script registers a worktree. A hot reload runs
12// session.start again, so both are set afresh.
13let repo = ''
14let hygieneScript = ''
15
16// Where a path lands once links and `..` are resolved; the spelling when it does not exist yet.
17async function realPathOf($: EngineInterface, path: string): Promise<string> {
18 try {
19 const stat = await $.fs.stat(path, { resolve: true })
20 return (stat as { realPath?: string }).realPath ?? path
21 } catch {
22 return path
23 }
24}
25
26// Pairs with the worktree rule: a worktree that exists is registered, as CLAUDE.md prescribes
27// (the registered cleanup procedure, 2026-09-26).
28async function registerWorktree($: EngineInterface, path: string): Promise<void> {
29 if (hygieneScript === '') return
30 const real = await realPathOf($, path)
31 const ran = await $.process.run([
32 'python3',
33 hygieneScript,
34 '--host',
35 'claude',
36 '--session',
37 await $.session.id(),
38 'register-worktree',
39 '--repo',
40 repo,
41 '--path',
42 real,
43 ])
44 $.ui.toast(
45 ran.exitCode === 0
46 ? `worktree registered: ${real}`
47 : `worktree NOT registered (${ran.stderr.slice(0, 80)})`,
48 )
49}
50
51export const register: Register = (on, options) => {
52 hygieneScript = String(options.hygiene_script ?? '')
53
54 on('session.start', async ($, e, next) => {
55 repo = (await $.session.repo())?.root ?? e.cwd
56
57 return next(e)
58 })
59
60 // Fail closed: a guard that throws refuses.
61 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash'] }, async ($, e, next) => {
62 const tool = String(e.tool)
63 const input = e as CallInput
64 let verdict = judgeCall(tool, input)
65 if (verdict.allow && input.file_path !== undefined)
66 verdict = judgePath(await realPathOf($, input.file_path))
67 if (verdict.allow) {
68 const ran = await next(e)
69 const added = tool === 'Bash' && !ran.isError ? worktreeAddPath(input.command ?? '') : undefined
70 if (added !== undefined) void registerWorktree($, added)
71 return ran
72 }
73 const target = input.file_path ?? input.notebook_path ?? (input.command ?? '').slice(0, 60)
74 const at = await $.clock.now()
75 await update($, refusals, (list) =>
76 [...list, { at, tool, rule: verdict.rule, target }].slice(-REFUSALS_CAP),
77 )
78 $.ui.toast(`refused ${tool}: ${verdict.rule}`)
79
80 return { deny: `cortex-guard (${verdict.rule}): ${verdict.reason}` }
81 }).catch(($, e, next) =>
82 next.called
83 ? next(e)
84 : { deny: `cortex-guard: its guard failed on ${String(e.tool)}, so the call was refused.` },
85 )
86}
87hooks/guard.ts 113 lines1// Pure verdicts for the two rules no settings hook enforces: wiki pages are written only
2// through the wiki tool, and every git worktree lives inside the repo.
3
4// source: the generated mirrors carry "Read-only mirror: edit the canonical wiki page"
5// (docs/adr/ADR-1060-*.md header); the canonical pages live under wiki/ (wiki/README.md).
6const MIRROR = /(^|\/)docs\/adr\/ADR-\d+[^/]*\.md$/
7const CANONICAL = /(^|\/)wiki\/(adr|specs|lessons)\/.+\.md$/
8
9// source: ~/.claude/CLAUDE.md rule 2 and Cortex CLAUDE.md: <repo>/.claude/worktrees/<name>/,
10// .Codex/worktrees/<name>/ for Codex.
11export const WORKTREE_ROOTS = ['/.claude/worktrees/', '/.Codex/worktrees/'] as const
12
13export type Verdict = { allow: true } | { allow: false; rule: string; reason: string }
14
15const ALLOW: Verdict = { allow: true }
16
17export const WIKI_RULE = 'wiki-write-only'
18export const WORKTREE_RULE = 'worktree-inside-repo'
19
20const refuseWiki = (path: string, why: string): Verdict => ({
21 allow: false,
22 rule: WIKI_RULE,
23 reason: `${path}: ${why} Use the Cortex wiki_write tool (wiki_adr for a decision); wiki_reindex regenerates the mirror.`,
24})
25
26export const judgePath = (path: string): Verdict => {
27 if (MIRROR.test(path)) return refuseWiki(path, 'a generated read-only mirror.')
28 if (CANONICAL.test(path))
29 return refuseWiki(path, 'a canonical wiki page, written only through the wiki tool.')
30 return ALLOW
31}
32
33const WRITES_FILES = /(^|[\s;&|(])(sed\s+-i|tee|cp|mv|rm|truncate|dd|perl\s+-pi)\b|>>?\s*\S/
34
35const tokensOf = (command: string): string[] =>
36 command.split(/\s+/).map((t) => t.replace(/^['"]|['"]$/g, ''))
37
38// Best effort: a shell command that names a protected path and writes.
39export const judgeShellWiki = (command: string): Verdict => {
40 if (!WRITES_FILES.test(command)) return ALLOW
41 for (const path of tokensOf(command)) {
42 const verdict = judgePath(path)
43 if (!verdict.allow) return verdict
44 }
45 return ALLOW
46}
47
48export const isInsideWorktreeRoot = (path: string): boolean =>
49 WORKTREE_ROOTS.some((root) => path.includes(root))
50
51// source: `git [-C <path>] [-c <name>=<value>] [--git-dir=<path>|--git-dir <path>] ...
52// <command>` (git(1) SYNOPSIS); these global options take a value when given separately.
53const GIT_VALUED_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
54
55// Index of the subcommand after `git` at `at`, skipping git's own global options.
56const gitSubcommandIndex = (t: string[], at: number): number => {
57 let i = at + 1
58 while (i < t.length && (t[i] ?? '').startsWith('-')) {
59 i += GIT_VALUED_OPTIONS.has(t[i] ?? '') ? 2 : 1
60 }
61 return i
62}
63
64// `git [global options] worktree add [options] <path> [<commit-ish>]`: the path is the
65// first token after `add` that is not an option and not an option's value (-b/-B/--orphan).
66export const worktreeAddPath = (command: string): string | undefined => {
67 const t = tokensOf(command)
68 let at = -1
69 for (let i = 0; i < t.length && at < 0; i++) {
70 if (t[i] !== 'git') continue
71 const sub = gitSubcommandIndex(t, i)
72 if (t[sub] === 'worktree' && t[sub + 1] === 'add') at = sub
73 }
74 if (at < 0) return undefined
75 const args = t.slice(at + 2)
76 for (let i = 0; i < args.length; i++) {
77 const a = args[i] ?? ''
78 if (a === '-b' || a === '-B' || a === '--orphan') {
79 i++
80 continue
81 }
82 if (a.startsWith('-')) continue
83 return a
84 }
85 return undefined
86}
87
88export const judgeShellWorktree = (command: string): Verdict => {
89 const path = worktreeAddPath(command)
90 if (path === undefined || isInsideWorktreeRoot(path)) return ALLOW
91 return {
92 allow: false,
93 rule: WORKTREE_RULE,
94 reason: `git worktree add ${path}: a worktree lives at <repo>/.claude/worktrees/<name>/ (.Codex/worktrees/ for Codex), never outside the repo.`,
95 }
96}
97
98export const judgeShell = (command: string): Verdict => {
99 const wiki = judgeShellWiki(command)
100 return wiki.allow ? judgeShellWorktree(command) : wiki
101}
102
103export type CallInput = { file_path?: string; command?: string; notebook_path?: string }
104
105export const judgeCall = (tool: string, input: CallInput): Verdict => {
106 if ((tool === 'Edit' || tool === 'Write') && input.file_path !== undefined)
107 return judgePath(input.file_path)
108 if (tool === 'NotebookEdit' && input.notebook_path !== undefined)
109 return judgePath(input.notebook_path)
110 if (tool === 'Bash' && input.command !== undefined) return judgeShell(input.command)
111 return ALLOW
112}
113types/index.d.ts 16 lines1// One refused call: when, which tool, which rule, and what it aimed at.
2export type Refusal = {
3 at: number
4 tool: string
5 rule: string
6 target: string
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'cortex-guard': {
12 refusals: Refusal[]
13 }
14 }
15}
16