Asks the user in a dialog before a push, deploy or publish the publish guard gates, in every permission mode.

A portable agent scaffold for Claude Code, Codex, Kimi, and GitHub Copilot. Canonical instructions, skills, hook policies, personas, and MCP definitions live under .agents/; host directories contain direct links or thin adapters.
This repository also carries an experimental genome layer. Exports remove it.
Requirements are Bash, Python 3, and Node.js 22+ for the two repository-owned MCP servers.
git clone https://github.com/Sarb0Z/colloid-swarm.git
cd colloid-swarm
.agents/export-scaffold.py /tmp/scaffold-kit
The exporter reads the current Git commit and refuses a non-empty destination. Review the kit, then follow export/README.md to merge it into a target. It includes .agents/, static .claude/ and .codex/ integration, Kimi config, Copilot links, root instructions, and the transplant guide. Dirty or ignored local state does not travel.
AGENTS.md: operating contract and delegation policy..agents/personas/: Claude-native cached roles; .claude/agents/ links here..codex/agents/: static Codex roles with exact invocation model/effort..agents/skills/: reusable, progressively disclosed workflows..agents/rules/: scoped codebase and framework guidance..agents/hooks/: engine-neutral policies plus host payload adapters..agents/mcp.json: server definitions and project on/off state..agents/playbooks/: focused review, QA, wrap, and reporting procedures.Run python3 .agents/check-layout.py after changing scaffold inventory. It checks committed links without generating or pruning files.
| Work | Claude | Codex |
|---|---|---|
| Mechanical or bounded exploration | Haiku | gpt-5.6-luna / low |
| Implementation, QA, research | Claude Sonnet 5 | gpt-5.6-terra / medium |
| Well-specified work too broad for medium; the lead | Claude Opus 5 | gpt-5.6-sol / high |
| Complex or critical work | Claude Fable 5.1 | — |
The cached personas cover common work but do not restrict generic delegation. The Opus lead sends well-specified work to the cheapest tier that can solve and verify it and complex work up to Fable, and grants only the capabilities that task needs.
The tracked registry directly owns state. Defaults are Context7, Playwright, and the repository-owned research server; mobile automation, live security scanning, issue trackers, and keyed search services remain off until requested.
python3 .agents/mcp.py
python3 .agents/mcp.py enable appium-mcp
python3 .agents/mcp.py disable appium-mcp
The command writes .mcp.json, .codex/config.toml, optional .kimi-code/mcp.json, Claude's enabled-server list, and the reader and default browser configs. Restart the session after a state change.
| Server | Default | Purpose |
|---|---|---|
context7 | on | current library documentation |
playwright | on | browser QA; optional synced site cookies and proxy (see .agents/README.md) |
research-mcp | on | readable web/PDF research |
playwright-reader | off | browser reading with an installed blocker |
appium-mcp | off | mobile device/simulator QA |
security-mcp | off | authorized live-target scanning |
linear, atlassian | off | issue and knowledge systems |
greptile, exa | off | keyed external services |
User- and plugin-level host configuration may still add unknown capabilities. Project records mask only the same server names; this is not a machine-wide allowlist.
Policies under .agents/hooks/policy/ receive normalized JSON on stdin. The active set covers destructive-command refusal, focused post-edit checks, research/source context, compaction recovery, session start/wrap, and stop inspection. Host adapters translate payloads; policies own behavior.
Codex hook declarations are hash-trusted. After changing them, review the file and run:
python3 .agents/codex/trust-hooks.py "$(pwd)"
python3 .agents/check-layout.py
.agents/lint-skills.sh
.agents/test-session-start.sh
python3 .agents/test-guard-destructive.py
python3 .agents/test-guard-publish.py
.agents/test-mcp.sh
.agents/test-codex.sh
.agents/test-export.sh
Each repository-owned MCP server also runs npm run check. CI runs the static and focused behavior suites; local test-codex.sh additionally proves that the installed Codex binary loads the project MCP records.
.agents/breadcrumbs.md: deferred work, surfaced at SessionStart..agents/debt-log.md: standing tradeoffs referenced as debt: <id>..agents/knowledge/: dated external observations indexed on demand.Colloid alone carries genomes.md, .agents/genome.sh, the mutagen, and the panspermia-mutation skill. The export removes those files, their hooks, config keys, and links. Run bash demo/demo.sh for the offline scaffold demonstration.
No repository-wide license has been selected. The learning-output-style playbook has its own Apache-2.0 notice under .agents/licenses/.
hooks/register.ts 135 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Where no permission prompt reaches the user (auto, bypassPermissions,
4// dontAsk), guard-publish denies a publish outright. This mod asks the user in
5// Claude Code's own question dialog, which reaches them in every mode, before
6// the call runs. "Run it" writes a token for this one call, which
7// guard-publish reads and turns its ask or deny into allow; every other hook
8// still decides, and a deny among them wins. The mod never decides by itself
9// whether a call publishes: it asks guard-publish, by argv, the same question
10// the settings hook will.
11
12const RUN = 'Run it'
13const REFUSE = 'Refuse'
14const HEADER = 'Publish'
15const SHOWN = 600
16
17type Decision = { readonly decision: string; readonly reason: string }
18
19// guard-publish prints nothing on a call it lets through, or one PreToolUse
20// envelope. Anything else is a defect the registration's catch reports.
21function parseDecision(stdout: string): Decision | undefined {
22 if (stdout.trim() === '') return undefined
23 const envelope: unknown = JSON.parse(stdout)
24 const output = typeof envelope === 'object' && envelope !== null && 'hookSpecificOutput' in envelope
25 ? envelope.hookSpecificOutput : undefined
26 if (typeof output !== 'object' || output === null
27 || !('permissionDecision' in output) || typeof output.permissionDecision !== 'string'
28 || !('permissionDecisionReason' in output) || typeof output.permissionDecisionReason !== 'string') {
29 throw new Error(`guard-publish printed an unexpected envelope: ${stdout.slice(0, 200)}`)
30 }
31 return { decision: output.permissionDecision, reason: output.permissionDecisionReason }
32}
33
34// The repository root when the switch is on, else undefined. The canonical
35// folder is <repo>/.agents/claude/mods/<name>, wherever the host link points.
36async function switchedOnRepo($: EngineInterface): Promise<string | undefined> {
37 const own = (await $.fs.stat($.plugin.root, { resolve: true })).realPath
38 if (own === undefined) throw new Error(`cannot resolve ${$.plugin.root}`)
39 const root = own.split(/[\\/]/).slice(0, -4).join('/')
40 const switched = await $.process.run([
41 'python3', `${root}/.agents/hooks/lib/config.py`, `${root}/.agents/config.json`,
42 'hooks.publish_approval.enabled=true',
43 ])
44 const word = switched.stdout.trim()
45 if (switched.exitCode !== 0 || (word !== 'yes' && word !== 'no')) {
46 throw new Error(`config.py answered ${JSON.stringify(word)} (exit ${switched.exitCode}): ${switched.stderr.trim()}`)
47 }
48 return word === 'yes' ? root : undefined
49}
50
51export const register: Register = on => {
52 // Settled on the first gated call rather than at session start, which a
53 // plugin loaded with the session does not reliably see. A rejection stays,
54 // so every later call reports it through the catch below.
55 let settled: Promise<string | undefined> | undefined
56
57 on('session.start', async ($, e, next) => {
58 settled = undefined // /clear or a resume re-reads the switch
59 return next(e)
60 })
61
62 // The tools guard-publish reads. PowerShell exists only on Windows builds, so
63 // a pattern rather than a list of names this build may not declare.
64 on('tool.call', { tool: /^(?:Bash|PowerShell|Monitor|Artifact)$/ }, async ($, e, next) => {
65 const repo = await (settled ??= switchedOnRepo($))
66 if (repo === undefined) return next(e)
67 const { tool, tool_use_id, agentId, ...tool_input } = e
68 // permission_mode default: the question is whether the guard would ask a
69 // person, whatever the session's mode turns that ask into.
70 const verdict = await $.process.run(['python3', `${repo}/.agents/hooks/lib/guard-publish.py`, repo], {
71 stdin: JSON.stringify({
72 tool_name: tool, tool_input, tool_use_id, permission_mode: 'default',
73 cwd: await $.session.cwd(), project_dir: repo,
74 }),
75 })
76 if (verdict.exitCode !== 0) {
77 $.ui.log(`${$.plugin.name}: guard-publish exited ${verdict.exitCode}: ${verdict.stderr.trim()}; the guard decides this call alone`)
78 return next(e)
79 }
80 const decision = parseDecision(verdict.stdout)
81 if (decision?.decision !== 'ask') return next(e)
82
83 // "Run it" approves the whole call, so the user must see all of it. Quoting
84 // makes newlines, carriage returns and escape sequences visible instead of
85 // letting them push the real command out of view; a call too long to show
86 // whole gets no dialog, and the guard decides alone.
87 const subject = 'command' in tool_input && typeof tool_input.command === 'string'
88 ? tool_input.command : JSON.stringify(tool_input)
89 const shown = JSON.stringify(subject)
90 if (shown.length > SHOWN) {
91 $.ui.log(`${$.plugin.name}: the call is ${shown.length} characters quoted, over ${SHOWN}; the guard decides it alone`)
92 return next(e)
93 }
94 const who = agentId === undefined ? 'The agent' : `Subagent ${agentId}`
95 let answer: string
96 try {
97 answer = await $.ui.ask(
98 `${who} wants to run a ${tool} call that the publish guard gates:\n\n${shown}\n\n${decision.reason}\n\nRun it?`,
99 { options: [REFUSE, RUN], header: HEADER },
100 )
101 } catch (unanswered) {
102 // Dismissed, resolved while the user was away (see the hook below), or
103 // nobody to ask (claude -p): none is an answer, so the guard decides alone.
104 $.ui.log(`${$.plugin.name}: no answer from the user (${String(unanswered)}); the guard decides this call alone`, { to: 'debug' })
105 return next(e)
106 }
107 if (answer === REFUSE) {
108 return { deny: `The user refused this call in the ${$.plugin.name} dialog. Do not retry it or work around it; ask the user what they want instead.` }
109 }
110 if (answer === RUN) {
111 await $.fs.write(`${repo}/.agents/.publish-approved-${tool_use_id}`, '')
112 return next(e)
113 }
114 // Typed text under "Other" is not an approval; the guard decides alone.
115 $.ui.log(`${$.plugin.name}: the dialog answered ${JSON.stringify(answer)}, not "${RUN}"; the guard decides this call alone`, { to: 'debug' })
116 return next(e)
117 }).catch(($, e, next) => {
118 $.ui.log(`${$.plugin.name}: ${next.error.message}; the guard decides this call alone`)
119 return next(e)
120 })
121
122 // $.ui.ask returns only a label, and the dialog can resolve itself after the
123 // user has been idle (afkTimeoutMs). The dialog runs through this plugin's
124 // other hooks, so this one turns such a result for its own dialog into a
125 // refusal of the dialog, which makes $.ui.ask reject instead of approving.
126 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
127 const answered = await next(e)
128 const ours = e.questions.length === 1 && e.questions[0]?.header === HEADER
129 if (ours && answered.deny === undefined && answered.isError !== true && answered.result.afkTimeoutMs !== undefined) {
130 return { deny: 'The publish dialog resolved while the user was away; that is not an approval.' }
131 }
132 return answered
133 })
134}
135