ADR 0019: Collapse into one binary with a TypeScript-native engine
Amendments.
- Vocabulary: current pointers use
standards(formerlyratchets),done(formerlyfinish),impactwherescopesnames the verb, and[jobs]/[jobs.<name>](formerly[capabilities]/[checks.<name>]); the retired product-category wording reads asdiscern, the gate, or the bar; the decisions below are unchanged.- ADR 0137 — Project Scripts: the language-agnostic executable contract stands, but current files are Project Scripts under
[scripts].dirand run throughdiscern script; the root fall-through and engine-wins collision rule described below are superseded.
Status: accepted
Context
§discern was two programs. The installer (src/) was Deno/TypeScript, compiled to a self-contained binary. The engine — the gate, the worktree lifecycle, standards, scope classification, the guideline compiler, the agent dispatcher — was POSIX shell, committed into each project under .discern/engine/ as managed files the installer kept byte-identical to templates/.
That split bought portability (an installed discern project needed no runtime) but charged three rents:
- DX confusion.
agentis a repo-local file, not onPATH;.discern/files invisibly drive it; two command vocabularies (discernvsagent,deno task selfsyncvsdiscern upgrade) for one tool. - Self-imposed overhead. A large slice of the installer existed only to ship-and-sync the committed shell engine: manifest content-hashing,
.newpreservation, orphan reconciliation,selfcheck/selfsyncdrift detection, the ADR 0013 vocabulary renderer, the dash/bash CI matrix, theset -fnoglob policy, shellcheck — plus two committed copies of the engine (thetemplates/source and the self-host root install) kept identical by a gate. - The portable-shell tax. Concurrency, fail-fast cancellation, signal handling, and TOML parsing are all painful in portable
sh. A meaningful fraction of the ADRs (0002, 0004, 0006, 0012) are shell-portability pain. And a tool that preaches typechecking shipped an un-typechecked engine.
The window to change this is now: only a couple of internal installs exist, the same window ADR 0016/0017/0018 leaned on.
Decision
§Collapse the two halves into one self-contained Deno binary with a TS-native engine compiled in. Delete the shell engine; rebuild it as TypeScript modules; delete the committed-engine sync machinery with it.
- The engine is TypeScript (
src/engine/**), sharingsrc/shared/**with the installer. The gate,jobsInStage/cmdsInStage, the parallel/serial job runner, scope-glob classification, standards, the worktree lifecycle + identity (POSIX-cksum-faithful), the guideline compiler, and the dispatcher are all TS. The--jsongate contract is reproduced byte-for-shape. The concurrency core uses Deno's process-group tree-kill (detached+Deno.kill(-pid)) for fail-fast cancellation — genuinely better than portablesh's best-effort sibling kill, not just different.
discernis the one command;agentis dropped. The former engine recipes are first-classdiscernsubcommands (done,tidy,test,standards,guidelines, theworktreecommand group,identity,impact). The rootagentfile is no longer scaffolded; worktree hooks, docs, and compiled guidance repoint todiscern.
- "Managed files" retire. With no committed engine to sync, there is no
manifest.json, no content hashes, no.newpreservation, no orphan reconciliation, no drift detection.setuplays down only your seed files (config, guidelines stub, brief, recipes README, merged settings, gitignore). Skills become materialized artifacts: bundled in the binary, copied to.discern/skills/bysetup(always overwritten — they are the binary's), gitignored, then symlinked into.claude/skills/. The ownership model is now two buckets: yours (committed seeds, write-once) and the binary's (gitignored, re-published artifacts — skills, compiledCLAUDE.md/AGENTS.md; the engine is the limit case, not even on disk).
- Project recipes stay language-agnostic executables. The binary discovers
.discern/recipes/*and execs the match withDISCERN_*exported. Recipes no longer source a shell library; they read config through a newdiscern config get|array|has|subsections|keyssurface. The engine still wins on a name collision (warn on shadow).
- Names unchanged.
discern,.discern/, the@db@/@project_slug@worktree tokens, and the[capabilities]/[checks]/[scopes]/[standards]config shape all survive this cutover. Renaming discern was left to a separate, later change — since carried out (see ADR 0022).
The explicit nos: no committed shell engine; no agent dispatcher; no managed-file machinery; no set -f/DISCERN_ENGINE_RECIPE noglob marker; no dual-vocabulary renderer.
Consequences
§- Self-host inverts (this supersedes ADR 0010). The repo no longer commits a second engine copy to gate for drift — there is no second copy and no drift to detect. It runs its own engine via the binary (
deno task dev done). The regression class ADR 0010'sselfcheckguarded is made structurally impossible: there is nothing that can drift. - ADR 0012 (engine noglob) retires. Glob classification is in-memory TS (
engine/scopes/glob.ts);set -fand theDISCERN_ENGINE_RECIPEmarker are gone. A project recipe is just an executable with normal shell globbing. - ADR 0013 (vocabulary renderer) retires. With no committed copy there is no
selfsync/selfcheck, so the dual-audience command-name problem evaporates;selfCmdand its gate guard are deleted. - ADR 0008 is moot.
managed.jsonand the managed-set classifier are deleted. - ADR 0001 is amended. Recipes read config via
discern config get, not by sourcing the engine library; the engine-always-wins shadow rule survives. - The installer shrinks sharply.
manifest.ts,invocation.ts, the hash/.new/orphan logic infs_plan.ts, the managed-sync half ofupgrade.ts,scripts/lint-sh.ts, the shellcheck CI step, and the dash/bash matrix all go.upgradesurvives, narrowed: run pending config-schema migrations → re-materialize the bundled skills → recompile guidelines → stamp[meta].schema_version(moved out of the deleted manifest). A migration prunes a pre-existing on-disk shell engine from an upgrading install. - Config is held to strict TOML. The runtime reader is now
@std/toml, stricter than the retiredtoml.awk; a malformed config throws, surfaced bydoctorrather than read leniently. (Amends ADR 0004 only in thatduration_sstays an integer for output-compat.) - One binary, one PATH command. ~60–90 MB (V8 was always baked in), startup in the low tens of ms — fine for git hooks and an all-day gate. arm64 macOS binaries are ad-hoc signed by
deno compileon a macOS host (release CI builds the darwin targets onmacos-latest).
Alternatives considered
§- Keep the shell engine, just ship it inside the binary (extract-and-run). Rejected: it keeps the portable-shell tax (concurrency, signals, TOML parsing) and an un-typechecked engine. The point is a type-checked engine with better primitives, not a shell engine in a nicer wrapper.
- Keep
agentas a thin shim that forwards to the binary. Rejected: two command names is exactly the DX confusion the cutover removes. The recipes are first-classdiscernverbs now. - Keep managed-file sync for skills (commit them, hash-track them). Rejected: skills are the binary's, identical for every install — materializing them (gitignored, re-published on
upgrade) is simpler than a hash-tracked,.new-on-edit committed copy, and removes the last consumer of the managed machinery.