SLOPSHOPPER

skillhub-guards

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…

newguardcommandtoaststatustool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skillhub-guards
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard-allow ⎿ skillhub-guards: skillhub-guards: nothing has been refused yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ skillhub-guards: CI : waiting
README

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

Why Skill Hub

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.

  • Discover Skills through keyword and semantic search, with source, licence, dependency, permission and compatibility context.
  • Create and improve Skills from a task description or guided conversation, while preserving an auditable version history.
  • Trial a frozen Skill Version with an explicit prompt, test data, acceptance criteria, cost boundary and trace.
  • Evaluate results as met, partially met, unmet or undetermined, then turn approved improvements into a new immutable version.
  • Package and distribute portable Skill packages that retain provenance, licence and selected test material.

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.

Architecture at a glance

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.

Security model

  • Untrusted Skills, scripts and uploaded data never run in the web or API process.
  • The execution plane cannot connect to the core database.
  • User data is Workspace-scoped from the authenticated session, not a client-supplied workspace id.
  • Skill Versions, Test Case snapshots and historical Runs are immutable; adopting an improvement creates a new version.
  • Secrets must not appear in packages, logs, traces or analytics. Traces are masked before storage.
  • Production Sandbox nodes use gVisor with default-deny egress controls and are replaced rather than repaired.

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.

Quick start: clean test mode

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.

Local full-stack development

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:

ComponentCommandResponsibility
APIgo -C apps/platform run ./cmd/apiHTTP, authentication, authorization and domain commands
Workergo -C apps/platform run ./cmd/workerRun dispatch, cleanup, outbox and periodic work
LLM capabilitycd apps/llm && uv run uvicorn skillhub_llm.app:appstructured model-backed capabilities
Sandbox providergo -C apps/sandbox run ./cmd/sandboxdlocal execution-provider boundary
Webnpm --prefix apps/web run devReact 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.

Optional model capability and cost

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.

Remote development: Codespaces and Dev Containers

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.

  • GitHub Codespaces: create a Codespace from the repository and wait for the post-create step to finish.
  • VS Code Dev Containers: clone the repository locally, install the Dev Containers extension, and choose Reopen in Container.

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.

Test and verify

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.

Repository map

PathPurpose
apps/webReact and TypeScript user interface
apps/platformGo control plane, Worker and bounded contexts
apps/llmPython FastAPI capability provider
apps/sandboxGo Sandbox provider
packagesReusable libraries and generated API clients
contractsSource of truth for cross-process OpenAPI, events and packaging contracts
dbMigrations, queries, SQL ownership and sqlc configuration
infraCompose, deployment, runtime images, egress and observability
toolsDevelopment, CI, data-maintenance and operations commands
docsProduct plans, architecture decisions, design guidance and runbooks

Documentation

Contributing

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

License

MIT

Source 3 files
hooks/register.ts 368 lines
1import 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}
368
hooks/assist.ts 126 lines
1export 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
126
types/index.d.ts 14 lines
1export 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