Guidance for any AI agent working in this repository. CLAUDE.md is a symlink to this file.
A pnpm workspace monorepo hosting AI agent plugins developed at Unic. Today it contains Claude Code plugins; the structure supports plugins for other agents (GitHub Copilot, etc.) in the future.
Root scripts are in package.json. Per-plugin operations:
pnpm --filter <name> bump patch # bump plugin version
pnpm --filter <name> verify:changelog # check changelogEvery plugin must work on macOS, Windows, and Linux. Use Node.js APIs (node:path, node:fs, node:os) instead of shell commands. CI runs all three OSes × Node 22 and 24.
Formatting is enforced by Biome and Prettier; read biome.json for the values.
- No TypeScript compilation —
// @ts-check+ JSDoc for type safety
Plugins are versioned independently. plugin.json is the source of truth. Use pnpm --filter <name> bump <patch|minor|major> — never hand-edit marketplace.json.
On a 0.x plugin a breaking change is a minor bump, and major is reserved for the 1.0.0 release itself. SemVer §4 makes the 0.x API unstable by definition, and 1.0.0 is reached by meeting that plugin's own release bar — never by making a breaking change. So the ### Breaking changelog entry is what warns a consumer there, not the version number: write the entry, and never reach for bump major to signal it. Read the plugin's current version off its plugin.json to know which rule applies. Above 1.0.0 the ordinary contract holds. See ADR-0022.
Tag scheme: <plugin-name>@<version> (e.g. auto-format@0.5.5).
CHANGELOG version headers must use the format ## [X.Y.Z] — YYYY-MM-DD (em dash, then ISO date). pnpm bump writes this format and verify:changelog (in packages/release-tools) structurally enforces — YYYY-MM-DD on every versioned header. Do not change the separator or the date format: CI and the release flow depend on it.
Use package scope: feat(auto-format): …, fix(pr-review): …, chore(release-tools): …, chore(unic-archon-dlc): …, feat(unic-pr-review): ….
| Branch | Purpose |
|---|---|
main |
Production. Only receives merge commits from develop (or hotfix/*). The release workflow fires here and creates tags. |
develop |
Integration. Default target for all feature PRs. CI runs on every push and PR. |
feature/<name> |
Day-to-day work. Branch from develop, PR back to develop. |
hotfix/<name> |
Urgent fixes only. Branch from main, PR to both main and develop. |
Never commit directly to main or develop. Always go through a PR.
PRs merge with a merge commit, never a squash — the release flow reads develop → main merges.
The Archon worktrees under ~/.archon/workspaces/<org>/<repo>/worktrees/ are worktrees of your clone, not a separate checkout. They share its ref store, its config and its origin, so an autonomous run can move develop or main directly and bypass the PR gate above. .githooks/pre-push refuses a push of either branch when the push comes from a path under .archon/workspaces/. This Archon guard leaves your own pushes alone, so read a refusal as "an Archon worktree tried this", never as "develop is protected".
Archon also keeps a default_branch of its own per repository, set from whatever was checked out on its first run there and never re-read from the host. worktree.baseBranch in .archon/config.yaml overrides it, and --from <branch> overrides that. A bare top-level baseBranch: has no reader — the nesting is the whole setting. So fix a wrong fork point in that file, never in ~/.archon/archon.db, which is one machine's row.
Run pnpm install once in every clone, including one that still has symlinks in .git/hooks. It points core.hooksPath at the main work tree's .githooks, as an absolute path, so every worktree of the clone runs the hooks the main work tree has checked out. Git then reads no hook from .git/hooks. The old symlinks may stay or go, but a hook of your own there, such as a personal post-commit, stops running.
A worktree on a branch without hooks is guarded too, but only while the main work tree is on a branch that carries them. Today main carries only the Archon part of pre-push, so with the main work tree on main no worktree runs the NDA commit hooks or the NDA push scan. pnpm install then fails with the name of each missing file, but only in a worktree whose branch carries .githooks/set-hooks-path.mjs. In the main work tree, main has no prepare script, so the install passes in silence. That gap closes once #576, #577 and #589 reach main.
When the hooks end up off, pnpm install fails and prints why, except in the cases below. In the first three, prepare does not run or its path is stale. One is a moved clone, where the absolute path is stale. Another is an install with --ignore-scripts, or with ignore-scripts=true in any npmrc, where prepare never runs. The third is an install on a branch with no prepare script, such as main today. In each of these three, check out a branch that carries .githooks in the main work tree, then run pnpm install there without that setting.
A worktree moved by hand is not reported either. pnpm install skips a worktree that git marks prunable or whose directory is gone, prints a warning and exits 0, because prepare cannot see the new path. Git marks a worktree moved by hand, or one whose directory it cannot read, as prunable. It lists a locked worktree moved by hand as locked instead, and the install skips that one because its directory is gone. Such a worktree still commits from its new place and reads its own config.worktree, so its hooks may be off. If you still use it, run git worktree repair <new path>, then pnpm install. Otherwise run git worktree prune, after git worktree unlock <old path> for a locked one. The install also skips an entry whose old path now holds another repository. git worktree prune does not clear that entry, so remove its directory under .git/worktrees by hand.
pnpm install reads the value back in every other worktree of the clone, except one that git marks prunable or whose directory is gone. A value in the config.worktree of a worktree that differs from the main work tree's .githooks fails the install and names that worktree. prepare runs only at install, so any change after install can turn the hooks off with no report, for example:
- A
git config --worktreevalue set in a worktree after install, or a value set by hand. - The main work tree moves to a branch that lacks a hook file, or where
pre-commit,commit-msgorpre-pushhas no executable bit. Git skips a hook without the bit and prints only ahint:, andadvice.ignoredHook=falsehides even that. No check covers the executable bit ofpre-commitorcommit-msgafter install, in or outside an agent session. Since #589 the Claude hook refuses a push from an agent session whenpre-pushis not executable. A push made outside an agent session has no such check. - An
includeIfin.git/config, after thecore.hooksPathentry, setscore.hooksPath. Its value wins as soon as its condition holds, for example anonbranch:include after a branch switch, or agitdir:include in the worktree it names. AnincludeIfin the global config does not win over.git/config.
After such a change, run pnpm install again in the main work tree.
This repository is public, and one client of the DLC work is under an NDA: its name, its repository name, and anything that would identify it must never reach GitHub. Two guards enforce that, the Claude hook and the git hooks, and neither of them names a protected term, which is why both can live here.
The terms are read from a file outside every repository — $UNIC_NDA_DENYLIST, else ~/.config/unic/nda-denylist.txt, one term per line, # for comments. A denylist that names the client is itself the leak, so it is never committed anywhere.
Each guard covers what it can see, and nothing more. ADR-0036 records why.
| Guard | Covers | Wired by |
|---|---|---|
.githooks/pre-push, which runs .githooks/nda-push.mjs |
Every push from this clone: both ref names, every tag object on the way, and every commit the push sends: the whole commit object, headers and message, and the added lines of its patch | core.hooksPath, which pnpm install sets (see above) |
.githooks/pre-commit, .githooks/commit-msg |
Any commit in this clone, whoever makes it: the added lines of the staged diff, then the message. They are an early warning, and pre-push catches what they miss |
core.hooksPath |
.claude/hooks/block-nda-terms.mjs |
What git never sees: the text of gh and glab commands and the files they name. For git, it checks only that pre-push will run |
.claude/settings.json, PreToolUse on Bash and PowerShell. It is committed, so there is nothing to install |
pre-push is the git guard that matters. A commit made with --no-verify, by am, cherry-pick or merge, or in another clone never met the commit hooks, but it cannot reach GitHub without a push. The commits a push sends are those reachable from the local sha and from neither the remote sha nor any refs/remotes/<remote>/* ref. A remote-tracking ref behind the remote makes it scan more. One that holds a commit the remote does not have makes it scan less, as the gaps below say. A remote sha that is not a local commit, as after a force push over commits never fetched, is left out rather than failing the listing, which scans more. A merge commit's patch is read against its first parent. A line that deletes a ref is skipped. The NDA scan runs on every push, before the Archon guard above.
Deleted lines do not count, in pre-commit or in pre-push. The deleted text is already in the published history, so refusing it would block the commit that removes a term. Both read diffs with --text --no-ext-diff --no-textconv, and --no-color, so a binary file, a -diff attribute, a textconv filter, diff.external and color.ui=always do not replace the content they read. They also pass --full-index, for the PNG read below, and --src-prefix=a/ --dst-prefix=b/, so diff.noprefix, diff.mnemonicPrefix and diff.dstPrefix do not change the +++ b/ line the guards use to tell that a file is a PNG. Without these flags, under such a setting, the guards would scan a PNG's hunks and refuse a chance match in IDAT again. For every file, both drop the index lines before they scan, because those lines hold only object ids. pre-commit reads the diff inside node, so a NUL byte next to a term stays a boundary. The matcher reads every text a second time with its NUL bytes removed, so a term in UTF-16 text in the ASCII range is found. Compressed content is not, as the gaps below say. pre-push also runs git with --no-replace-objects, runs git log with --root and --encoding=UTF-8, peels nested tags one at a time, and scans the blob or tree that a tag on a non-commit points at.
The git guards read a PNG from its blob, chunk by chunk, not from its diff. For an added or modified file whose path ends in .png, in any case, pre-commit and pre-push skip the file's hunks. They read its post-image blob by the id on the file's index line, which --full-index makes whole. They scan the type and data of every chunk except IDAT, and each chunk is its own text, so no match spans two chunks. The guards still read tEXt, iTXt, eXIf and every other chunk, wherever it sits in the file. IDAT holds deflate data, where a short term turns up by chance in bytes that hold no text anyone wrote. When the blob has a wrong signature, a chunk that runs past its end, 1 to 11 bytes after its last whole chunk, or a first chunk that is not IHDR or a last chunk that is not IEND, the guards scan the whole blob. When git cannot read the blob, the guards write a line to stderr and scan the file's hunks as for any other file. The guards read the PNG's path as they read any other path. ADR-0036 records why.
The Claude hook runs these checks on a command from the Bash or PowerShell tool, in this order. On Windows, Claude Code can send shell commands through the PowerShell tool, and its payload carries the command in the same field as Bash.
- It refuses a command whose own text holds
--no-veriorhookspath, in any case, whatever the verb.--no-vericatches--no-verifyand every abbreviation of it that git accepts. Git rejects--no-v,--no-veand--no-veras ambiguous, so--no-verbosepasses. The check also covers-c core.hooksPath=andGIT_CONFIG_KEY_<n>=core.hooksPath. It also refusesgit config --get core.hooksPathand agrepfor the word, on purpose. To mention either in a commit message, write the message to a file and commit with-F <file>. An authorised use is for the maintainer to run with!. - It refuses a command with
send-packas a word, because git runs nopre-pushfor it. It refuses a command withpushas a word unless this clone'spre-pushwill run. A hyphen or a dot bounds a word too, socat .githooks/pre-pushandnda-push.mjscount as a push. The session's cwd must be in this clone or one of its worktrees,core.hooksPathmust point at the.githooksof the main work tree, and that directory must holdpre-pushandnda-push.mjs, withpre-pushexecutable where the OS has an executable bit. The refusal names the condition that failed, and offers a push from the clone afterpnpm install, or!.
Only a command with git, gh or glab as a word anywhere goes on to the checks below, and only while the term list holds a term. In a PowerShell command these three words count in any case, because PowerShell and Windows ignore case in command names. push and send-pack keep their case in both tools, because git rejects PUSH.
- It refuses a
ghorglabcommand that also changes directory. That iscdorpushdas a word, orSet-LocationorPush-Locationas a word in any case. A path segment with either of those two names is unlikely, so the hook refuses one too. It is alsosl,cd,pushdorchdirin any case at the start of a command. The start of a command is the start of the text or of a line, or a point after;,|,&,(,{,},",',=or.. Whitespace may sit between that point and the name. Sodocs/sl/x.mdpasses, but the hook refuses--title "sl fix". It also refuses a sentence or a domain name that putscdorslafter a.. The hook refuses a directory change because a relative path after it resolves where the hook does not look. Name every file by its absolute path, or have the maintainer run the command with!. - For a
ghorglabcommand, it splits the text on whitespace, quotes, backticks,=,@,<,(,),$,;,&and|, and it also tries whole each quoted string, each whitespace-split token, and the part of a token after its first=, so a path that holds@or=is read. It reads every piece that is an existing file, resolved against the session's cwd with a leading~/expanded, except a file whose lowercased basename isgh,glab,gh.exeorglab.exe, which is taken for the executable. That reads--body-file=<path>,-F body=@file,--file=<path>,$(<file),<file,`cat file`and a quoted path with a space. It refuses a file over 2 MB rather than skip it. Reading more than a command needs costs nothing, because the hook refuses only when it finds a term. - It scans the command text and every file it read for a term. So
sh -c 'gh …',/usr/bin/ghand\ghare read too, and forgita branch or tag named with a term is refused before git runs.
The Claude hook reads no staged diff and parses no commit directory. pre-commit sees the real staged content, whatever the command string says.
Every guard applies one matching rule, from .githooks/nda-match.mjs. pre-commit and commit-msg run it with node. The Claude hook and nda-push.mjs, which pre-push runs, import it. Change the rule there and nowhere else. It works in three steps:
- It removes base64 data. It removes the payload of every
data:…;base64,URI whose payload has 80 or more characters, and keeps the media type. It also removes every run of 80 or more characters made only of letters, digits,+and/that holds a digit and either is followed by=or holds a+right after a letter or digit. A sha512 hash, 88 characters ending in==, is such a run. A+must follow a letter or digit, so neither the+that starts every staged diff line, the second+of a diff line inside Markdown, nor the+of a SvelteKit/+pageroute counts. A short term inside embedded font data identifies nobody, and every Archify diagram embeds a font. A long path or URL rarely has a+right after a letter or digit, or an=right after it, so the rule still matches one. - It matches a term, ignoring case, only on a word boundary at both ends. A boundary is the edge of the text, any character that is not a letter or digit, a change between letter and digit, a change from lower to upper case, or the capital that starts a capitalised word after another capital. So
acme,acme-site,acme_site,acme2026,acmeSite,myAcme,XAcmeandACMESiteall matchacme. - When the text holds a NUL byte and step 2 found nothing, it runs steps 1 and 2 again on the text with every NUL byte removed. UTF-16 text in the ASCII range reads as letters with a NUL between each pair, so this finds a term in it. The first run keeps a NUL next to a term as a boundary.
Before a text goes public, check it for the four shapes the rule lets through. The first is a term joined to a letter where the join is neither a change from lower to upper case nor the capital that starts a capitalised word after another capital, such as acmesite, Acmesite or ACMEsite. The second is a term inside a data:…;base64, payload of 80 or more characters. The third is a term inside a run of 80 or more characters made only of letters, digits, + and /, where the run holds a digit and either is followed by = or holds a + right after a letter or digit, such as form-encoded text or a path under a c++ directory. The fourth is a term that starts or ends with a digit and is joined there to another digit, such as acme2 inside acme26. Only a term that starts or ends with a digit can take this shape.
Describe no client in public, whether or not it is under an NDA. Write no word that tells a reader which client a piece of work is for: no sector, city, site or product name, and no example drawn from one. That also applies when the example appears in a warning about identifying a client, where it is easiest to write. The term list carries such descriptors as well as names, so the guards refuse them. A paraphrase the list does not hold still passes, so read every public text for it before you send.
Every guard fails closed. With no readable term list, a commit, a push, or a git, gh or glab command in an agent session is refused, and the message names the file to create. touch that file to opt out deliberately. An empty list turns off the term checks, but the --no-verify, hookspath, send-pack and push checks of the Claude hook still apply. The git hooks also refuse when node is not on PATH, and pre-push refuses when git cannot list the commits a push sends. The Claude hook exits 2, the only exit that blocks a PreToolUse call, on an empty payload, one that is not a JSON object, and an exception. .claude/settings.json runs it as node "$CLAUDE_PROJECT_DIR/.claude/hooks/block-nda-terms.mjs" || exit 2, so it runs from any cwd, and any other failure, such as a missing node (exit 127), also blocks. This is the opposite of the git-guardrails defect described below, where a missing jq makes the hook exit 0 and read as a successful block.
These gaps are accepted:
git commit -nskips the commit hooks, and the Claude hook does not detect it, because inpushthe same-nmeans--dry-run.pre-pushcatches the commit.- A push that the command points at another repository, such as
git -C <other clone> push, or through any option or environment variable that changes git's directory, is checked against the cwd's config, not the other repository's. - A git alias that pushes holds no
pushword, so the Claude hook does not check it.git -c include.path=<file> push, where the file setscore.hooksPath, switches the hooks off without the word the hook looks for. GIT_CONFIG_GLOBALandGIT_CONFIG_SYSTEMare not checked, because the clone's localcore.hooksPathoverrides both. Do not add them.- A quote or a backslash inside a word the Claude hook looks for splits that word. Examples are
git push --no-"verify",git -c core.hooks""Path=…,git -c core.hooks\Path=…andg""h issue create --body-file <file>. The hook does not refuse such a command. A split flag word can switch the git hooks off, and a splitghmeans the hook reads no named file. - A
ghfile is not read when it is named through a shell variable, a glob, a brace expansion or a backslash-escaped space, or read afterenv --chdir=<dir>, or read by any process or job started with a working-directory parameter, such asStart-Process gh -WorkingDirectory <dir> …,pwsh -WorkingDirectory <dir> -Command "…", the same with-wd, andStart-Job -WorkingDirectory <dir> …. - A file whose lowercased basename is
gh,glab,gh.exeorglab.exeis never read, whether or not it is the executable. - A non-ASCII term in Latin-1 or UTF-16 content is not found. The second read without NUL bytes covers UTF-16 in the ASCII range only.
- In PowerShell the backtick is the escape character, so a backtick splits any word the Claude hook looks for. Examples are
git push --no-ver`ify,git -c core.hooks`Path=…andg`h issue create --body-file <file>. PowerShell runs them with--no-verify,hooksPathandgh, but the hook sees none of those words. A split flag word defeats the check for--no-verifyandhooksPath. A splitpushorsend-packdefeats the push check. A splitghorglabdefeats the directory check and the reading of named files. When the command holds no othergit,ghorglabword, it also defeats the term scan of the command text. The entry above gives the same gap for a quote or a backslash. - On Windows without Git Bash no NDA guard works. The git hooks are
shscripts, and the Claude hook's entry in.claude/settings.jsonisshsyntax that no measurement has shown to run there. pre-commitdoes not see the author or the committer of a commit.pre-pushreads both, so it catches a term there.commit-msgdrops every line from the scissors line down and every line that starts with the comment string before it matches. Git does not always drop them, and the hook cannot see when it keeps them. Git keeps comment lines when it opens no editor, as under-m,-F,-Cand--no-edit. It also keeps them under--cleanup=verbatim,--cleanup=whitespaceand--cleanup=scissors. It cuts at the scissors line only with-v,commit.verboseor--cleanup=scissors. Without a cut, git applies its cleanup mode to the lines below the scissors line as to any other line. A term on such a line reaches the commit, andpre-pushrefuses it there, because it reads the whole commit message.- A Git Bash path such as
/c/Users/x/body.mdafter--body-filedoes not resolve on Windows, so the file is not read. Use a Windows path or!. - Compressed content, such as a
.gz,.zipor.docxfile, is scanned as its compressed bytes, so a term inside it is not found by any guard. - The git guards skip the
IDATchunks of a*.pngfile that git can read and that parses as a PNG. So they do not find a term there. When the blob does not parse, the guards scan the whole blob. When git cannot read the blob, the guards scan the file's diff lines. - The git guards scan the bytes of a compressed
zTXt,iTXtoriCCPchunk, and of an APNGfdATchunk, as they are. A short term can match those bytes by chance, and the guards then refuse the commit and the push. The guards do not decompress these chunks, so they do not find a term in the decompressed text. - The git guards scan a PNG committed under another extension as its diff bytes,
IDATincluded, so a term that its deflate data holds by chance still refuses the commit and the push. The Claude hook also reads a PNG that aghorglabcommand names as whole bytes. - A remote-tracking ref that holds a commit the remote does not have makes
pre-pushscan less: a ref set by hand, one left over after a leaked commit was deleted from the remote, or one fetched from another URL than the push goes to. - In a worktree, git runs the hooks the main work tree has checked out. While the main work tree is on
main, thepre-pushwith the NDA scan runs only once it reachesmain. - When
nodeis missing, the Claude hook's entry in.claude/settings.jsonexits 2 and blocks everyBashandPowerShellcommand, so the session can run no shell command untilnodeis onPATH. - A person who pushes with
--no-verifyoutside an agent session skips every git hook. The Claude hook seesBashandPowerShellonly, so a publish through an MCP tool is unguarded. An Archon workflow node inherits no ambient settings, so a Box almost certainly runs without the Claude hook. Measure that before dispatching a Box that could publish.
Bugs are not a separate prefix: a bug issue that targets develop uses feature/ (the prefix encodes PR topology, not change kind). Archon-dispatched branches add a scope sub-namespace: feature/<scope>/<issue#>-<slug>, where <scope> is the area label with its tier stripped (app:unic-pr-review → unic-pr-review, repo → repo). The /archon-rollout command owns the full derivation rule.
To ship a new plugin version:
- On a feature branch, add the change to the plugin's
CHANGELOG.mdunder## [Unreleased], and commit it with the change.pnpm bumprefuses an[Unreleased]section that holds only- (none), and it refuses a working tree with uncommitted changes. - Bump the version:
pnpm --filter <name> bump <patch|minor|major>. The bump turns the[Unreleased]entries into a dated## [X.Y.Z] — YYYY-MM-DDsection and does not commit. Commit its result on its own, aschore(<name>): release X.Y.Z. - Open a PR targeting
develop. CI runsverify:changelogon all PRs — it will fail if the changelog entry is missing or malformed. - After the PR merges to
develop, open a release PR fromdevelop→main. - After the release PR merges, the release workflow on
maindetects that<name>@<version>has no tag yet and creates it automatically.
CI summary:
| Event | Root checks | Package tests | verify:changelog |
|---|---|---|---|
| PR (any branch) | ✓ | ✓ (changed packages) | ✓ |
Push to develop |
✓ | ✓ (changed packages) | — |
Push to main |
✓ | ✓ (changed packages) | — |
Every push to main publishes a card for every plugin in the root .claude-plugin/marketplace.json to the Unic AI Artefact Marketplace, source unic-agents-plugins. .github/workflows/marketplace-ingest.yml maps the catalogue with ci/map-to-envelope.mjs and posts it to the public ingest sidecar. The mapper kind is unic-agents, because this is a Claude-plugin monorepo. The full documentation is Push your catalog, which needs a VP login. Run it by hand to see the envelope:
node ci/map-to-envelope.mjs unic-agents . > body.jsonRead that output, never POST it. Off CI the mapper resolves commit, ref and author to empty strings, because it reads them from the CI environment. Posting such an envelope is how this repository's six cards got a version history with a blank author in the first place, and VP cannot repair one afterwards. The local Python tool now refuses a push it cannot attribute (UNICGRAPH-572), but that guard lives in that tool: neither this mapper nor the ingest endpoint will stop you. Only the workflow may push.
A push sends the full set. VP diffs it and soft-removes any artefact the push omits, so never narrow the set to the plugins that changed.
Both files are upstream templates, copied verbatim. Never hand-edit them and never reformat them. To update either one, re-copy it from its VP URL — map-to-envelope.mjs and pipeline-github-action.yml. Both answer 401 without a signed-in VP session, so a person must fetch them; an agent cannot. The documentation warns twice that a hand-built envelope is how provenance.author goes missing, and this repository's six cards already carry an empty author from one such push on 10 June 2026. The same warning covers the artefact content block, which does not apply here: mapUnicAgents emits no content for any plugin, because plugins are pointer artefacts obtainable via /plugin install.
biome.json excludes ci/map-to-envelope.mjs so that rule can hold: Biome reports two errors on the vendored mapper and reformats it. Keep the exclusion, and keep it scoped to that one path. A mapper reformatted to repo style makes the next re-copy read as a diff, and a directory-wide !ci would silently un-check any file added to ci/ later.
The mapper resolves provenance.author to GITHUB_ACTOR, the person who triggered the run, not the commit author. That is wrong on a merge-commit repository like this one, and it is filed upstream as UNICGRAPH-575. Do not patch it here. A local fix would hide the defect instead of surfacing it.
The ingest token lives only as the repository secret MARKETPLACE_INGEST_TOKEN (Settings → Secrets and variables → Actions). It never goes into a file, a commit or an agent conversation. The token also decides which marketplace the push lands in — the request body cannot name another source.
New work enters through the issue tracker as Features. Plan with /wayfinder when the work is too big for one agent session, or /grill-with-docs when it fits in one; then /to-spec → /to-tickets → /archon-rollout. /to-tickets iterates the breakdown with you and applies ready-for-agent once you approve it — that in-session approval is the checkpoint, and nothing re-checks it before dispatch. So keep the ready queue short: grill late, dispatch soon, and re-grill a ticket that has sat for more than a few days rather than trusting it. Use /tdd and /implement for individual issues. See docs/process/ai-development.md for the mental model.
unic-archon-dlc is not installed here — this repo builds it, it does not run it. See ADR-0033.
This repo's product is prose — commands, skills, AGENTS.md files, ADRs. So a criterion names an observable outcome: what a document must state, checked by reading it. It does not name a command carrying its pasted output, and no file:line citation inside a criterion is binding — the next merge moves the line, so a criterion written around one rots on a schedule nobody controls.
Adding a module plus a test so that prose becomes testable is a defect, not rigour. unic-archon-dlc's lib/slopcheck.mjs was the worked example: it existed only to be tested, while apps/claude-code/unic-archon-dlc/.archon/workflows/unic-dlc-build.yaml inlines its own copy and imports nothing. #381 deleted it with the whole of that plugin's lib/ and test/, which is what the next passage is the bar for.
Four reads catch what a criterion-by-criterion pass cannot. Run them while writing the criteria, inside /to-tickets, because nothing downstream runs them for you:
- Read the criteria as one set. Individually reasonable criteria can be collectively impossible, and the dangerous shape is a gap between two of them that an implementer fills with a defensible-sounding decision — a green PR that faithfully implements the wrong thing. Amend the ticket and re-dispatch; do not merge it, because it becomes the precedent the next agent reads.
- Turn the diagnosis on the cure. When a ticket says a thing rots because it is written by hand, read the fix back and ask what is still written by hand.
- Ask which criteria an implementer satisfies by changing nothing. A vacuous criterion is a finding. It reads as coverage, buys none, and costs an audit round to discover after the code exists.
- Close a grilling by listing what it did not decide. "Either is acceptable" and "whichever fits" are where an unanswered question hides. Write each one down as an open question, or decide it there.
- External runtime deps to plugins unless truly essential (
auto-formathas zero; that's the bar) - Turborepo or other build orchestrators — plain pnpm workspaces is the current choice
- Features not tracked in the issue tracker — open a Feature first
- Per-plugin
pnpm-lock.yamlfiles — the root lockfile is canonical; sub-package lockfiles should never be committed
Never create, copy, or delete LICENSE files. The maintainer manages these manually in every package and plugin directory. If an acceptance criterion requires a LICENSE file to exist, warn the maintainer to add it themselves before continuing.
A Box is prose, so its bar is a run and a read, never a test. Three parts:
- It runs where it ships. Install the plugin into a Consumer through the marketplace — no
node_modules, no hand-set environment variable — and run the Box far enough to see the step under review succeed. This is the only check that sees what this repository cannot: every command defect ofunic-archon-dlc0.22.0 was invisible topnpm testand toarchon workflow listhere, and visible on the first command run inDXP-DesignSystem. - Its rules are stated where they are needed. A rule that lives only in an
AGENTS.mdis invisible at run time, so every prompt that must honour a rule carries it inline. Read for those rules when you touch a Box, and again in review. - What it depends on is written once. One list, in prose, with no mirror and no generator — because a mirror is what drifts, and a generator is the module this bar exists to avoid.
What the bar gives up, plainly:
- A
git add -A, or a repository derived from a remote URL, now merges green if nobody reads the diff.test/box-staging-and-repo-pinning.test.mjsgrepped every Box YAML for both patterns; #381 deleted it and nothing replaces it. Both patterns are visible on the page, and both rules are stated inline in every prompt that stages a path or names a repository. That is the whole guard, and an unread diff defeats it. - An upstream rename is not caught in this repository.
test/command-methods.test.mjswas the tripwire for a Method upstream had renamed; it compared two hand-written surfaces here and never watched upstream, so it could not have caught the rename wave it was written for. Nothing replaces it either, but the moment of the check is now named rather than left to chance: upgrading a vendored Method Bundle means diffing the vendored tree against the new upstream tag by hand, in the same commit that moves the pin. The plugin version is the pin. Between two upgrades, a rename surfaces on the next Consumer run.
Issues live in GitHub Issues at unic/unic-agents-plugins (planned migration to Azure DevOps). See docs/agents/issue-tracker.md.
8-state vocabulary: needs-triage → needs-info → needs-specs → ready-for-agent / ready-for-human → resolved → closed (or rejected). See docs/agents/triage-labels.md.
Type (kind of issue): feature, bug, spike, tech-debt, docs, release. See docs/agents/labels.md, which is now the only home for the repo-local release type.
Area (which app/package): app:<plugin> one per app, pkg:<package> one per workspace package, and repo for monorepo-wide / cross-cutting work. Hand-applied. See CONTEXT-MAP.md and ADR-0032.
Multi-context repo: Per-plugin CONTEXT.md files live under apps/claude-code/<plugin>/. Root CONTEXT-MAP.md at repo root. See docs/agents/domain.md.
Matt Pocock's skills (mattpocock/skills) are the agent-skill driver for this repo, installed via npx skills and pinned in skills-lock.json. See ADR-0033.
- issue-tracker.md —
ghconventions, PR triage surface, wayfinding operations - triage-labels.md — canonical triage role → this repo's state label
- labels.md — four-tier label taxonomy: state, type, priority, area
- domain.md — multi-context layout,
CONTEXT.mdand ADR locations - feature-runner.md — AFK invocation of the feature runner
- orchestrator-and-wayfinder.md — who plans and who dispatches; why the orchestrator is a session, not an agent
- agent-tool-traps.md — measured traps in the tools this repo uses, and the shapes in which a session's claims go wrong
- dispatching-and-learning.md — writing a session opener, when to rewrite it, and where a worker's learnings go. Read its § Route each learning by its lifetime before writing project memory. Project memory stays on one machine, so every entry names a
destination:in git and moves there at the next handoff
| Path | Owner | Rule |
|---|---|---|
.claude/skills/<vendored name>, and the .agents/skills/<name> a symlink there points to |
npx skills |
Never hand-edit. Every npx skills add overwrites the directory; edits die silently. skills-lock.json says which names are vendored |
.claude/skills/{afk-loop,archon,new-plugin,night-shift,verify-spec} |
This repo | Real directories, repo-authored. npx skills does not manage them — never remove them while pruning vendored skills |
skills-lock.json |
npx skills |
Never hand-edit — the hashes are computed |
docs/agents/*.md |
This repo | Hand-maintained, no generator. Do not run /setup-matt-pocock-skills: it reverts triage-labels.md to a five-role wontfix vocabulary |
This repo vendors skills from the sources in the table below. Every one goes through npx skills and is tracked in skills-lock.json.
| Source | What comes from it | Selection policy |
|---|---|---|
mattpocock/skills |
The agent-skill driver — most of .claude/skills/ |
All of skills/engineering/ and skills/productivity/, skills/misc/ by explicit justification, never skills/in-progress/ |
cursor/plugins |
unslop only |
By name, one skill at a time. Nothing is taken from this source wholesale |
tt-a1i/archify |
archify and archify-review |
By name. Nothing else is taken from this source |
unslop cuts AI tells from prose. It is vendored here because this repo's product is prose, and because the maintainer's output style and user CLAUDE.md both tell a session to read it. Its frontmatter sets disable-model-invocation: true, so an agent reads the file rather than invoking the skill. Take nothing else from cursor/plugins without deciding it the same way.
archify renders the diagrams under apps/<agent>/<plugin>/docs/architecture/ from their source JSON, and archify-review is its maintenance companion. They are vendored so every session and worktree can regenerate a diagram without a per-machine install (#569).
The same skill name can exist at user scope and at project scope, and the user copy wins. Claude Code resolves a name clash enterprise over personal, and personal over project, so a session in this repo runs the maintainer's ~/.claude/skills/unslop/, not the copy vendored here. The vendored copy is what a teammate without it reads. The two can drift, and nothing reports it. When you upgrade one, diff the other. A plugin skill never enters this clash: it is namespaced as /plugin-name:skill-name and loads alongside.
Two mattpocock/skills entries the policy needs to name explicitly:
misc/git-guardrails-claude-codeis the onemisc/entry installed. Justification: it is the only vendored skill that installs a repo-local safety hook, so it belongs where the repo is. Read the caveat below before wiring it up.setup-matt-pocock-skillsstays installed but must never run. It is the referencedocs/agents/*.mdwas hand-authored from, which is why it is kept. A run revertsdocs/agents/triage-labels.mdto the five-rolewontfixvocabulary. Four installed skills tell an agent to invoke it when a tracker or label mapping looks missing —triage,wayfinder,to-spec,to-tickets. Those files exist and are correct here, so that condition is never met: if a skill asks for them, readdocs/agents/, do not run the setup skill.
npx skills@latest add mattpocock/skills -a claude-code -y -s <name> -s <name> …
npx skills@latest add cursor/plugins -a claude-code -y -s unslop
npx skills@latest add tt-a1i/archify -a claude-code -y -s archify -s archify-review
npx skills@latest remove -s <name> -s <name> … -a claude-code -yAdd -l to any add command to list the source's catalogue and install nothing. Use it to check a source before you take from it.
Most vendored skills are a plain .claude/skills/<name>/ directory. archify and archify-review are a .agents/skills/<name>/ directory plus a .claude/skills/<name> symlink to it, because that is what npx skills wrote when they were added on 2026-09-25. skills-lock.json carries a hash for every vendored skill in either shape.
Claude Code discovers project skills only under .claude/skills/ (skills documentation), so in the second shape the symlink is what loads the skill. Commit the directory and the symlink together. A checkout that writes symlinks as plain files gets a short text file in place of the skill, and the skill does not load there. On Windows that is the default unless the clone was made with symlinks enabled; CONTRIBUTING.md § Cloning on Windows says how. CI does not catch a broken link, because its Windows job runs plugin logic, not the vendored skills.
The shape has changed before. Until 2026-09-21 the mattpocock/skills entries were a .agents/skills/<name>/ directory plus a .claude/skills/<name> symlink, while unslop was already a plain directory. An ordinary npx skills add of the 26 tracked names rewrote all of them as plain directories in one run, reported each as copied, and left the whole .agents/skills/ tree orphaned. The CLI picks the shape, not you, and it can change the shape of skills already installed. So read the installed tree after an upgrade rather than before, and never convert a shape by hand.
Three traps the CLI sets:
- Target
-a claude-code, never-a '*'. The wildcard installs a second, frontmatter-rewritten copy of every skill into a top-levelagent/skills/tree for foreign agents, which then drifts from the one under.claude/skills/.removerejects-a '*'outright. -stakes repeated flags, not a comma list.-s a,b,creports "no matching skills found" and exits 0. In zsh,for n in $namesdoes not split on newlines either, so a list built that way collapses into one bogus name and the CLI prints its catalogue instead of installing.removeleft the source directory behind under the old shape. Whether it now cleans the plain.claude/skills/<name>/directory is unverified here. Rungit statusafter a removal and delete what it leaves.
Upstream renames and deletes skills between releases, and nothing prunes. After upgrading, diff the installed set against the upstream tree (add -l) and remove what no longer exists there. Do this per source: a cursor/plugins listing says nothing about what mattpocock/skills still ships.
docs/adr/0032-label-taxonomy.md and docs/adr/0033-de-dogfood-unic-archon-dlc.md still name .agents/skills/** as the upstream-owned tree. They record the decision as it was taken and are left as written; the path they cite is now .claude/skills/<name>/, and the rule that upstream owns it is unchanged.
.agents/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh reads its PreToolUse payload with jq and does not check that the read worked. Without jq on the hook runner's PATH it exits 0 — which the hook protocol means as allow — so a git push --force passes while the transcript looks identical to a successful block. Its own verification step tests only the matching-command path, so installing it appears to confirm protection that is conditional on jq.
Before wiring this hook into .claude/settings.json, confirm jq resolves for the hook runner, then test the negative path: pipe a payload in with jq off the PATH and expect a non-zero exit. The fix belongs upstream — .agents/skills/** is never hand-edited here, so a local patch dies on the next npx skills add.