feat(claude): group the Claude Code surface under bough claude, add skill/command install, publish bough-hooks / bough-all#123
Conversation
The flat root mixed two unrelated vocabularies and two altitudes: - `bough plugins` already means bough's own engine plugin binaries (bough-plugin-<kind> on PATH). Claude Code's extension unit is also called a plugin, so a `bough plugin ...` verb would sit one letter away from an unrelated command. - The hook dispatcher's internal verbs (inject-context / session-end / preserve-instincts / session-evolve-claudemd) sat at root next to create/remove/list, so `bough --help` advertised things a hook fires alongside things an operator runs. Add a `bough claude` namespace that owns what bough installs INTO Claude Code (hooks today; skills / commands next). Root keeps working verbatim: `bough hook` / `bough doctor` stay as Deprecated aliases (cobra still executes them, it only prints the notice), and the four dispatcher verbs stay reachable but Hidden — hook.go's handle switch calls their Go functions directly, so they are a manual debugging escape hatch, not a surface. No behavior change; root --help drops from 18 to 15 entries.
`bough claude hook` could wire the hook loop from the CLI, but the other
two things bough installs into Claude Code — the using-bough skill and the
/bough:* commands — had no CLI path at all. The only way to get them was
the Claude Code plugin, which forces an operator into plugin scopes and a
marketplace round-trip for what is, on disk, "write these files under
.claude/". This lands the missing half of the surface with the same verb
set (install / uninstall / list) and the same project|user scope
vocabulary, so knowing one subcommand means knowing all three.
The content is embedded rather than copied. skills/ and commands/ must sit
at the repo root for Claude Code's plugin auto-discovery, and //go:embed
cannot reach outside its own package, so the embed lives in a new root
package (assets.go). That is what lets the CLI and the plugin ship
byte-identical content from one tree — a second copy under internal/ would
drift the first time either side was edited.
Two existing pieces are generalised rather than duplicated:
- procutil.DeployFlake was already "walk an embedded subtree, write it to
a directory"; nothing in it was nix-specific. It becomes DeployAssets,
with DeployFlake kept as the wrapper the engine plugins' call sites
read naturally.
- claudeDir is lifted out of claudeSettingsPath so the scope -> .claude
resolution has one definition. Hook settings and artifact directories
now agree on what "user scope" means by construction.
skills and commands differ only in where they live and whether an entry is
a directory or a .md file, so install/uninstall/list are written once
against an artifactKind description instead of twice per kind.
uninstall removes only the names bough ships, read from the embedded FS at
call time: files an operator authored in the same directory are left alone,
matching the contract `bough claude hook uninstall` already keeps for
hand-edited settings.json entries. Reading the set from the embedded FS
also means a newly added skill needs no second registration anywhere.
….json
v0.17.0 dropped hooks/hooks.json from the plugin on the belief that plugin
hooks could only be installed at user scope, which would have made a
hook-bearing plugin observe every repo on the machine — not something to
hand someone behind a one-line install. That belief was wrong. Measured
against a throwaway marketplace with an isolated CLAUDE_CONFIG_DIR:
`claude plugin install -s project` writes enabledPlugins into that repo's
own .claude/settings.json, and the plugin's UserPromptSubmit hook fired
only in that repo — a second repo on the same machine got zero hits. Scope
is the operator's choice, so the manifest can come back.
Rather than fold hooks back into the one plugin, publish three, because the
kinds differ in when they act and that difference is what an operator is
actually choosing between:
bough commands + skill. Inert until invoked, so it is safe
anywhere, including user scope.
bough-hooks the hook loop alone, for someone who drives bough from the
shell and only wants the observation.
bough-all both, for the one-line install.
One tree backs all three: bough-all symlinks the root commands/ and skills/
and bough-hooks' hooks/, so there is a single copy of every artifact and no
way for two variants to ship different content under one version.
The drift guard comes back with the manifest and follows it to its new
path. It gains a sibling that pins the sharing itself: if bough-all ever
became a real directory holding its own copy, the drift test would keep
passing while bough-all shipped stale wiring, so the symlink is asserted
rather than assumed. marketplace_test.go covers the other half of the
packaging — that every declared source resolves, that each plugin.json name
matches the name users type in `/plugin install <name>@bough`, and that
each variant contains what its description promises and nothing more. None
of that is read at build time, so without these tests a renamed directory
breaks installs while the Go build stays green.
…ts invalidate
Rounds out the `bough claude` work with the tests and prose it needs to be
true rather than merely present.
Tests (normal + failure paths, per kind where they differ):
- install deploys every embedded name, asserted against the embedded FS so
adding a skill cannot leave a stale expectation passing
- re-install overwrites stale content in place — the path an operator takes
to pick up a new bough version — without duplicating entries
- uninstall leaves hand-authored entries alone, and is a quiet no-op at a
scope where nothing was installed
- a typo'd --scope is rejected by name rather than silently resolving to
some default directory and writing where nobody looks
- the dir-vs-.md filter that separates skills from commands: get it
backwards and install silently ships nothing
- the list report marks present/absent correctly
Docs. Restoring the plugin's hooks made several claims false, and a doc that
confidently states the opposite of what the code does is worse than no doc:
- PLUGIN_CLAUDE_CODE.md was built around "a plugin installs at user scope by
default, so its hooks would fire in every repo". Rewritten around the three
variants, the measured project-scope behaviour, and the CLI equivalents.
- README taught `bough hook install` as the way in; that path now prints a
deprecation notice. Swept to `bough claude ...` everywhere it instructs.
Internal comments saying "bough doctor" are left alone — the alias works,
so they are not lies; ROADMAP entries are history, not instructions.
- doctor's double-fire note asserted bough's hooks live only in
settings.json. They do not, again. It now names both plugins that conflict
and how to drop one, and its test asserts that rather than the old premise.
`bough claude doctor`'s Short still pointed at `bough hook doctor`, which is
itself now the deprecated spelling. Fixed.
Verified end-to-end against a real Claude Code install with an isolated
CLAUDE_CONFIG_DIR: `--scope project` wires only that repo (the hook fired in
folderA and not in folderB), and bough-all's symlinked trees materialise as
real directories with real content in the plugin cache — the packaging
assumption the whole three-variant split rests on.
`nix build .#default` — and with it `nix profile install github:threecorp/bough`, one of the three install routes the README offers — has been broken since 24a4fbc, which trimmed 12 requires out of go.mod without re-deriving the vendorHash beside it. The vendor tree that hash pins no longer matches go.mod, so the build dies on a wall of "explicitly required in go.mod, but not marked as explicit in vendor/modules.txt". CI missed it because `nix flake check` only EVALUATES packages.default; it never builds it, so a stale vendorHash evaluates clean and fails only for the person actually installing. Reproduced on origin/main (eab0ba7) in a detached worktree before touching anything here, so this is pre-existing and not a fallout of the claude-namespace work this branch carries. The new hash was read from nix's own "got:" line and verified by building: `nix build .#default` now succeeds, and the resulting binary serves `bough claude skill list` — which also confirms the new root-package //go:embed survives the nix path, since the store copy has no repo to read skills/ from. The comment records how to re-derive it, because the failure mode is silent until someone installs from the flake.
… dispatcher Found by exercising the whole surface rather than a sample of it: `bough hook install` — the line actually sitting in people's scripts and muscle memory — moved to `bough claude` in total silence. cobra prints Deprecated for the command it EXECUTES, so marking only `hook` reached the one audience that did not need telling (someone typing bare `bough hook` gets help text anyway) and missed everyone else. deprecatedAlias now recurses, giving each leaf its own replacement path so the notice names the exact line to retype. Recursing naively then broke something worse. `bough hook handle` is not an operator verb that moved — it is the live dispatcher hooks.CanonicalCommand wires into settings.json, which Claude Code invokes on every session event. Marking it deprecated printed a notice to stderr on every tool call, addressed to nobody, since no human types it. Hidden subcommands are now skipped: Hidden already means "machine-invoked" in this tree, so it is the honest gate rather than a name-check on "handle". Verified end-to-end on a built binary: all five operator verbs print the notice with their exact replacement, and the real event path (`bough hook handle --event PreToolUse`, straight out of `hook list`) produces empty output. Tests pin both halves, because they pull in opposite directions and a later edit could satisfy either one alone: every non-hidden leaf must carry a notice naming its replacement, and both spellings of `handle` must carry none. The handle guard also asserts CanonicalCommand still points at `hook handle`, so if the wiring ever moves, the exemption is re-examined instead of silently protecting a command nothing calls.
| if err != nil { | ||
| return err | ||
| } | ||
| if err := procutil.DeployAssets(bough.Assets, k.subdir, dst); err != nil { |
There was a problem hiding this comment.
install は同名の手書き資産を無警告で上書きします。bough が出す list.md / create.md / status.md は衝突しやすい一般名で、実際に .claude/commands/list.md を置いて再現しました。上書き自体は upgrade 経路として必要なので、新規/置換を出力で区別します。
| return nil | ||
| }, | ||
| } | ||
| cmd.Flags().StringVar(&scope, "scope", "project", "install scope: project (= cwd/.claude) | user (= ~/.claude)") |
There was a problem hiding this comment.
--scope の宣言が install / uninstall / list に3重複。1箇所直しても他が沈黙で drift します。helper に集約します。
Two findings from reviewing this branch. install could destroy an operator's work without saying so. bough ships names as generic as list.md, create.md and status.md, so `.claude/commands/list.md` is as plausibly the operator's own command as bough's — and DeployAssets writes every embedded name unconditionally. Reproduced: a hand-authored list.md was replaced by bough's copy, and the output said "installed 11 command(s)" as if nothing had been lost. Refusing to overwrite is not the fix: overwriting IS the upgrade path, and skipping would strand a stale skill forever. Nor can bough tell its own older copy from the operator's file — no provenance is recorded, and adding some would be a lot of machinery for a rare collision. What it can do is stop hiding it, so install now reports each entry as new or replaced. Existence is read before deploying, since afterwards every name looks alike. The doc comment claimed "files you authored yourself are never touched", which was only ever true of uninstall; it now says which verb protects what. The --scope flag was declared three times, once per verb. A reworded help string or a changed default in one copy would leave the other two silently disagreeing, so it moves to one scopeFlag() the three share.
`observer`, `evolve` and `ecc` sat at the root next to `create` and `remove`, as if minting an instinct were a peer of bootstrapping a worktree. They are not: they are four views of one feature, and `.bough.yaml` has always said so — the whole domain lives under a single `instinct:` key, down to `instinct.observer.autostart`. Only the CLI disagreed. This makes it mirror the config the operator already writes, so the two vocabularies cannot drift: bough instinct observer run-once|start|stop|status (was `bough observer`) bough instinct evolve (was `bough evolve`) bough instinct import (was `bough ecc import`) The reading verbs keep their spelling — `bough instinct list|show|status| promote` was already in the right place, so nobody retypes those. Root drops from 18 advertised commands to 12. A test pins that bound rather than trusting it: the root is what an operator reads first, so the next feature should have to pick a namespace instead of landing there by default. The daemon's self-exec moves to the canonical path in the same change. It re-runs a pass every tick, and pointing machinery at a deprecated spelling is how a migration notice ends up firing on a timer — the exact mistake fixed one commit ago for `hook handle`. Docs are swept for the class, not the instance: README, EVOLVE, SECURITY, PLUGIN_CLAUDE_CODE, the /bough:* command bodies and the using-bough skill all taught the old spelling. ROADMAP keeps it — those entries record what shipped in v0.9, and rewriting history to match today's names would be a lie.
`nix flake check --no-build` is doing exactly what it says: it evaluates packages.default and never builds it. So when v0.10.0 trimmed go.mod without re-deriving the vendorHash, the derivation still evaluated fine, CI stayed green, and `nix profile install github:threecorp/bough` — one of the three install routes the README offers — was broken for eight releases. Nobody found out until this branch tried to build it while chasing an unrelated failure. `build smoke` did not cover it either: that compiles inside the dev shell with go on PATH, which never touches nix's vendored module tree. This step is the only one that walks the path a user installing from the flake actually walks. Verified the guard has teeth rather than just passing: with a require removed from go.mod (reproducing the v0.10.0 change), `nix build .#default` fails with "is marked as explicit in vendor/modules.txt, but not explicitly required in go.mod". Green before, red after. The job name is untouched — it is the check name branch protection matches on, and renaming it to mention the build would silently detach the requirement.
The note this replaces asked the operator to go run `claude plugin list` and
work out whether they had two wirings, because bough supposedly could not see
the plugin side. That was wrong. Claude Code records an enabled plugin as
`enabledPlugins` in the very settings.json bough already opens to read its own
hooks — both halves of the conflict are in one file bough already parses.
So the doctor now says which case you are in, rather than which cases exist:
both wired WARNING naming the plugin and both ways out
plugin only a note that the plugin supplies the hooks — because "not
wired" reads as "bough is not observing me", and the natural
answer to that is the install that causes the double-fire
settings only what it can see, plus the scope it cannot
That last branch is the honest part. enabledPlugins is read from the
settings.json for the scope asked about, so a plugin enabled at the other scope
is outside the report. It says so instead of implying all-clear.
enabledPlugins is used rather than plugins/installed_plugins.json, which
carries more (install scope, project path) and would have covered that gap.
It is internal and schema-versioned ("version": 2); enabledPlugins is the
surface Claude Code documents and the file bough already owns. Reaching into
the registry would trade a known blind spot for an unknown breakage.
Verified against a real project-scope `claude plugin install bough-all@bough`:
silent when only the plugin wires hooks, WARNING once `bough claude hook
install` is added on top, silent again after uninstall.
| evolve cluster the corpus into skills / agents / commands | ||
| import migrate an existing everything-claude-code corpus in | ||
|
|
||
| Nothing here runs unless instinct.enabled is true in .bough.yaml.`, |
There was a problem hiding this comment.
課金挙動の嘘。instinct.enabled はどこからも読まれず(config.go 自身が「継続学習とは無関係・未設定」と明記)、false にしても claude --print は走り課金されます。実際の gate は instinct.observer.autostart のみ。真実に書き換えます。
| @@ -20,17 +20,42 @@ import ( | |||
| // homunculus tree directly. v0.9.13 adds promote (project → global | |||
| // corpus, ECC auto-promotion parity); mint stays out because the | |||
There was a problem hiding this comment.
旧 doc comment が残置。新 comment と2段になり、しかも旧側は bough observer run-once を canonical と呼んでいます(本 commit で deprecated alias に降格済)。削除します。
| fmt.Fprintln(w, " Every event fires both: observations double, the instinct block") | ||
| fmt.Fprintln(w, " is injected twice. Keep one —") | ||
| fmt.Fprintln(w, " bough claude hook uninstall (keep the plugin's wiring)") | ||
| fmt.Fprintf(w, " claude plugin uninstall %-14s (keep these entries)\n", r.HookPlugins[0]) |
There was a problem hiding this comment.
見出しは全 plugin を列挙するのに、uninstall 例は HookPlugins[0] だけ。2つ有効な時、示された1行を実行しても片方が残り二重発火が続きます。全件出します。
| report := &DoctorReport{SettingsPath: m.SettingsPath} | ||
| // Best-effort: a settings.json bough cannot parse is already reported by | ||
| // List above, and an unreadable one means there are no plugins to find. | ||
| if raw, err := m.loadSettings(); err == nil { |
There was a problem hiding this comment.
List() が既に読んだ settings.json を loadSettings() で再読込・再parse。doctor 1回につき2回 I/O。
| bough claude command install | uninstall | list # the /bough:* commands | ||
| bough claude doctor # hook wiring + observer capture + cost posture | ||
|
|
||
| # Continuous learning (opt-in; instinct.enabled: true) |
There was a problem hiding this comment.
instinct.enabled: true は死に設定 (誰も読まない)。この行も嘘なので直します。下の3行は "instinct " が増えた分の padding 未調整で comment 列がずれています。
…ery conflicting plugin
The `bough instinct` help text I added two commits ago ended with "Nothing here
runs unless instinct.enabled is true in .bough.yaml." That is false, and it is
false about billing. `InstinctConfig.Enabled` is read nowhere — config.go's own
doc comment says so outright: "v0.9's continuous-learning surface (`bough
observer`, `bough evolve`, `bough instinct status/list/show`) is unrelated and
unconfigured by this struct". An operator who set `instinct.enabled: false` and
trusted the help would still get `claude --print` spawned and billed by
`instinct evolve --generate`. README carried the same claim in its cheat-sheet
header; both now describe the real posture — you pay when you run observer or
evolve --generate, the only automatic path is the observer daemon behind
instinct.observer.autostart, and reading the corpus costs nothing. (The
CHANGELOG's v0.5 mention stays: back then the field was live, and that entry is
history.)
The doctor's double-fire WARNING listed every conflicting plugin in its
headline, then hard-coded HookPlugins[0] into the uninstall line. With
bough-hooks and bough-all both enabled, an operator following the printed fix
would uninstall one, believe the tool, and keep double-firing on the other. It
now prints a line per plugin and says that dropping one of two is not enough.
Also from the same review:
- the old newInstinctCmd doc comment survived the rewrite, leaving two
contiguous paragraphs where the stale one still called `bough observer
run-once` canonical — the very spelling this branch demoted to an alias
- runObserverOnceQuiet's comment still named that alias as what it spawns,
while the code below it execs `instinct observer run-once`
- Doctor read settings.json twice (List loads it, then loadSettings again for
enabledPlugins). One read now feeds both, so the two halves of the report
cannot disagree about a file that changed between them
- README's cheat-sheet comment column, knocked askew by the "instinct "
prefix landing without a padding recount
Groups everything bough installs INTO Claude Code under one
bough claudenamespace, gives skills and commands the CLI path hooks already had, and publishes two more plugin variants.v0.17.0 dropped the plugin's
hooks.jsonon the belief that plugin hooks could only land at user scope. Measured with an isolatedCLAUDE_CONFIG_DIR:claude plugin install -s projectwired only that repo — the hook fired in folderA and not in folderB. The belief was wrong, so hooks ship in a plugin again.bough claude hook|skill|command|doctorbough pluginsalready means engine binaries; hook-internal verbs sat at root. Root: 18 → 15. Old spellings kept as deprecated aliases.bough claude skill|command install//go:embed-ed, so CLI and plugin ship one tree.bough-hooks/bough-allvariantsbough-allsymlinks the shared trees (tests assert no copies).hooks.json+ drift guard restoredvendorHashre-derivednix build/nix profile installhas failed since 24a4fbc (go.mod trimmed, hash not re-derived). Reproduced on origin/main first.nix flake checkonly evaluates, so CI never caught it.30 packages pass,
golangci-lint0 issues,nix buildfixed and verified, and the plugin flow was verified against a real Claude Code install.Refs:
docs/PLUGIN_CLAUDE_CODE.md