SLOPSHOPPER

publish-approval

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

newguardprocess
A shopper browsing a rack in a slop shop
README

Colloid Swarm

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.

Install

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.

Architecture

  • 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.

Delegation defaults

WorkClaudeCodex
Mechanical or bounded explorationHaikugpt-5.6-luna / low
Implementation, QA, researchClaude Sonnet 5gpt-5.6-terra / medium
Well-specified work too broad for medium; the leadClaude Opus 5gpt-5.6-sol / high
Complex or critical workClaude 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.

MCP capabilities

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.

ServerDefaultPurpose
context7oncurrent library documentation
playwrightonbrowser QA; optional synced site cookies and proxy (see .agents/README.md)
research-mcponreadable web/PDF research
playwright-readeroffbrowser reading with an installed blocker
appium-mcpoffmobile device/simulator QA
security-mcpoffauthorized live-target scanning
linear, atlassianoffissue and knowledge systems
greptile, exaoffkeyed 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.

Hooks

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)"

Verification

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.

Deferred work and evidence

  • .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.

Experimental genome layer

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.

License

No repository-wide license has been selected. The learning-output-style playbook has its own Apache-2.0 notice under .agents/licenses/.

Source 1 files
hooks/register.ts 135 lines
1import 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