Skip to content

feat(claude): group the Claude Code surface under bough claude, add skill/command install, publish bough-hooks / bough-all#123

Merged
ikeikeikeike merged 12 commits into
mainfrom
feat/claude-namespace-taxonomy
Jul 17, 2026
Merged

feat(claude): group the Claude Code surface under bough claude, add skill/command install, publish bough-hooks / bough-all#123
ikeikeikeike merged 12 commits into
mainfrom
feat/claude-namespace-taxonomy

Conversation

@ikeikeikeike

@ikeikeikeike ikeikeikeike commented Jul 16, 2026

Copy link
Copy Markdown
Member

Groups everything bough installs INTO Claude Code under one bough claude namespace, gives skills and commands the CLI path hooks already had, and publishes two more plugin variants.

v0.17.0 dropped the plugin's hooks.json on the belief that plugin hooks could only land at user scope. Measured with an isolated CLAUDE_CONFIG_DIR: claude plugin install -s project wired only that repo — the hook fired in folderA and not in folderB. The belief was wrong, so hooks ship in a plugin again.

Change Why
bough claude hook|skill|command|doctor bough plugins already means engine binaries; hook-internal verbs sat at root. Root: 18 → 15. Old spellings kept as deprecated aliases.
bough claude skill|command install Only hooks had a CLI path. Content is //go:embed-ed, so CLI and plugin ship one tree.
bough-hooks / bough-all variants The kinds differ in when they act; bough-all symlinks the shared trees (tests assert no copies).
hooks.json + drift guard restored The premise for removing it was measurably false.
vendorHash re-derived Unrelated pre-existing break: nix build / nix profile install has failed since 24a4fbc (go.mod trimmed, hash not re-derived). Reproduced on origin/main first. nix flake check only evaluates, so CI never caught it.

30 packages pass, golangci-lint 0 issues, nix build fixed and verified, and the plugin flow was verified against a real Claude Code install.

Refs: docs/PLUGIN_CLAUDE_CODE.md

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.
Comment thread internal/cli/claude_artifacts.go Outdated
if err != nil {
return err
}
if err := procutil.DeployAssets(bough.Assets, k.subdir, dst); err != nil {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

install は同名の手書き資産を無警告で上書きします。bough が出す list.md / create.md / status.md は衝突しやすい一般名で、実際に .claude/commands/list.md を置いて再現しました。上書き自体は upgrade 経路として必要なので、新規/置換を出力で区別します。

Comment thread internal/cli/claude_artifacts.go Outdated
return nil
},
}
cmd.Flags().StringVar(&scope, "scope", "project", "install scope: project (= cwd/.claude) | user (= ~/.claude)")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.
Comment thread internal/cli/instinct.go Outdated
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.`,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

課金挙動の嘘。instinct.enabled はどこからも読まれず(config.go 自身が「継続学習とは無関係・未設定」と明記)、false にしても claude --print は走り課金されます。実際の gate は instinct.observer.autostart のみ。真実に書き換えます。

Comment thread internal/cli/instinct.go Outdated
@@ -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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

旧 doc comment が残置。新 comment と2段になり、しかも旧側は bough observer run-once を canonical と呼んでいます(本 commit で deprecated alias に降格済)。削除します。

Comment thread internal/hooks/hooks.go Outdated
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])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

見出しは全 plugin を列挙するのに、uninstall 例は HookPlugins[0] だけ。2つ有効な時、示された1行を実行しても片方が残り二重発火が続きます。全件出します。

Comment thread internal/hooks/hooks.go Outdated
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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List() が既に読んだ settings.json を loadSettings() で再読込・再parse。doctor 1回につき2回 I/O。

Comment thread README.md Outdated
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@ikeikeikeike
ikeikeikeike merged commit f018c5c into main Jul 17, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant