T-WGV4-runtime-agnostic-shipped-code
Status: closed/done · Impact: high · Complexity: medium
D-0014 rules that shipped code (plugin/lib/, plugin/cli/) is
JS-runtime-agnostic: node: builtins and web standards only, so plain
Node can run the CLI. Today a bounded set of Bun-only constructs breaks
that — most dangerously the import.meta.main entry guard in
plugin/cli/sdlc.ts, which is undefined under Node ≤23, so the CLI
would silently do nothing. This task removes every Bun coupling from
shipped code and adds a gate so it cannot return unnoticed.
Partial progress — PR #692 (2026-07-12). The
Bun.*globals are already out of shipped code:Bun.spawnSync→node:child_process(registry, cleanup, doctor, scan-candidates, markdown-fixtures),Bun.Glob→ tinyglobby (resolve-touchpoints),Bun.serve→@hono/node-server(dashboard), plus aproject-check-no-bun-in-shippedlefthook gate. Still open on this task: (a) AC-1’simport.meta.dir(~25 sites) andimport.meta.mainentry guards — includingplugin/cli/sdlc.ts, the most dangerous Bun-ism — which #692 did NOT touch; (b) AC-2 wants asdlc gate runtime-agnosticverb wired intosdlc.yamlquality_checks, not the interim lefthook check; (c) AC-4’s plain-noderun is unverified; (d)plugin/lib/util/runtime.ts. When finishing, fold the lefthook gate into the verb and widen it toimport.meta.*.
| Location | Role today |
|---|---|
plugin/lib/registry.ts#spawnRunner | default CommandRunner seam; Bun.spawnSync for git/gh subprocesses |
plugin/lib/services/project/cleanup/index.ts#makeRunFn | Bun.spawnSync for merge-base / worktree probes |
plugin/lib/services/project/ops/doctor.ts#probeBinary | Bun.spawnSync --version presence probe (git/gh/claude); typeof Bun runtime probe (already portable) |
plugin/lib/services/project/ops/scan-candidates.ts | Bun.spawnSync git check-ignore --stdin -z with NUL-delimited stdin blob |
plugin/lib/services/gate/ops/markdown-fixtures.ts | Bun.spawnSync of literal bunx rumdl@<pinned>; checks Bun-only proc.success |
plugin/lib/services/dashboard/server.ts | Bun.serve over a Hono app (routes / and /api/state, no websockets/static mounts); detached re-spawn via spawn("bun", ["run", entry]) |
plugin/lib/model/entities/task/ops/resolve-touchpoints.ts#globMatches | Bun.Glob().scanSync existence probe for touchpoint globs |
plugin/lib/services/docs/site.ts | spawns helper via spawnSync("bun", ["run", helperPath]); module-relative paths via Bun-only import.meta.dir |
plugin/cli/sdlc.ts | CLI entrypoint guarded by Bun-only import.meta.main |
plugin/cli/backlog_cli/create.ts | spawns bun run + bunx rumdl; dir via new URL(import.meta.url).pathname (breaks on encoded/Windows paths); import.meta.main |
plugin/lib/model/authoring.ts | one of 23 import.meta.dir sites resolving templates/schemas module-relative (full list in Files to touch) |
package.json | hono present; @hono/node-server absent; runtime deps otherwise runtime-agnostic (tinyglobby already available for the glob swap) |
Not affected: 111 test files across 29 tests/ dirs import bun:test —
dev-side per D-0014, staying on Bun. Shipped code has no bun: imports,
no Bun.file/Bun.env/Bun.write, and no reliance on Bun’s .env
autoload (all process.env reads are OS/CI/plugin vars).
Proposed
Section titled “Proposed”Shipped trees contain zero Bun-only constructs. A shared
plugin/lib/util/runtime.ts provides the four portable idioms
(module dir, is-main guard, current-runtime exec path, bunx/npx
picker); every former call site uses it or a direct node: API. The
dashboard serves through @hono/node-server. A sdlc gate runtime-agnostic op scans shipped trees for the forbidden tokens and
runs in the quality-check suite, so reintroduction fails CI. In-repo,
plain Node ≥24 can run the CLI end to end (type stripping applies —
the node_modules refusal is a package concern, not an in-repo one).
Approach
Section titled “Approach”- Add
plugin/lib/util/runtime.tswith unit test:moduleDir(metaUrl)→dirname(fileURLToPath(metaUrl));isMain(metaUrl)→ compare againstpathToFileURL(process.argv[1]).href;runtimeExec()→process.execPath;xRunner()→"bunx"whentypeof Bun !== "undefined", else"npx". - Mechanical sweep: 23
import.meta.dirsites →moduleDir(import.meta.url); 7import.meta.mainguards →isMain(import.meta.url). Fixbacklog_cli/create.ts’snew URL(...).pathnametomoduleDirin the same pass. - Replace the five
Bun.spawnSynccalls withnode:child_processspawnSync. API mapping:exitCode→status,success→status === 0,stdin:→input:(thescan-candidates.tsNUL-stdin case), stdout/stderr buffers unchanged;markdown-fixtures.ts’sexitCode === nullcheck becomesstatus === null(+error/signal). - Replace
Bun.Globinresolve-touchpoints.ts#globMatcheswithtinyglobby.globSync(rel, { cwd: projectRoot, dot: true }), preserving the directories-match-too semantics ofonlyFiles: false(tinyglobby includes dirs viaonlyDirectories:falsedefault — add a test row covering a directory-form touchpoint to pin parity). - Dashboard: add
@hono/node-server;Bun.serve({...})→serve({ fetch: app.fetch, hostname, port }); read the bound port fromserver.address();server.stop(true)→server.close()+closeAllConnections()in the SIGTERM/SIGINT handler; the detached re-spawn replacesspawn("bun", ["run", entry])withspawn(runtimeExec(), [entry])(both Bun and Node accept a direct entry file;runis dropped). - Remaining literal
bun/bunxspawns:docs/site.tshelper andbacklog_cli/create.tsCLI re-invocation →runtimeExec(); the two pinned-rumdl invocations →xRunner(). - Add
plugin/lib/services/gate/ops/runtime-agnostic.ts(path: ['gate','runtime-agnostic']): scansplugin/lib/+plugin/cli/(excludingtests/,*.test.ts,_example/) forBun.,from "bun,import.meta.dir,import.meta.main; exits non-zero listing file:line per hit. Test with an in-memory/fixture violation. Append tosdlc.yamlquality_checks. - Verify under both runtimes: full Bun quality suite, then in-repo Node
smoke (
node plugin/cli/sdlc.ts --help,node plugin/cli/sdlc.ts project doctor --output json) on Node ≥24. If a type-stripping limitation surfaces (e.g. a JSON import needing attributes), fix it here — the built-artifact path (T-XTGT) must not be the first place shipped code meets Node.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/lib/util/runtime.ts | new | portable runtime idioms: moduleDir, isMain, runtimeExec, xRunner |
plugin/lib/util/tests/ | modify | unit test for runtime.ts |
plugin/lib/registry.ts | modify | Bun.spawnSync → node:child_process in spawnRunner |
plugin/lib/services/project/cleanup/index.ts | modify | Bun.spawnSync → node:child_process in makeRunFn |
plugin/lib/services/project/ops/doctor.ts | modify | Bun.spawnSync → node:child_process in probeBinary |
plugin/lib/services/project/ops/scan-candidates.ts | modify | Bun.spawnSync → spawnSync with input: for NUL stdin |
plugin/lib/services/gate/ops/markdown-fixtures.ts | modify | Bun.spawnSync+bunx → spawnSync+xRunner(); success/exitCode mapping |
plugin/lib/services/dashboard/server.ts | modify | Bun.serve → @hono/node-server; port via address(); stop(true) → close(); re-spawn via runtimeExec() |
plugin/lib/model/entities/task/ops/resolve-touchpoints.ts | modify | Bun.Glob → tinyglobby.globSync; parity test for dir-form touchpoints |
plugin/lib/services/docs/site.ts | modify | bun run spawn → runtimeExec(); 3 import.meta.dir sites → moduleDir |
plugin/cli/sdlc.ts | modify | import.meta.main → isMain (entry guard) |
plugin/cli/backlog_cli/create.ts | modify | bun run/bunx spawns → helpers; URL(...).pathname → moduleDir; isMain |
plugin/cli/backlog_cli/capture.ts | modify | import.meta.main → isMain |
plugin/cli/backlog_cli/index.ts | modify | import.meta.main → isMain |
plugin/lib/services/docs/generate.ts | modify | 4 import.meta.dir sites → moduleDir; import.meta.main → isMain |
plugin/lib/services/dashboard/dashboard-service.ts | modify | import.meta.main → isMain |
plugin/lib/services/dashboard/gen-client.ts | modify | import.meta.dir → moduleDir; import.meta.main → isMain |
plugin/lib/intersect/fixtures.ts | modify | import.meta.dir → moduleDir |
plugin/lib/model/authoring.ts | modify | import.meta.dir → moduleDir |
plugin/lib/model/ops/migrate.ts | modify | dirname(import.meta.dir) → moduleDir-based |
plugin/lib/model/ops/audit.ts | modify | dirname(import.meta.dir) → moduleDir-based |
plugin/lib/model/entities/backlog/ops/update.ts | modify | import.meta.dir → moduleDir |
plugin/lib/model/entities/principle/reports/review/schema.ts | modify | import.meta.dir → moduleDir |
plugin/lib/model/entities/standard/ops/update.ts | modify | import.meta.dir → moduleDir |
plugin/lib/model/entities/standard/ops/supersede.ts | modify | import.meta.dir → moduleDir |
plugin/lib/model/entities/task/commits/lifecycle/schema.ts | modify | import.meta.dir → moduleDir |
plugin/lib/model/entities/task/ops/close-commit.ts | modify | import.meta.dir → moduleDir |
plugin/lib/model/entities/task/ops/update.ts | modify | import.meta.dir → moduleDir |
plugin/lib/model/entities/task/reports/review/schema.ts | modify | import.meta.dir → moduleDir |
plugin/lib/services/plugin/info-report.ts | modify | resolve(import.meta.dir, ...) → moduleDir-based |
plugin/lib/services/project/ops/setup.ts | modify | import.meta.dir → moduleDir |
plugin/lib/services/docs/site_render.ts | modify | import.meta.dir → moduleDir |
plugin/lib/services/gate/ops/runtime-agnostic.ts | new | gate op scanning shipped trees for Bun-only tokens |
plugin/lib/services/gate/tests/ | modify | test for the new gate op (clean pass + seeded violation) |
package.json | modify | add @hono/node-server runtime dep (lockfile updates alongside) |
sdlc.yaml | modify | append gate runtime-agnostic to quality_checks |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
grep -rE 'Bun\.|from "bun|import\.meta\.dir|import\.meta\.main' plugin/lib plugin/cli --include='*.ts'— excludingtests/dirs,*.test.ts, and_example/— returns zero matches. - AC-2:
sdlc gate runtime-agnosticexits 0 on the clean tree, exits non-zero naming file:line when a violation exists (proven by test), and is listed insdlc.yamlquality_checks. - AC-3: the existing quality suite stays green under Bun:
bunx tsc --noEmit,bun test,bun test ./.claude. - AC-4: on Node ≥24, in-repo:
node plugin/cli/sdlc.ts --helpexits 0 andnode plugin/cli/sdlc.ts project doctor --output jsonemits valid JSON reporting runtimenode. - AC-5: dashboard lifecycle unchanged under Bun:
sdlc dashboard start/stopserve/and/api/stateand shut down gracefully on SIGTERM (existing tests pass). - AC-6:
@hono/node-serveris the only new runtime dependency added by this task.
Out of scope
Section titled “Out of scope”- The publish-time build, package rename, and
files:allowlist (T-XTGT). - Skill-corpus invocation normalization and launcher runtime resolution (
T-ITMN). - Package-root vs project-root path assumptions (
T-T0O9) — this task keeps module-relative resolution semantics identical, only making them portable. - Tests stay on
bun:test; rootpackage.jsonscripts stay Bun (dev-side per D-0014). - The pinned-rumdl version policy in the markdown gates (unchanged, just re-spawned portably).
Dependencies
Section titled “Dependencies”- none — first wave of the D-0014 migration;
T-XTGTandT-ITMNbuild on it.
Discovery context
Section titled “Discovery context”- D-0014 Migration step 2 (
docs/planning/decisions/D-0014-cli-primary-npm-distribution.md). - Call-site inventory from a full repo scan on 2026-07-02: 5
Bun.spawnSync, 1Bun.serve, 1Bun.Glob, 23import.meta.dir, 7import.meta.main, 3 literalbunspawns, 2bunxspawns; nobun:imports in shipped code.