ADR 0010: two-tier public surface
Date: 2026-07-12 Status: Accepted
Context
Section titled “Context”Harnery’s identity is multi-agent coordination. But because host CLIs embed it
(createHarneryProgram), the package also exports the utility libraries its
own commands are built from — lib/format, lib/cookies, lib/readability,
and so on — and embedding hosts have started leaning on them (the first host
already imports lib/format from ~15 files, and a shared HTTP client for its
vendor integrations is queued to land in lib/http).
That raised a positioning question from the project owner: the utilities must not become what harnery is. Outside users should come for coordination; the utilities are supporting plumbing for hosts that have already embedded. Without an explicit boundary, three drifts were likely:
- The README/docs pitch degrades into “coordination + assorted utilities,” which sells neither well.
- Utility modules grow casual imports of the coordination core, so pulling
lib/httpstarts dragging in heartbeat/hook machinery — making the utilities unusable as plain libraries and entangling the product with them. - The toolkit accretes speculative helpers (“might be useful”) instead of growing by demonstrated reuse.
One export already violated any clean boundary: ./lib/scratch was a
coordination feature (agent scratchpads, imports core/agents) shipped under
the utility namespace.
Alternatives considered
Section titled “Alternatives considered”- Split into two npm packages (
harnery+ a@harnery/toolkit). Cleanest marketing story, but real ongoing cost: two publishes, cross-package version management, and harnery depending on its own sibling — for a package with a handful of consumers, all currently controlled by the same owner. Rejected as premature; the door stays open (the layering rule below is exactly the precondition a future split would need). - Rename
src/lib/directories to mirror the tiers (e.g.src/toolkit/). Rejected: the tier boundary is the public exports map, not the source layout —src/lib/legitimately hosts internal coordination libraries (council, decision, identities) that are not exported at all. Moving directories churns every import for zero enforcement value. - Document the tiers but don’t enforce them. Rejected: soft rules about what may import what don’t survive contact with future contributors (or future agents). The project already learned this with portability — the written rule got a scanner, and the scanner is what actually holds the line.
- Do nothing. Rejected: drift #2 in particular is one convenient import away at any time, and it is much cheaper to forbid before it exists than to untangle after.
Decision
Section titled “Decision”- The exports map is the tier boundary. Product tier:
.,./commander,./core/*— the coordination layer, the reason to install harnery, full semver discipline. Toolkit tier: every./lib/*subpath — supporting utilities for embedding hosts, supported but secondary, may evolve faster. Anything unexported is internal regardless of directory. ./lib/scratchbecomes./core/scratch(source physically moved tosrc/core/scratch/). Scratchpads are a coordination feature; the export said otherwise. Pre-1.0 hard rename, no alias left behind; the only affected importers were harnery’s own CLI and web app.- Layering guard:
scripts/check-layering.ts+tests/unit/layering.test.tswalk the transitive relative-import graph of every./lib/*export and fail CI if any path reachessrc/core/(or self-importsharnery/core...). No per-line waiver — a toolkit module that needs the core is reclassified, not excused. Product → toolkit imports remain free (that’s the direction the toolkit exists for). - Promotion rule: utilities are born in a host CLI and get promoted into the toolkit only when a second consumer needs them or harnery’s own commands do. The toolkit grows by demonstrated reuse.
- Positioning follows: README tagline,
package.jsondescription, and the docs-site description now lead with coordination alone; the toolkit is documented under Embedding + surface tiers as batteries for embedders, with an explicit “don’t adopt harnery for the toolkit alone” note.
Result / gotchas
Section titled “Result / gotchas”check:layeringverified 8 toolkit exports clean at adoption time, and the guard was negative-tested (an injectedcore/agentsimport intolib/machinewas caught with a readable import chain).- The guard derives its scan roots from the exports map itself, so a future
toolkit export is covered automatically — and a future coordination-flavored
export accidentally placed under
./lib/*fails CI, which is the fence working as intended. - The
buncondition of each export points atsrc/*.ts, so the guard walks source, notdist/— it runs without a build. - Gotcha discovered during the scratch move: the first embedding host’s CLI
runs harnery from workspace source, so breaking harnery imports mid-refactor
breaks that host’s tooling while you’re using it to do the refactor. Use
plain
sed/editor for multi-step harnery source moves; the host CLI comes back when the tree compiles again. - Adoption also surfaced a self-hosting leak: running
initfrom the embedding host’s CLI stamped the host’s bin name into harnery’s own.harnery/config.jsoncand rendered it into the committed AGENTS.md block and skills — host tokens headed for a public repo. Two fixes shipped with this ADR:initnow honors a binName already pinned inconfig.jsoncover the invoking CLI’s name (pinnedBinName()), and the portability scanner grew the agent-facing surfaces (AGENTS.md,CLAUDE.md,.claude/,.harnery/config.jsonc) as scan roots so a re-stamp fails CI instead of shipping. harnery’s own checkout pins"binName": "harn".