Trigger Snippets
Copy-paste prompts for the six workflow entry points. They live in
snippets/ and are the fastest way to activate the skill correctly.
orkestrasi-brainstorm.md(keyword:brn): grill-until-locked brainstorm interview BEFORE any plan: grounds in the repository, surfaces trade-offs via the harness question tool, and locks tasks A/B/C/D into an approved intent artifact.orkestrasi-ngoding-plan.md(keyword:;): plan generation, TDD execution, and the mandatory subagent pipeline.orkestrasi-debugging.md(keyword:;,): root cause analysis with hypothesis-parallel investigation, and the mandatory subagent pipeline.orkestrasi-pr.md(keyword:;;,): PR delivery from a finished session, PR review, and the ordered batch merge.orkestrasi-overnight.md(keyword:ovn): carry an approved plan to a verified PR while you are away. Its target comes from#{clipboard}: copy the approved plan's path. Entry gate, retry budget, never-merge, and a morning handoff file.orkestrasi-pr-review.md(keyword:prr): standalone remote PR review, line-anchored findings, coverage table, and binary verdict; merges to main only when pre-authorized and all gates pass. Its target comes from#{clipboard}: copy one PR URL for a single review, or several (one per line) for a batch of any size.
Command snippets
Short prompts for the moments between the six entry points: authorizing a merge, continuing past a failure, answering a check-in, resuming, cleaning up. Each is one paragraph, carries no subagent contract (they run inside a session that already has one), and is tracked in the manifest like the large ones.
| Keyword | File | Use it to |
|---|---|---|
mrg | cmd-merge.md | authorize merging this session's PR(s) into main; gates re-measured, never --admin or --force |
err | cmd-error-continue.md | keep going after a failure: 2 attempts per chunk, then park as blocked |
apv | cmd-approved.md | answer a "shall I continue?" check-in inside the approved scope |
dnn | cmd-done-verify.md | report a manual step done; the agent measures instead of trusting |
sts | cmd-status.md | read-only status table: plan, PRs, running job |
rsm | cmd-resume.md | resume from the plan file after compaction, /clear, or an interrupted turn |
cln | cmd-cleanup.md | remove this session's merged worktree and local branch, by PID, never rm -rf |
aud | cmd-audit.md | read-only audit with ranked proposals and revert paths |
prm | cmd-review-merge.md | type after prr: the pre-authorization that lets it merge safe PRs |
fbk | cmd-fallback.md | fallback ladder when a tool fails; never swap a remote run for a local one |
mrg authorizes the merge and nothing else: no deploy, no cleanup. Cleanup is cln,
a separate act. Undo any slot with snipset snippet delete <uuid> (the uuid is in
snippets.manifest.json) and remove its manifest entry and source file.
Clipboard-driven review targets
orkestrasi-pr-review.md takes its target from #{clipboard}, substituted by Snipset at
expansion time. One URL reviews one PR; several (one per line) review a batch:
snipset snippet expand --by-keyword prr \
--clipboard "https://github.com/owner/repo/pull/42"
# a batch
snipset snippet expand --by-keyword prr --clipboard "$(gh pr list --json url --jq '.[].url' | tr '\n' ' ')"
Accepted shapes: a full PR URL, #123, or owner/repo#123. A value with no
recognizable target is a stop, never a guess at a nearby number and never a
fallback to whatever gh pr list shows first.
Batch behaviour is defined rather than left to the agent reading a bigger number. One
verdict per PR, never one for the batch. Fan-out chunks per (PR, coverage category)
so no two subagents share a cell. Merges stay strictly sequential even though the
reviews ran in parallel, with a fetch and a local check after every individual merge.
Pre-authorization covers each PR in the list individually, so merging the first does
not authorize the second. One unreachable PR is recorded as not reviewed and the
batch continues.
Copy rules: no em dash, no attribution footer
Two rules about the copy this repository ships, enforced by
bun run copy:check and asserted on every bun run ci:
bun run copy:check <file>... # check an artifact (a PR body, a doc)
bun run copy:check --commits [N] # check the last N commit messages
The checker reads the repository the command runs in: --diff and --commits
use the git toplevel of the current directory and exit 2 outside a repository,
and file paths resolve against the current directory. bun run copy:check
always runs from this repository's root, so to check another repository call the
script there: cd <repo> && bun <vivera>/scripts/check-copy-rules.mjs --diff origin/main.
| Rule | Pattern |
|---|---|
| No em dash | U+2014 in user-visible prose |
| No attribution footer | Generated with <tool>, a ๐ค credit badge, Co-Authored-By:, Signed-off-by: |
| No tool prefix on a branch name (the same attribution rule, on generated text) | pr-registry.mjs claim derives <plan-id>/<session-slug> |
The second rule exists because of PR #15 on 2026-10-04, where a
Generated with [Claude Code] footer was appended to a PR body that no
template, no instruction, and none of the seven other PRs in this repository
supported, in a session that was not Claude Code. It published a false claim
about authorship in a permanently public artifact. Both rules already existed as
prose; the em-dash rule was in the master skill and in the PR template checklist,
and 69 em dashes still rode out in one diff. A rule that only exists in prose
cannot stop anything.
Two scope decisions, both forced by running the checker rather than reading it:
The patterns match attribution SHAPES, not tool names. A first version also
matched bare vendor names and immediately flagged README's harness table, which
legitimately lists Claude Code and Copilot, plus a mermaid node reading
๐ค Subagents execute. Those are not attributions. A gate that fires on those
gets learned to be noise, and a noisy gate reports success while checking
nothing. Generated with X is an attribution whatever X is; a bare tool name is
not.
Git history is not scanned by CI. An earlier version checked the last 20
commit messages and failed on two commits from a previous session, which is not
CI's to fix and would leave the gate red forever on history nobody will rewrite.
Run bun run copy:check --commits on your own commits before pushing, where a
hit is a hit on work you just produced.
The branch-name rule needs a different gate, because the name is generated.
The pattern list above can only catch text someone typed, and a branch name is
printed by pr-registry.mjs claim rather than written by hand. So the gate is
bun run validate: it calls branchFor and compares the result against the
shape the master skill documents, which fails the moment a prefix is put back.
pr-registry.mjs once derived ai/<plan-id>/<session-slug>. A branch name is
permanent, publicly readable repository history, so that prefix was an
authorship claim in the one artifact nobody can redact afterwards, and it was
removed on that basis. Slots claimed before the change keep the branch they
claimed, so there is no migration and no rename to undo.
A line that states the rule is exempt from it, with a two-line lookback so a wrapped markdown checklist item can quote the trigger phrase it forbids. The window is bounded: a violation four lines below a rule statement is still caught.
Design tells: no decorative left stripe
The master skill names seven frontend tells (an eyebrow badge, a decorative
status dot, a colored left stripe, and four more). The one with a mechanical
shape has a gate, bun run design:check:
bun run design:check <file-or-dir>... # whole files; a directory is walked
cd <repo> && bun <vivera>/scripts/check-design-tells.mjs --diff origin/main
# only stylesheet lines added since the base
It exists because on 2026-10-16 the Snipset website still carried 31 decorative
left stripes while the tell had been prose in sucp-rules all along: a 4px accent
border-left on the closing CTA card of every solutions page (23 inline
style="" attributes, EN and ID), on the compare page's TCO card, on every
testimonial card (a 4px gradient ::before), on two terminal demo boxes, and on
the 404 page. None marked a state. The owner found the first one in a screenshot.
| Shape | Pattern |
|---|---|
| Border stripe | border-left, border-left-width, border-inline-start(-width) of 2px or more with a visible colour |
| Shadow stripe | box-shadow: inset <N>px 0 0 ... with N of 2 or more |
| Pseudo-element stripe | a ::before/::after that is position: absolute, left: 0, 2 to 8px wide, and full height |
| Utility stripe | Tailwind border-l-2, border-l-4, border-l-8 in a class list |
A stripe is a signal when its selector says which state it marks, so a selector
(or any enclosing selector, or the element's own tag for an inline style) that
contains active, current, selected, warning, error, success, alert,
callout, blockquote, status, pass, fail, or the rest of STATE_WORDS
passes on its own. Anything else needs /* design-tells: allow <reason> */ on
the same line or the line above, and a waiver without a reason is a violation of
its own. A 1px border is a border, and top, right, and bottom borders stay out of
scope until one is measured as a tell. Like copy:check, --diff judges only
the lines the branch adds, so an old stripe never blocks an unrelated PR.
Duplicate definitions: the reuse rung of the Simplicity Ladder
Rung 2 of the Simplicity Ladder says to reuse a helper the repository already has
before writing a new one. That rung was prose, so a duplicate helper was found by
hand or not at all. bun run dups:check is its mechanical form:
bun run dups:check --diff origin/main # only definitions on lines the branch adds
It reports a top-level definition on a line this branch adds whose name, lower-cased
with _ and - removed, is already defined in a different file (formatPrice and
format_price are one name). It reads JS and TS, Python, Go plain funcs, and Rust
fn. It skips generic names (including parseArgs and usage), test files,
node_modules, vendor, dist, build, target, graphify-out, and pairs across
language families. Exit 0 is clean, 1 is a possible duplicate, and 2 is a usage
error. The last line is always scanned N added definitions in M files, so a zero
is visible as a zero and not as a run that read nothing. The waiver is
dup-defs: allow <reason> on the same line or the line above, and a waiver without
a reason is a violation of its own.
A hit is a possible duplicate: the reviewer reads both definitions before filing it.
On the history of this repository on 2026-10-17 it reported 31 distinct hits over two
windows (the last 20 and the last 60 commits). Reading both definitions of each, 5
were true duplicates, 14 partial (same job, real differences), and 12 were not
duplicates. The two skips that came out of that read, parseArgs and usage as
generic names and pairs across language families, remove 7 of the 12, leaving 24
hits of which 19 are worth a read. Two rules were tried on paper and rejected
because they hide real duplicates: exported definitions only, and skipping
checkDiff. The limit is that it matches names only, so a helper rewritten under
another name is not found, and 5 same-name different-job pairs remain.
Strict retries: retry_if
defaults:
retry_if: any # any (default) | transient
| Value | A retry is spent on |
|---|---|
any | Anything except a failure the exit code proves cannot change: 127, 126, 130, 143 |
transient | Only transient: true, which in practice means a timeout |
The strict policy is opt-in because transient: 'unknown' is the ordinary exit
1, where a deterministic assertion failure and a flaky test are indistinguishable
from an exit code. Defaulting to the strict policy would stop retrying flaky tests
in every existing plan, including plans belonging to repositories this one does
not own. An unrecognised value throws rather than defaulting, the same as
on_precondition_fail.
Mutual exclusion for the PR registry
pr.registry.json decides which session owns which branch and worktree, and it is
gitignored, so a lost write cannot be recovered. Atomic writes make each write
indivisible; they do not stop two writers from interleaving, where the second
rename silently discards the first.
saveRegistry therefore takes expectedRaw: the exact bytes the caller read. If
the file changed since, it refuses. There is no lock file, deliberately: a lock
needs a stale-lock policy, and a stale lock needs a decision about what a crashed
holder does to the next one. Compare and swap needs neither, because the state it
reads is the state.
mutateRegistry((registry) => claimSlot(registry, { ... }).registry, { registryPath })
mutate must be pure, because it is re-applied on a fresh read when a conflict
happens. After four conflicts it fails loudly rather than spinning.
The residual window is stated, not hidden: between the re-read and the rename,
another process could still write. That window is one call wide instead of a whole
session, and mutateRegistry closes it by re-reading and re-applying.
Backfilling impacts into plans that predate it
bun scripts/backfill-plan-impacts.mjs --dry-run # report only
bun scripts/backfill-plan-impacts.mjs # rewrite in place
Twelve plans held 108 tasks written before the key existed, each emitting a
validation warning. Hand-writing 108 impact claims would be writing 108 analyses,
and an analysis nobody performed is a fabrication wearing the costume of a record.
Every entry is instead derived from what each task already declared in its own
files, plus the consumers of those files in the current tree, plus the command
that shows them.
The first version of this script reported a file as having no consumer because its
regex stripped .mjs off the stem and then required the closing quote immediately,
so from './plan-publish-frontmatter.mjs' never matched. 83 of 127 tasks came
back as the "checked, nothing found" sentinel, and every plan still validated,
because a sentinel is a legal entry. That is the failure the key exists to
prevent, reproduced inside the tool that fills it in. After the fix: 88 derived
entries, 39 sentinels, and a test that re-scans every claimed consumer count.
All five share the subagent and evidence rules
All five carry the same subagent and evidence rules, because the most common failure is an agent that reads a trigger prompt, never sees a subagent requirement in it, and quietly implements everything inline. Each one states the eight-step pipeline explicitly: task-chunking, batch manifest, high fan-out floor, non-overlapping scopes, nested fan-out, gather and synthesize, parent diff audit, and batched dispatch. Inline work is allowed only as a written exception.
The brainstorming variant locks user intent through structured interview rounds before any plan
or code exists. The debugging variant chunks by hypothesis rather than by file, so competing
explanations are tested in parallel and a disproven cause is discarded without
contaminating the others. The PR delivery variant adds derived isolation, parent-only git ownership,
the PR body contract (--body-file), and topological batch merge. The PR review variant enforces
line-anchored review contracts and gates merging behind green local checks and a correct verdict.
All 16 snippets (six entry points and ten command snippets) carry one identical CONFIRMATIONS: paragraph right after the PROGRESS METER: paragraph, because a session started from a snippet never loads sucp-rules, and a test in scripts/sync-snippets.test.mjs fails if one is missing, duplicated, or drifts. The Snipset database copy only catches up when the user runs bun run snippets:push.
Keeping the database in sync
The markdown files are the source of truth. The Snipset database holds a copy, because
that copy is what an agent actually receives when the snippet is triggered.
snippets.manifest.json maps each file to its database slot
(uuid, keyword, name, description).
bun run snippets:status # table of local vs database hashes
bun run snippets:check # exit 1 on drift (also runs in CI)
bun run snippets:push # write local content to the database
Adding a new snippet is a create, not an update
sync-snippets.mjs only ever calls snippet update, which requires a uuid that
already exists. So a new trigger prompt needs its database slot provisioned once,
by hand, before snippets:push can do anything with it:
# 1. the CLI must agree with the database schema, or every write is refused
snipset doctor # schema_match must be true
# 2. create the slot, taking the body from the file (never retyping it)
snipset snippet add \
--name "<name>" --keyword "<keyword>" --group "Agentic Coding AI" \
--description "<description>" \
--content "$(bun -e "import {readFileSync} from 'fs'; import {extractPromptBody} from './scripts/sync-snippets.mjs'; process.stdout.write(extractPromptBody(readFileSync('snippets/<file>.md','utf8')))")"
# 3. paste the uuid the database returns into snippets.manifest.json, then
bun run snippets:push
The uuid is chosen by the database, so it cannot be pre-generated and written into
the manifest ahead of time. A manifest entry whose uuid does not exist yet reports
snippet not found from snippets:check, which is the correct verdict: the database
genuinely has no copy, and the agent would receive nothing when the snippet is triggered.
Both sides are normalized before comparison (em dash, curly quotes, rightwards arrow, CRLF, trailing whitespace), so a typographic character in the Markdown source is not reported as drift. A missing Snipset install is reported as a skipped comparison rather than a failure, because a CI runner has no local database and that is an environment boundary, not evidence of a problem. Real drift still fails.
validate-skill.mjs enforces the invariants that hold everywhere, including CI: the
manifest is valid JSON, every entry has all required fields, no duplicate uuid or
keyword, every referenced file exists, every tracked file carries the subagent contract,
and every required trigger snippet is tracked so it can never be silently left out of the
database.
Mirroring plans into the Obsidian vault
A plan under docs/code-plan/plans/ is a markdown file in a project repository, so the
vault cannot search it, render its Mermaid, or show it in the graph. scripts/plan-publish.mjs
copies each plan into 01 - Projects/<Project>/plans/ with the vault's PARA frontmatter
merged in. The copy is a one-way, read-only mirror: the project repository is the source
of truth, the vault copy is derived state, and the vault copy is never hand-edited. The
publisher never reads a mirror back as an input, so a stale mirror can rot visibly
(--check exists to catch that) but can never corrupt a plan.
bun run mirror:publish <plan.md>... # mirror the named plans (or pass the flags below directly)
bun run mirror:check # exit 1 on drift or a missing mirror
bun run mirror:status # table of every plan and its mirror state, always exit 0
bun run issue:sync <plan.md>... # the same plan, mirrored to a GitHub issue
bun run issue:check <plan.md>... # issue drift report, never writes
bun run issue:status # table of recorded plan-issue links, always exit 0
A plan has two derived copies, both one-way: this Obsidian note, and a GitHub
issue carrying the same text. Run the issue sync immediately after each
mirror:publish for the same trigger, so the two never drift apart. See
Plan Mirroring for the state mapping
and the three traps that shape the implementation.
mirror:publishwrites one file per plan and stages it in the vault withgit addwhenstageInVaultis on, so obsidian-git (configured withautoCommitOnlyStaged: true) commits it. It stages the single file and never commits or pushes. A re-publish of an unchanged plan printsSKIPPED-IDEMPOTENT, exits 0, and writes nothing.mirror:checkis the CI-style gate: it never writes, and it exits 1 when a mirror's recordedsource_hashno longer matches its source plan or the mirror is missing. Exit 0 means every enumerated plan is mirrored and current.mirror:statusis a report, not a verdict. It always exits 0, including when a mirror is missing, so it is safe inside a&&chain and in a shell pipeline.
Flags the CLI itself understands, for when you call bun scripts/plan-publish.mjs directly:
| Flag | Why it exists |
|---|---|
--check [--all] | Verify only. --all enumerates every plan under every mirror: true project instead of requiring explicit paths. |
--status | Report mirror state for every plan; never fails. |
--dry-run | Print the destination and whether it would write, then write and stage nothing. |
--today YYYY-MM-DD | Overrides the updated frontmatter value. It exists to make that property deterministic in tests; without it the value is today's local date. |
PLAN_PUBLISH_CONFIG | Environment variable holding the path to plans.publish.json. It is the only way to point the tool at a different vault or a different set of project roots. |
Two more environment variables are host seams, so scripts that need a machine-specific path can run on a machine that is not this one:
| Variable | Read by | Default |
|---|---|---|
OBSIDIAN_VAULT | spike-skipif-corpus.mjs | $HOME/Dokumen/Obsidian Vault |
GRAPHIFY_SITE_PACKAGES | vault-index.mjs, vault-index-cluster.mjs / .py | unset: the graphifySitePackages key in gitignored local.config.json (see local.config.example.json), else an actionable error |
GRAPHIFY_PYTHON | vault-index.mjs, vault-index-cluster.mjs | unset: the graphifyPython key in local.config.json, else an actionable error |
bun test scripts/ skips, rather than fails, the tests whose premise the
current host cannot satisfy: a checkout elsewhere, a machine without the
graphify detector installed, or a machine without the vault. The skip carries
its reason, so an absent prerequisite is never reported as a defect, and,
equally, never counted as a pass. Set the relevant variable to run those tests
here.
Exit codes: 0 success or idempotent skip, 1 drift / missing mirror / refused destination /
transform failure, 2 usage error.
A worktree is not a project
projects is a routing table keyed on path prefix, and a plan file carries no
project name, so a plan is routed purely by where it sits on disk. That makes a git
worktree of an already-registered project unpublishable: its path is owned by nobody,
and mirror:publish refuses rather than guessing.
The tempting fix, registering the worktree as a project in its own right, is wrong,
and the damage is proportional to the repository's history. mirror: true does two
things: it permits explicit publishing and it enrolls the root in --check --all
enumeration, which walks every .md under <root>/docs/code-plan/plans/. A
worktree checkout contains the entire tracked history, so enumerating one republishes
every plan the repository has ever had. Registering the Snipset SEO worktree as a
project mirrored all 274 existing Snipset plans a second time, under a second project
folder, 286 files in one commit.
So a project may declare worktrees: extra directories that hold the same
project's plans on another branch.
{ "name": "Snipset", "root": "/home/.../Proyek/Snipset", "mirror": true,
"worktrees": ["/home/.../Proyek/Snipset-seo"] }
| Concern | How it is handled |
|---|---|
| Routing | resolveProject() treats a worktree as a candidate location for its project, so a plan written in a worktree mirrors into 01 - Projects/<Project>/plans/ beside its siblings. It creates no second project and no second index. |
| Enumeration | enumeratePlans() reads the root only, never the worktrees. This asymmetry is the whole point of the field: routing must accept a worktree, enumeration must not, because the worktree's plans are the root's plans. enumeratePlans is asserted against it directly. |
| Session worktrees | A plan under <root>-wt/<slot>/, the layout scripts/pr-registry.mjs claim creates, routes to the project whose root is <root> with no config entry, in both plan-publish.mjs and plan-issue-sync.mjs; an explicit worktrees entry still wins over that derived rule. |
| Origin remote | In plan-issue-sync.mjs only: a checkout whose directory name is not a project key, such as a detached /tmp gate worktree, resolves through its origin remote, read from the git config on disk (following gitdir and commondir), when that owner/repo is already a value in projects. When the directory key and the origin both resolve to configured repositories and those differ, the sync is refused, naming both; an origin that no project maps to is ignored when the directory key resolves, and --project still wins. |
| Precedence | Roots and worktrees compete on the same longest-prefix scale, so a real project nested inside a worktree still wins over the worktree that contains it. |
| Refusal | A worktrees value that is not an array, an entry that is empty or not a string, an entry that is already a registered root, and an entry two projects both claim are all load errors. One directory is never routable as two projects. |
| Detection | There is no git-identity check, so nothing infers that two roots are one repository. A worktree has to be declared. loadRegistry rejects the collision instead of letting the longer-prefix rule pick a winner by accident. |
A worktree that has been removed from disk should be dropped from worktrees. The
field costs nothing while stale, since enumeration never reads it, but a stale entry
silently keeps routing paths that no longer exist to a project that will never mirror
them.
mirror:check is deliberately not part of bun run ci. It reads plans.publish.json, which
names one vault by absolute path on one machine, and the plan set it enumerates currently has
mirrors for none of it, so the check is a local gate to run on the machine that owns the vault
rather than something a portable CI runner can ever pass.
Indexing generated Markdown into the graph
graphify update . cannot do this. Its help reads "re-extract code files and update the
graph (no LLM needed)" and it parses code, so every plan, batch manifest, spike report,
handoff, progress log and learning ledger a session writes stays invisible to graphify query, path and explain. scripts/graphify-sync.mjs closes that gap with the
deterministic half of the vault-index pipeline: one document node per eligible Markdown
file, its ATX heading nodes, and its resolvable [[wikilinks]] as references edges,
unioned into graphify-out/graph.json.
bun run graphify:sync # structural extraction + merge, writes graph.json
bun run graphify:check # exit 1 when the graph is stale; never writes
bun <vivera>/scripts/graphify-sync.mjs --root "$(pwd)" # from any repository's root, vivera included
bun <vivera>/scripts/graphify-sync.mjs --check --root "$(pwd)"
bun scripts/graphify-sync.mjs --dry-run
bun scripts/graphify-sync.mjs docs/code-plan/plans/<plan>.md # narrow to files
| Concern | How it is handled |
|---|---|
| Cloud boundary | It is a local line scan: no extract, no API key, no packet. The eligible set is decided by graphify's own detector through scan(), so the .gitignore/.graphifyignore boundary is the same one a future extraction would honour rather than a second matcher. |
| Ordering | Run it after graphify update .. update rewrites the code graph and is not known to preserve foreign nodes, so running it after the sync can discard the document nodes the sync just added. The pre-commit hook below enforces this order automatically. |
| Duplicate ids | The graph already carries graphify-era document ids. Before the union, previous document ids are remapped onto this layer's ids for the same source_file, so a known file is not indexed twice and its edges follow the remap. node_kind: heading nodes are never remapped: they are children of a file, not its identity. |
| Loss | merge() seeds the union from the previous graph, so code, concept and rationale nodes are retained and can only be added to. A gate refuses to write when the node count would fall. |
| Idempotency | structural() is deterministic and the merge is a union keyed on node id, so a second run over unchanged files writes byte-identical output. graphify:check is the gate. |
| Missing graph | It refuses and says so. It merges into an existing graph; it never fabricates one. |
The bun run and bun scripts/... lines run inside vivera and default to its root. Step 6.6
of the skill runs the sync at the end of a session in the --root "$(pwd)" form from the
session's repository, which is correct in vivera and in every other repository. Inside vivera
it also runs automatically through a committed git hook.
The pre-commit hook
.githooks/pre-commit keeps the graph consistent with what a commit actually contains,
without the agent having to remember: when code changed it runs graphify update . (local
AST), then bun run graphify:sync adds the Markdown's structural nodes last. install.sh
enables it with git config core.hooksPath .githooks.
| Concern | How it is handled |
|---|---|
| Not blocking | Every path exits 0. The graph is derived, local and regenerable, so a failed step prints a bounded warning and the commit proceeds. A red graph must never block history. |
| Ordering | graphify update runs before the sync, never after. update rebuilds graph.json and its writer is not guaranteed to preserve foreign nodes, so the structural layer must be the last writer or the document nodes can vanish. Measured 2026-10-03: update took 5.3 s, grew the graph 2342 โ 2832 nodes, preserved the semantic layers (concept 284 โ 274, rationale 156 โ 158), and the following sync restored the document layer to 2984 nodes with graphify:check CURRENT. |
| Gating / cost | Nothing runs when the staged diff is empty or there is no graph to update. A Markdown-only commit runs the sync alone and skips the 5.3 s code rebuild; a commit with any non-Markdown file runs update then the sync. Both steps are local: no LLM, no network. |
| Why a git hook and not an OpenCode plugin hook | OpenCode v2's only session-lifecycle event is session.idle, which fires after every assistant turn rather than at a session end, so a plugin hook there would re-scan constantly. A git hook is harness-agnostic (OpenCode, Claude Code, a terminal all end in git commit) and is a committed file rather than machine-local plugin state. |
| Enable / disable | git config core.hooksPath .githooks enables it; GRAPHIFY_SYNC_SKIP=1 git commit ... disables it for one commit; git config --unset core.hooksPath reverts to the default .git/hooks. Neither touches graphify-out/. |
This is deliberately not part of bun run ci: it writes a gitignored, machine-local
artifact whose correctness depends on the vault and the installed graphify, neither of
which a portable CI runner can see.
Getting sessions to query the graph
A graph nobody queries is only a build cost. Claude Code does not load a repository's
AGENTS.md when a CLAUDE.md sits in a parent directory, so the graphify rules in that file
never reached most sessions, and nothing else prompted a query: 14 of 161 top-level Claude
Code sessions from 2026-10-08 to 2026-10-14 made one. Three user-level hooks now tell each
session and subagent about the graph at start, and two commands register a repository and
list the ones still without a graph.
bun run graphify:hooks (--install | --uninstall | --check) [--settings <path>] [--hooks-dir <dir>] [--dry-run]
bun run graphify:register <dir> [--never] [--global]
bun run graphify:audit <root>... [--json] [--max-depth N]
| Command | What it does | Exit codes |
|---|---|---|
graphify:hooks | Merges the three entries below into --settings (default ~/.claude/settings.json), each running a script in --hooks-dir (default scripts/hooks in this checkout). An entry is its own when a command contains /scripts/hooks/graphify-: --install replaces those, so a second run changes nothing, and --uninstall removes only those. Every other key and hook is kept, and the previous file is kept at <settings>.bak. --install creates a missing settings file. --dry-run prints the resulting JSON and writes nothing. Run from a linked worktree without --hooks-dir, --install refuses and names the main checkout to run it from, because a session worktree is removed after its PR merges. | --check: 0 when all three entries and their scripts exist, else 1 naming what is missing. A settings file that is not valid JSON, or whose hooks is not an object: 1, nothing written. --install from a linked worktree without --hooks-dir: 1, nothing written. |
graphify:register | Resolves the main checkout (run from a worktree, it registers the main checkout), runs graphify update <mainRoot>, appends graphify-out/ to <git-common-dir>/info/exclude when it is not already ignored. Only with --global does it then run graphify global add <graphPath> --as <basename of mainRoot>; without it, it prints how to add the graph later. The global graph stays opt-in because every graphify MCP server loads all of it at startup (measured at 176 MB with 24,617 nodes and at least 625 MB with 126,291), so it holds only what should answer queries that name no repository. Safe to run twice. An opted-out repository is reported and not built. --never sets git config graphify.register never and builds nothing. | 0 done; 1 when graphify is missing or a graphify step exits non-zero (the output names the step and its code); 2 on usage or a directory outside a git repository. |
graphify:audit | Walks each root to --max-depth (default 4), skipping node_modules, .cache and worktrees (listed separately, never counted as unregistered), and prints each repository's path, kind, node and edge counts and staleness with a totals line, or JSON with --json. | 1 while any repository is unregistered (opted-out ones do not count), else 0; 2 with no root or a root that does not exist. |
Registration is offline: graphify update parses code locally (AST), with no LLM and no
network. A 2,076-file repository built in about 13 s with networking disabled.
The three hook entries, each "<path to bun>" "<hooks-dir>/<script>" with a 10 s timeout:
SessionStart, no matcher, so acompactre-injects it:graphify-session-start.mjsSubagentStart:graphify-session-start.mjsPreToolUse, matcher^(Grep|Glob|Read|Bash|mcp__graphify__.*)$:graphify-search-nudge.mjs
| Repository state | What the session or subagent sees at start |
|---|---|
| Registered | One paragraph: the graph path (from a worktree, the main checkout's graph, and the text says so); when the graph is older than the last commit, that graphify update <mainRoot> refreshes it offline; and the query command, graphify query "<question>" --graph <graphPath> (also path, explain) or the MCP tool query_graph with project_path set to the main checkout. |
| Unregistered | Session start only, not subagents: that the repository has no graph, the registration command, and the user's standing request to be offered registration through the question tool with Register now, Not now, and Never for this repository. Register now runs graphify-register.mjs, Never runs it with --never, and Not now records nothing, so the next session offers again. |
| Opted out, or not a git repository | Nothing. |
In a registered repository, the first search by a session or subagent before its first graph
query gets one reminder next to the result, naming the graph path and the query command. A
search is Grep, Glob, a Read of a file that is not Markdown and not under
graphify-out/, or a Bash command whose first word is rg, grep, fd, find or ag. A
query is any mcp__graphify__ tool, or graphify query, path or explain in Bash. Each
session and agent is tracked separately. Both hooks exit 0 on every path and never allow or
block a tool call. install.sh only prints the install command; it does not edit the global
settings.
| Change | Revert |
|---|---|
Hook entries in ~/.claude/settings.json | bun run graphify:hooks --uninstall |
| Never for a repository | git config --unset graphify.register, run in that repository |
| A registration | Only when it ran with --global: graphify global remove <tag>, where the tag is the main checkout's directory name. The local graph stays in graphify-out/; delete it, and the graphify-out/ line in .git/info/exclude, to undo the build too. |
The runbook section with the measurement details is ยง3.4.1 of
docs/graphify-integration.md.
The drift watchdog
scripts/plan-mirror-check.sh wraps mirror:check for unattended daily runs. It only ever
passes --check, so there is deliberately no publish path in that file.
| Concern | How it is handled |
|---|---|
| Scheduling | A systemd --user timer, not a GitHub Actions workflow. A workflow on a runner can see neither the vault nor the sibling repositories, so it would either fail forever on a missing path or pass vacuously by enumerating zero plans. |
| Vacuous pass | Reported as a distinct verdict rather than hidden. A gate that passes because it found nothing is worse than no gate, because it manufactures confidence. |
| Exit status | Propagated on purpose, so a failed run shows up in systemctl --user --failed. Swallowing it would make the script a log-dumper instead of a watchdog. |
| Missing runtime | exit 127 when bun is absent from both PATH and $HOME/.bun/bin/bun, because systemd starts services with a minimal environment. |
| Log | ~/.local/state/plan-mirror/mirror-check.log, append-only, capped at 500 records and 20 detail lines per run. The trim goes through a temp file, so an interrupted run cannot leave a truncated log. |
Disable it with:
systemctl --user disable --now plan-mirror-check.timer
systemctl --user reset-failed plan-mirror-check.service