Guards the repository's red lines at the moment of action, runs platform tests against the test database, watches CI after a push, and gives the model a…

<h1 align="center">Skill Hub</h1>
An open platform for discovering, creating, trialling and distributing Agent Skills with evidence, provenance and controlled execution.
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a> <a href="https://github.com/ArthurC02/SkillHub/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/ArthurC02/SkillHub/actions/workflows/ci.yml/badge.svg"></a> <a href="README.zh-TW.md">繁體中文</a>
[!IMPORTANT] Skill Hub is pre-release software. The repository contains working product paths, but public exposure, paid model use and production deployment remain governed by explicit configuration and verification. Read the current plan and milestone status before treating a capability as released.
Agent Skills are useful only when people can answer practical questions: where did this come from, what can it access, will it work for my task, what did it cost, and can I take the result elsewhere? Skill Hub makes those questions part of the product rather than leaving them to convention.
The product deliberately does not use a global quality leaderboard: evidence is meaningful only in the context of the task and acceptance criteria that produced it.
Browser
│
▼
React web ───────────────► Go control plane API ───► PostgreSQL + S3-compatible object storage
│ │
│ └── transactional outbox
▼
Go Worker (only queue consumer)
│ │
internal HTTP│ │internal HTTP
▼ ▼
Python LLM capability sandboxd on isolated nodes
│ │
▼ ▼
LiteLLM gateway gVisor runtime image
│
▼
model provider
The Go control plane owns authorization, Workspace scope, domain rules, Run state and all core data. The Python LLM service and Sandbox are capability providers: they accept structured requests and return structured results, but never access the core database. Every model call goes through LiteLLM; provider credentials stay at the gateway. A Run receives a short-lived Virtual Key, and its state transition plus outward event are recorded transactionally.
The platform code is organized into reviewed bounded contexts for creator, product, skill and trial work. External systems sit behind ports and adapters, while package ownership, query ownership and cross-context dependencies are checked mechanically. See the architecture identity, bounded-context model and architecture decisions.
Clean test mode is intentionally not a security boundary. It uses in-process stand-ins so the product can be demonstrated without Docker, keys or network access; do not use it for untrusted Skills or real data.
The shortest path to a running product is a cost-free demonstration mode. It needs Go and Node.js, but not Docker or model credentials.
task doctor
task bootstrap
npm ci --prefix tools/pglite
npm --prefix apps/web run build
task clean-mode
The launcher prints a local URL and explains any unmet prerequisite. Without Task, use go -C tools/devctl run . doctor, go -C tools/devctl run . bootstrap, and node tools/cleanmode/start.mjs --seed.
Clean mode substitutes an embedded database, in-memory object storage and a local-process driver. It demonstrates the browser-to-API journey, not production isolation, presigned object URLs, concurrency, object storage or paid model capability.
Start with the portable diagnostics; exact tool versions are owned by go.mod, .node-version, apps/llm/.python-version and tools/toolchain.yaml, not this README.
task doctor
task env:init
task bootstrap
task gen:check
task dev
task dev starts local PostgreSQL and SeaweedFS without model cost. Then run the product processes in separate terminals:
| Component | Command | Responsibility |
|---|---|---|
| API | go -C apps/platform run ./cmd/api | HTTP, authentication, authorization and domain commands |
| Worker | go -C apps/platform run ./cmd/worker | Run dispatch, cleanup, outbox and periodic work |
| LLM capability | cd apps/llm && uv run uvicorn skillhub_llm.app:app | structured model-backed capabilities |
| Sandbox provider | go -C apps/sandbox run ./cmd/sandboxd | local execution-provider boundary |
| Web | npm --prefix apps/web run dev | React development UI |
For a local SPA, set DEV_CORS_ORIGIN=http://localhost:5173 on the API process. For a real Run, API and Worker must share the same database, object-storage, Sandbox, model-gateway and Trace settings; the full dependency and verification sequence is in the Provision guide.
Model capability is off by default.
task dev:model
task dev:llm
The first command starts LiteLLM after checking required secrets. The second mints a budget-limited Virtual Key for apps/llm; it does not give that service the gateway master key. A model request can incur cost, so paid live tests are opt-in and never part of the default test command.
Skill Hub ships a repository-owned Dev Container in .devcontainer. It builds the pinned infra/images/devtools/Dockerfile, starts a nested Docker daemon for local Compose and code generation, creates .env from .env.example without overwriting an existing file, and bootstraps Go, Node and Python dependencies.
Forwarded ports are pre-labelled for the Web dev server (5173), Platform API (8080), LiteLLM (4000), SeaweedFS (8333) and PostgreSQL (5432). The container also recommends the Go, Python, Docker, YAML, ESLint and Prettier VS Code extensions and points Python tooling at apps/llm/.venv.
For the exact startup flow, trust boundary and daily commands, see /.devcontainer/README.md.
task gen:check # generated contracts and SQL output match their sources
task test # normal test suites; paid E2E remains opt-in
task ci # deterministic, secret-free local CI sequence
task preflight # checks unpushed commits against CI-facing rules
A green local check is evidence about this machine, not proof of hosted CI or production readiness. CI diagnostics explain how to distinguish a skipped, opaque or genuinely failing workflow result. When fixing behaviour, tests are expected to prove that the un-fixed behaviour fails before claiming a fix.
| Path | Purpose |
|---|---|
apps/web | React and TypeScript user interface |
apps/platform | Go control plane, Worker and bounded contexts |
apps/llm | Python FastAPI capability provider |
apps/sandbox | Go Sandbox provider |
packages | Reusable libraries and generated API clients |
contracts | Source of truth for cross-process OpenAPI, events and packaging contracts |
db | Migrations, queries, SQL ownership and sqlc configuration |
infra | Compose, deployment, runtime images, egress and observability |
tools | Development, CI, data-maintenance and operations commands |
docs | Product plans, architecture decisions, design guidance and runbooks |
AGENTS.md, the repository rules for people and coding agentsContributions are welcome. Start with CONTRIBUTING.md, keep generated files generated, define cross-process interfaces in contracts/ first, and run the checks appropriate to the change. Do not commit .env, credentials, paid-test outputs or secrets.
For a security issue, do not open a public issue. Use the private process in SECURITY.md.
hooks/register.ts 368 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 CI_GIVE_UP_MS, CI_POLL_MS, DOCUMENTED_TEST_DSN, MUTATION_TIMEOUT_MS, MUTATION_TOOL, MUTATION_TOOL_SPEC,
4 SHIP_TOOL, SHIP_TOOL_SPEC, TEST_DATABASE_NOTE, ciMessage, ciVerdict, dsnFromEnvFile,
5 heredocWritesCodeWithBackslash, mutationVerdict, replaceOnce, repoRelative, shipPathProblem, withTestDatabase,
6 type MutationInput, type ShipInput,
7} from './assist'
8
9const ROLES = ['skillhub-writer', 'skillhub-verify', 'skillhub-mutation']
10const FAMILIES = ['fable', 'opus', 'sonnet', 'haiku']
11const RUNTIME_IMAGE = 'infra/images/runtime-agent-sdk'
12const DOMAIN_MEMORY = 'docs/domain-memory'
13const AGENT_CACHES = ['.agents/', '.codex/']
14const ALLOW_HINT = 'If the owner agrees, ask them to type /guard-allow, then retry once.'
15const GENERATED_BANNER_LINES = 15
16
17const AT_COMMAND = String.raw`(?:^|[;&|(\n]|\$\()\s*(?:[A-Za-z_]\w*=\S*\s+)*(?:rtk\s+(?:proxy\s+)?)?`
18const ARG = String.raw`(?:"[^"]*"|'[^']*'|\S+)`
19const GIT = String.raw`${AT_COMMAND}git(?:\s+(?:-C\s+${ARG}|-c\s+${ARG}|--[\w-]+(?:=${ARG})?))*\s+`
20const NPM_FLAGS = String.raw`(?:\s+-{1,2}[\w-]+(?:[ =]${ARG})?)*`
21
22const at = (pattern: string) => new RegExp(AT_COMMAND + pattern)
23
24const SUBAGENT_RULES: [RegExp, string][] = [
25 [at(String.raw`task\s+dev:model\b`), 'may not start the paid model stack (AGENTS.md 開發自動化 2)'],
26 [at(String.raw`docker[\s-]compose\b[^;&|\n]*--profile\s+model\b[^;&|\n]*\sup\b`),
27 'may not start the paid model stack (AGENTS.md 開發自動化 2)'],
28 [new RegExp(GIT + String.raw`(?:commit|push|pull|rebase|merge|cherry-pick|revert|am|apply|add|rm|mv|switch|checkout|restore|reset|stash|clean|update-ref|worktree\s+(?:add|remove|move|prune)|branch\s+-[dDmMfc])\b`),
29 'may not write to Git; the dispatcher stages and commits (AGENTS.md 開發自動化 3)'],
30 [at(String.raw`(?:npm|pnpm|yarn)${NPM_FLAGS}\s+(?:install|i|ci|add|update|uninstall|remove)\b`),
31 'may not install or update packages (AGENTS.md 開發自動化 3)'],
32 [at(String.raw`go(?:\s+-C\s+${ARG})?\s+(?:get|install|mod\s+tidy)\b`),
33 'may not install or update packages (AGENTS.md 開發自動化 3)'],
34 [at(String.raw`(?:uv\s+(?:add|remove|sync|lock|pip\s+install)|pip3?\s+install)\b`),
35 'may not install or update packages (AGENTS.md 開發自動化 3)'],
36 [at(String.raw`docker[\s-]compose\b[^;&|\n]*\sdown\b`), 'may not run Compose down (AGENTS.md 開發自動化 3)'],
37 [at(String.raw`(?:npx\s+)?(?:prettier\b[^;&|\n]*--write\s+\.|gofmt\s+-\w*w\w*\s+\.|ruff\s+format\s+\.)(?:\s|$)`),
38 'may not run a repo-wide formatter (AGENTS.md 開發自動化 3)'],
39 [at(String.raw`(?:npm${NPM_FLAGS}\s+run\s+format|task\s+format)(?:\s|$)`),
40 'may not run a repo-wide formatter (AGENTS.md 開發自動化 3)'],
41 [at(String.raw`task\s+gen(?::(?!check\b)[\w-]+)?(?:\s|$)`),
42 'may not regenerate generated files; the dispatcher serialises it (AGENTS.md 開發自動化 5)'],
43 [/\b(?:run\s+\.|devctl)\s+agent-sync\b/, 'may not run agent-sync; only the single writer does (AGENTS.md 開發自動化 3)'],
44]
45
46const GIT_PUSH = new RegExp(GIT + String.raw`push\b`)
47const GIT_COMMIT = new RegExp(GIT + String.raw`commit\b`)
48const SIGNED = /\s(?:-S\S*|--gpg-sign\S*)(?=\s|$)/
49const GENERATED_BANNER = /^\s*(?:\/\/|#|\/?\*|--)\s*.*(?:code generated|generated - do not edit|auto generated by|generated by datamodel-codegen)/i
50const TEST_RUN = at(String.raw`(?:go(?:\s+-C\s+${ARG})?\s+test|npm${NPM_FLAGS}\s+(?:test|run\s+test\S*)|npx\s+(?:vitest|playwright)|uv\s+run\s+pytest|pytest|task\s+test)\b`)
51
52const familyOf = (model: string | undefined) =>
53 FAMILIES.find(family => (model ?? '').toLowerCase().includes(family))
54
55const normalize = (path: string) => path.replace(/\\/g, '/').toLowerCase()
56
57const directoryOf = (path: string) => {
58 const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
59 return slash > 0 ? path.slice(0, slash) : '.'
60}
61
62export const spawnProblem = (
63 model: string | undefined, subagentType: string, parentModel: string, fork: boolean,
64): string | undefined => {
65 if (fork) {
66 return 'a fork always runs on the dispatcher\'s flagship model; dispatch a named agent with a lower model ' +
67 '(AGENTS.md 開發自動化 3)'
68 }
69 if (model === undefined && !ROLES.includes(subagentType)) {
70 return `name a model for this subagent or use one of ${ROLES.join(', ')}; ` +
71 'an unnamed model inherits the dispatcher\'s flagship model (AGENTS.md 開發自動化 3)'
72 }
73 const family = familyOf(model)
74 if (family === 'fable' || (family !== undefined && family === familyOf(parentModel))) {
75 return `model ${model} is the dispatcher's own tier; subagents start from the lowest tier ` +
76 'that can finish the task (AGENTS.md 開發自動化 3)'
77 }
78 return undefined
79}
80
81export const subagentCommandProblem = (command: string): string | undefined =>
82 SUBAGENT_RULES.find(([pattern]) => pattern.test(command))?.[1]
83
84export const isGeneratedHeader = (text: string): boolean =>
85 text.split('\n', GENERATED_BANNER_LINES).some(line => GENERATED_BANNER.test(line))
86
87export const isAgentCache = (path: string, root: string): boolean => {
88 const prefix = normalize(root).replace(/\/$/, '') + '/'
89 const target = normalize(path)
90 return AGENT_CACHES.some(cache => target.startsWith(prefix + cache))
91}
92
93export const needsSignature = (command: string): boolean =>
94 GIT_COMMIT.test(command) && !SIGNED.test(command)
95
96const porcelainPaths = (porcelain: string): string[] => {
97 const paths: string[] = []
98 const fields = porcelain.split('\0')
99 for (let i = 0; i < fields.length; i++) {
100 const field = fields[i] ?? ''
101 if (field.length < 4) continue
102 paths.push(field.slice(3))
103 if (field[0] === 'R' || field[0] === 'C') i++
104 }
105 return paths
106}
107
108export const attributedToCommand = (before: string, after: string): string[] => {
109 const was = new Set(porcelainPaths(before))
110 return porcelainPaths(after).filter(path => !was.has(path))
111}
112
113async function touched($: EngineInterface): Promise<string[]> {
114 return (await $.state.get({ plugin: 'skillhub-guards', key: 'touched' })).value ?? []
115}
116
117async function remember($: EngineInterface, paths: string[]) {
118 const list = await touched($)
119 const fresh = paths.map(normalize).filter(path => !list.includes(path))
120 if (fresh.length > 0) await $.state.set({ plugin: 'skillhub-guards', key: 'touched' }, [...list, ...fresh])
121}
122
123async function consumeAllowance($: EngineInterface, action: string): Promise<boolean> {
124 const allowance = (await $.state.get({ plugin: 'skillhub-guards', key: 'allowance' })).value
125 if (allowance !== action) return false
126 await $.state.set({ plugin: 'skillhub-guards', key: 'allowance' }, null)
127 return true
128}
129
130async function refuse($: EngineInterface, action: string, reason: string) {
131 await $.state.set({ plugin: 'skillhub-guards', key: 'lastDenied' }, action)
132 return { deny: `skillhub-guards: ${reason} ${ALLOW_HINT}` }
133}
134
135async function status($: EngineInterface, root: string): Promise<string | undefined> {
136 const result = await $.process.run(['git', '-C', root, 'status', '--porcelain', '-z', '--untracked-files=all'])
137 return result.exitCode === 0 ? result.stdout : undefined
138}
139
140async function readHeader($: EngineInterface, path: string): Promise<string> {
141 try {
142 return (await $.fs.read(path)).slice(0, 4096)
143 } catch {
144 return ''
145 }
146}
147
148async function unallowedRuntimeImagePush($: EngineInterface, root: string) {
149 const head = (await $.process.run(['git', '-C', root, 'rev-parse', 'HEAD'])).stdout.trim()
150 const upstream = await $.process.run(['git', '-C', root, 'rev-parse', '--verify', '--quiet', '@{u}'])
151 const base = upstream.exitCode === 0 ? '@{u}' : 'origin/main'
152 const changed = await $.process.run(['git', '-C', root, 'diff', '--name-only', `${base}..HEAD`, '--', RUNTIME_IMAGE])
153 const action = `push of ${head.slice(0, 8)} touching ${RUNTIME_IMAGE}`
154 if (changed.stdout.trim() === '' || await consumeAllowance($, action)) return undefined
155 return refuse($, action,
156 'this push publishes a new runtime image tag, which cannot be withdrawn; ask the owner first ' +
157 '(.claude/rules/tests.md).')
158}
159
160async function git($: EngineInterface, root: string, args: string[], stdin?: string) {
161 const run = await $.process.run(['git', '-C', root, ...args], stdin === undefined ? undefined : { stdin })
162 return { ok: run.exitCode === 0, out: `${run.stdout}${run.stderr}`.trim() }
163}
164
165async function isDirectory($: EngineInterface, path: string): Promise<boolean> {
166 try {
167 return (await $.fs.stat(path)).kind === 'dir'
168 } catch {
169 return false
170 }
171}
172
173async function watchCI($: EngineInterface, root: string): Promise<string> {
174 const sha = (await $.process.run(['git', '-C', root, 'rev-parse', 'HEAD'])).stdout.trim()
175 await $.state.set({ plugin: 'skillhub-guards', key: 'ciWatch' }, { sha, since: Date.now() })
176 $.ui.status(`CI ${sha.slice(0, 8)}: waiting`)
177 return sha
178}
179
180async function testDsn($: EngineInterface, root: string): Promise<string> {
181 try {
182 return dsnFromEnvFile(await $.fs.read(`${root}/.env`)) ?? DOCUMENTED_TEST_DSN
183 } catch {
184 return DOCUMENTED_TEST_DSN
185 }
186}
187
188async function pollCI($: EngineInterface) {
189 const watch = (await $.state.get({ plugin: 'skillhub-guards', key: 'ciWatch' })).value
190 if (!watch) return
191 if (Date.now() - watch.since > CI_GIVE_UP_MS) {
192 $.ui.status(`CI ${watch.sha.slice(0, 8)}: still not finished after an hour`)
193 await $.state.set({ plugin: 'skillhub-guards', key: 'ciWatch' }, null)
194 return
195 }
196 const root = await $.session.root()
197 const run = await $.process.run(['go', '-C', `${root}/tools/devctl`, 'run', '.', 'ci-status', watch.sha],
198 { timeoutMs: 120_000 })
199 const output = `${run.stdout}\n${run.stderr}`
200 const verdict = ciVerdict(output)
201 if (verdict === undefined || verdict === 'pending') {
202 $.ui.status(`CI ${watch.sha.slice(0, 8)}: running`)
203 return
204 }
205 await $.state.set({ plugin: 'skillhub-guards', key: 'ciWatch' }, null)
206 const text = ciMessage(watch.sha, verdict, output)
207 $.ui.status(text)
208 $.ui.toast(text, { timeoutMs: 15_000 })
209}
210
211export const register: Register = on => {
212 on('session.start', async ($, e, next) => {
213 await $.command.register({
214 name: 'guard-allow',
215 description: 'Let the action skillhub-guards last refused through, once',
216 })
217 await $.tool.register(MUTATION_TOOL_SPEC)
218 await $.tool.register(SHIP_TOOL_SPEC)
219 $.clock.every(CI_POLL_MS, () => { void pollCI($) })
220 return next(e)
221 })
222
223 on('command.run', { command: 'guard-allow' }, async ($, e) => {
224 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') {
225 return { text: 'skillhub-guards: only a person typing /guard-allow can grant it.' }
226 }
227 const lastDenied = (await $.state.get({ plugin: 'skillhub-guards', key: 'lastDenied' })).value
228 if (!lastDenied) return { text: 'skillhub-guards: nothing has been refused yet.' }
229 await $.state.set({ plugin: 'skillhub-guards', key: 'allowance' }, lastDenied)
230 return { text: `skillhub-guards: the next attempt at ${lastDenied} goes through, once.` }
231 })
232
233 on('agent.spawn', ($, e, next) => {
234 const problem = spawnProblem(e.model, e.subagentType, e.parentModel, e.fork)
235 return problem ? { deny: `skillhub-guards: ${problem}` } : next(e)
236 })
237
238 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
239 const problem = e.agentId !== undefined ? subagentCommandProblem(e.command) : undefined
240 if (problem) return { deny: `skillhub-guards: a subagent ${problem}.` }
241 const root = await $.session.root()
242
243 if (GIT_PUSH.test(e.command)) {
244 const refusal = await unallowedRuntimeImagePush($, root)
245 if (refusal) return refusal
246 }
247
248 if (heredocWritesCodeWithBackslash(e.command)) {
249 return { deny: 'skillhub-guards: this heredoc writes a code file whose body holds backslashes, and the shell ' +
250 'layer has halved them before; write the file with the Write tool instead.' }
251 }
252
253 if (needsSignature(e.command)) {
254 const staged = await $.process.run(['git', '-C', root, 'diff', '--cached', '--name-only', '--', DOMAIN_MEMORY])
255 const action = `unsigned commit touching ${DOMAIN_MEMORY}`
256 if (staged.stdout.trim() !== '' && !(await consumeAllowance($, action))) {
257 return refuse($, action,
258 `commits touching ${DOMAIN_MEMORY} must be signed (git commit -S); the server rejects an unsigned one ` +
259 'at push, and amend and reset are not allowed to repair it.')
260 }
261 }
262
263 const rewritten = withTestDatabase(e.command, await testDsn($, root))
264 if (rewritten !== undefined) {
265 const result = await next({ ...e, command: rewritten })
266 const shell = result.result as { stdout?: unknown } | undefined
267 if (result.deny !== undefined || typeof shell?.stdout !== 'string') return result
268 return { ...result, result: { ...shell, stdout: TEST_DATABASE_NOTE + shell.stdout } }
269 }
270
271 const watched = TEST_RUN.test(e.command) ? undefined : await status($, root)
272 const result = await next(e)
273 const after = watched === undefined ? undefined : await status($, root)
274 if (watched !== undefined && after !== undefined) {
275 await remember($, attributedToCommand(watched, after).map(path => `${root}/${path}`))
276 }
277 if (e.agentId === undefined && GIT_PUSH.test(e.command) && result.deny === undefined && result.isError !== true) {
278 await watchCI($, root)
279 }
280 return result
281 })
282
283 on('tool.call', { tool: `mcp__skillhub-guards__${SHIP_TOOL}` }, async ($, e) => {
284 if (e.agentId !== undefined) return { deny: 'skillhub-guards: a subagent may not commit or push (AGENTS.md 開發自動化 3).' }
285 const input = e as unknown as ShipInput
286 const root = await $.session.root()
287 const paths = input.paths.map(path => repoRelative(root, path))
288 const loose = shipPathProblem(paths)
289 if (loose) return { result: loose, isError: true as const }
290 for (const path of paths) {
291 if (await isDirectory($, `${root}/${path}`)) return { result: `"${path}" is a directory; name each file`, isError: true as const }
292 }
293
294 const lint = await $.process.run(['go', '-C', `${root}/tools/devctl`, 'run', '.', 'comment-lint', ...paths],
295 { timeoutMs: 300_000 })
296 if (lint.exitCode !== 0) return { result: `comment-lint refused:\n${lint.stdout}${lint.stderr}`, isError: true as const }
297
298 const staged = await git($, root, ['add', '--', ...paths])
299 if (!staged.ok) return { result: `git add failed:\n${staged.out}`, isError: true as const }
300 const sign = input.sign === true || paths.some(path => path.startsWith(`${DOMAIN_MEMORY}/`))
301 const commit = await git($, root, ['commit', ...(sign ? ['-S'] : []), '-F', '-', '--', ...paths], input.message)
302 if (!commit.ok) return { result: `git commit failed:\n${commit.out}`, isError: true as const }
303
304 await git($, root, ['fetch', '--quiet'])
305 const fastForward = await git($, root, ['merge-base', '--is-ancestor', 'origin/main', 'HEAD'])
306 if (!fastForward.ok) {
307 return { result: 'committed, not pushed: origin/main has commits this branch lacks; run git pull --rebase, ' +
308 'then git push (the push is still watched).', isError: true as const }
309 }
310 const refusal = await unallowedRuntimeImagePush($, root)
311 if (refusal) return refusal
312 const push = await git($, root, ['push', '--quiet'])
313 if (!push.ok) return { result: `committed, push failed:\n${push.out}`, isError: true as const }
314 const sha = await watchCI($, root)
315 return { result: `pushed ${sha.slice(0, 8)}${sign ? ' (signed)' : ''}; CI is being watched\n${push.out}`.trim() }
316 })
317
318 on('tool.call', { tool: `mcp__skillhub-guards__${MUTATION_TOOL}` }, async ($, e) => {
319 const input = e as unknown as MutationInput
320 const root = await $.session.root()
321 const path = /^([a-zA-Z]:|\/)/.test(input.file) ? input.file : `${root}/${input.file}`
322 const original = await $.fs.read(path)
323 const mutated = replaceOnce(original, input.find, input.replace)
324 if (typeof mutated !== 'string') return { result: mutated.problem, isError: true as const }
325
326 await $.fs.write(path, mutated)
327 let run
328 try {
329 run = await $.process.run(input.argv, {
330 cwd: input.cwd ? `${root}/${input.cwd}` : root, env: input.env, timeoutMs: MUTATION_TIMEOUT_MS,
331 })
332 } finally {
333 await $.fs.write(path, original)
334 }
335 const restored = (await $.fs.read(path)) === original
336 const { verdict, summary } = mutationVerdict(run.exitCode, `${run.stdout}\n${run.stderr}`)
337 if (!restored) {
338 return { result: `RESTORE FAILED: ${path} differs from before; fix it now\n${verdict}\n\n${summary}`, isError: true as const }
339 }
340 return { result: `${verdict}\nfile restored byte for byte\n\n${summary}` }
341 })
342
343 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
344 if (e.tool !== 'Edit' && e.tool !== 'Write') return next(e)
345 const path = e.file_path
346 if (isAgentCache(path, await $.session.root())) {
347 return { deny: `skillhub-guards: ${path} is a generated agent cache; edit .claude/agents or .claude/skills ` +
348 'and run agent-sync (AGENTS.md 開發自動化 3).' }
349 }
350 if (isGeneratedHeader(await readHeader($, path))) {
351 return { deny: `skillhub-guards: ${path} declares itself generated; change its source and regenerate with ` +
352 'task gen:sql or task gen:openapi (AGENTS.md 開發自動化 5).' }
353 }
354 if (!(await touched($)).includes(normalize(path))) {
355 const dirty = await $.process.run(['git', '-C', directoryOf(path), 'status', '--porcelain', '--', path])
356 const action = `edit of ${path}`
357 if (dirty.exitCode === 0 && dirty.stdout.trim() !== '' && !(await consumeAllowance($, action))) {
358 return refuse($, action,
359 `${path} has uncommitted changes this session did not make; keep them and report ` +
360 '(AGENTS.md 開發自動化 3).')
361 }
362 }
363 const result = await next(e)
364 if (result.deny === undefined && result.isError !== true) await remember($, [path])
365 return result
366 })
367}
368hooks/assist.ts 126 lines1export const DOCUMENTED_TEST_DSN = 'postgres://REDACTED:skillhub@localhost:5432/skillhub_test?sslmode=disable'
2export const CI_POLL_MS = 60_000
3export const CI_GIVE_UP_MS = 60 * 60_000
4export const MUTATION_TIMEOUT_MS = 600_000
5const SUMMARY_LINES = 30
6export const MUTATION_TOOL = 'mutation_probe'
7export const TEST_DATABASE_NOTE = '[skillhub-guards] ran against the test database with SKILLHUB_REQUIRE_DB=1 and -count=1\n'
8
9const ARG = String.raw`(?:"[^"]*"|'[^']*'|\S+)`
10const GO_TEST = new RegExp(
11 String.raw`(^|[;&|(\n]|\$\()(\s*)((?:[A-Za-z_]\w*=\S*\s+)*)((?:rtk\s+(?:proxy\s+)?)?go(?:\s+-C\s+${ARG})?\s+test\b)`)
12const FAILURE_LINE = /--- FAIL|^FAIL\b|^panic:|\(fail\)|FAILED|AssertionError|✗|×|Error:/
13const BUILD_FAILURE = /\[build failed\]|\[setup failed\]|error TS\d+|SyntaxError|cannot find package|\bundefined: /
14
15export const dsnFromEnvFile = (text: string): string | undefined =>
16 text.match(/^SKILLHUB_TEST_DATABASE_URL=["']?([^"'\r\n]+)["']?\s*$/m)?.[1]
17
18export const withTestDatabase = (command: string, dsn: string): string | undefined => {
19 if (!/apps[\\/]platform/.test(command) || /SKILLHUB_TEST_DATABASE_URL=/.test(command)) return undefined
20 const match = GO_TEST.exec(command)
21 if (!match) return undefined
22 const [whole, separator, space, assignments, goTest] = match
23 const count = /\s-count[= ]/.test(command) ? '' : ' -count=1'
24 const rewritten = `${separator}${space}${assignments}SKILLHUB_TEST_DATABASE_URL='${dsn}' SKILLHUB_REQUIRE_DB=1 ${goTest}${count}`
25 return command.slice(0, match.index) + rewritten + command.slice(match.index + whole.length)
26}
27
28export type CIVerdict = 'green' | 'red' | 'pending'
29
30export const ciVerdict = (output: string): CIVerdict | undefined =>
31 output.match(/^\w+ \(\w+\): (green|red|pending)\b/m)?.[1] as CIVerdict | undefined
32
33export const replaceOnce = (text: string, find: string, replace: string): string | { problem: string } => {
34 const first = text.indexOf(find)
35 if (find === '' || first < 0) return { problem: 'the text to break was not found in the file' }
36 if (text.indexOf(find, first + 1) >= 0) return { problem: 'the text to break occurs more than once; give a longer snippet' }
37 return text.slice(0, first) + replace + text.slice(first + find.length)
38}
39
40export const mutationVerdict = (exitCode: number, output: string) => {
41 const lines = output.split(/\r?\n/)
42 const failures = lines.filter(line => FAILURE_LINE.test(line))
43 const summary = (failures.length > 0 ? failures : lines.filter(line => line.trim() !== ''))
44 .slice(-SUMMARY_LINES).join('\n')
45 if (exitCode === 0) return { verdict: 'GREEN: the test still passes with the line broken; it does not catch this', summary }
46 if (BUILD_FAILURE.test(output)) {
47 return { verdict: 'BUILD FAILED: a compile error does not prove the test catches it; break the behaviour instead', summary }
48 }
49 return { verdict: 'RED: the test fails with the line broken', summary }
50}
51
52export const MUTATION_TOOL_SPEC = {
53 name: MUTATION_TOOL,
54 description: 'Prove a test catches a defect: replaces one exact snippet in a product file, runs the given ' +
55 'test command, always restores the file byte for byte, and reports RED, GREEN or BUILD FAILED with the ' +
56 'failing lines. Use it for every "fixed X" claim instead of editing and reverting by hand.',
57 inputSchema: {
58 type: 'object',
59 properties: {
60 file: { type: 'string', description: 'product file, absolute or relative to the project root' },
61 find: { type: 'string', description: 'exact snippet that occurs once in the file' },
62 replace: { type: 'string', description: 'the broken version of that snippet' },
63 argv: { type: 'array', items: { type: 'string' }, description: 'test command as argv, no shell' },
64 cwd: { type: 'string', description: 'directory to run in, relative to the project root' },
65 env: { type: 'object', additionalProperties: { type: 'string' } },
66 },
67 required: ['file', 'find', 'replace', 'argv'],
68 },
69}
70
71export type MutationInput = {
72 file: string
73 find: string
74 replace: string
75 argv: string[]
76 cwd?: string
77 env?: Record<string, string>
78}
79
80export const ciMessage = (sha: string, verdict: 'green' | 'red', output: string): string => {
81 const failed = output.split('\n').filter(line => /^\s+failure /.test(line)).map(line => line.trim())
82 return verdict === 'green' ? `CI ${sha.slice(0, 8)}: green` : `CI ${sha.slice(0, 8)}: red — ${failed.join('; ') || 'see ci-status'}`
83}
84
85const CODE_FILE = String.raw`["']?[^\s"'|;&<>]+\.(?:mjs|cjs|js|jsx|ts|tsx|go|py|sh|ps1|sql|ya?ml|toml|json)["']?`
86const HEREDOC_INTO_CODE = new RegExp(
87 String.raw`(?:(?:>>?|\btee\s+(?:-a\s+)?)\s*${CODE_FILE}[^\n]*<<-?\s*['"]?\w+|<<-?\s*['"]?\w+['"]?[^\n]*(?:>>?|\btee\s+(?:-a\s+)?)\s*${CODE_FILE})`)
88
89export const heredocWritesCodeWithBackslash = (command: string): boolean => {
90 const header = command.indexOf('\n')
91 return header >= 0 && HEREDOC_INTO_CODE.test(command.slice(0, header)) && command.slice(header).includes('\\')
92}
93
94export const repoRelative = (root: string, path: string): string => {
95 const unify = (p: string) => p.replace(/\\/g, '/')
96 const base = unify(root).replace(/\/$/, '') + '/'
97 const target = unify(path)
98 return target.toLowerCase().startsWith(base.toLowerCase()) ? target.slice(base.length) : target
99}
100
101export const shipPathProblem = (paths: string[]): string | undefined => {
102 if (paths.length === 0) return 'name the files to commit'
103 const loose = paths.find(path => /^(?:\.|-.*|.*[*?[].*|.*\/)$/.test(path) || path.includes('..'))
104 return loose === undefined ? undefined : `"${loose}" is not one explicit file; name each file`
105}
106
107export const SHIP_TOOL = 'ship'
108export const SHIP_TOOL_SPEC = {
109 name: SHIP_TOOL,
110 description: 'Commit and push named files the way this repository requires: comment-lint them, stage each by ' +
111 'explicit path, sign when docs/domain-memory is staged, refuse a non-fast-forward push and a runtime ' +
112 'image publish the owner has not allowed, push, then watch CI. Use it instead of hand-written git commands.',
113 inputSchema: {
114 type: 'object',
115 properties: {
116 paths: { type: 'array', items: { type: 'string' }, description: 'each file to commit, one path per entry' },
117 message: { type: 'string', description: 'the full commit message, attribution lines included' },
118 sign: { type: 'boolean', description: 'sign the commit even when no domain-memory file is staged' },
119 },
120 required: ['paths', 'message'],
121 },
122}
123
124export type ShipInput = { paths: string[]; message: string; sign?: boolean }
125
126types/index.d.ts 14 lines1export type GuardedAction = string | null
2export type CIWatch = { sha: string; since: number } | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 'skillhub-guards': {
7 touched: string[]
8 lastDenied: GuardedAction
9 allowance: GuardedAction
10 ciWatch: CIWatch
11 }
12 }
13}
14