Parent Epic
#11831 — Sub 14. Closes the env-binding anti-pattern that survived Sub 1's masterclass refactor.
Premise
Sub 1 (#11833) committed ai/daemons/orchestrator/Orchestrator.mjs to a consumer pattern of:
get pollIntervalMs() {
return Env.parseNumber(process.env.NEO_ORCHESTRATOR_POLL_INTERVAL_MS, 'NEO_ORCHESTRATOR_POLL_INTERVAL_MS')
?? AiConfig.orchestrator.intervals.pollMs;
}
The env-var name is duplicated at every consumer (13 call-sites in Orchestrator alone; pre-survey will find more across ai/). Each consumer also re-implements the lookup-parse-fallback dance. The current src/util/Env.mjs:12 JSDoc even codifies this pattern as canonical:
Fallback policy lives at the consumer call-site (e.g., Env.parseNumber(process.env.X, 'X') ?? AiConfig.X).
Three failure modes this creates:
- Rename drift — change the env-var name in one spot, miss the matching string literal at the consumer; warning messages name a non-existent var.
- Audit opacity — no single file enumerates which env vars the system reads. Grep-based discovery only.
- No .env-file integration —
dotenv is already a devDependency, but consumers read process.env.X directly; nothing wires .env → process.env.
Prescription
Create ai/config/env.mjs (new file) with the shape:
import 'dotenv/config';
import Env from '../../src/util/Env.mjs';
const bindings = {
NEO_ORCHESTRATOR_POLL_INTERVAL_MS: Env.parseNumber,
NEO_ORCHESTRATOR_SUMMARY_SWEEP_INTERVAL_MS: Env.parseNumber,
NEO_ORCHESTRATOR_KB_SYNC_INTERVAL_MS: Env.parseNumber,
NEO_ORCHESTRATOR_BACKUP_INTERVAL_MS: Env.parseNumber,
NEO_ORCHESTRATOR_PRIMARY_DEV_SYNC_INTERVAL_MS: Env.parseNumber,
NEO_ORCHESTRATOR_DREAM_INTERVAL_MS: Env.parseNumber,
NEO_ORCHESTRATOR_GOLDEN_PATH_INTERVAL_MS: Env.parseNumber,
NEO_ORCHESTRATOR_SWARM_HEARTBEAT_INTERVAL_MS: Env.parseNumber,
NEO_ORCHESTRATOR_KB_SYNC_ENABLED: Env.parseBool,
NEO_ORCHESTRATOR_PRIMARY_DEV_SYNC_ENABLED: Env.parseBool,
NEO_ORCHESTRATOR_BRIDGE_DAEMON_ENABLED: Env.parseBool,
NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED: Env.parseBool,
NEO_ORCHESTRATOR_GOLDEN_PATH_REPO_ENRICHMENT_ENABLED: Env.parseBool,
};
const env = {};
for (const [name, parse] of Object.entries(bindings)) {
env[name] = parse(process.env[name], name);
}
export default env;
Consumer shape post-migration:
import env from '../../config/env.mjs';
get pollIntervalMs() {
return env.NEO_ORCHESTRATOR_POLL_INTERVAL_MS ?? AiConfig.orchestrator.intervals.pollMs;
}
Pre-survey (Stage 0 of implementation):
git grep -nE "Env\.parse(Bool|Number|Port|Url|String)\(process\.env" --include='*.mjs' to find every consumer
git grep -nE "process\.env\.NEO_" --include='*.mjs' to find any direct-read consumers that bypass Env.parseX entirely (those may need separate handling or migration)
Acceptance Criteria
ai/config/env.mjs created with binding registry covering all NEO_* vars discovered via pre-survey.
dotenv/config imported at module load — local .env files auto-loaded into process.env.
- All consumers migrated from
Env.parseX(process.env.NAME, 'NAME') → import env from '...'; env.NAME pattern.
src/util/Env.mjs:12 JSDoc updated — recommends the registry pattern, not the consumer-side redundant call.
- Unit test for
ai/config/env.mjs:
- Missing var:
env.NAME === undefined
- Invalid value: warn fires +
env.NAME === undefined
- Valid value: parsed correctly per parser type
.env-file load: .env.test file populates process.env (mocked via dotenv programmatic API)
- Process-env override precedence: explicit
process.env.X set BEFORE module load wins over .env.X (default dotenv behavior — assert it holds)
- Zero remaining
Env.parseX(process.env.NAME, 'NAME') call-sites in ai/ (grep-clean).
- All existing unit + integration tests pass (no behavior change at consumer surface).
Avoided Traps
- Don't lazy-evaluate inside
env — eager binding at module load is correct. Env vars don't change at runtime in production; lazy adds complexity without value.
- Don't make
env keyed by semantic name (env.kbSyncEnabled) instead of env-var name (env.NEO_ORCHESTRATOR_KB_SYNC_ENABLED). The semantic mapping is the consumer's call (different consumers may want different defaults via ??). Keep env as the typed env-var slot, not the policy.
- Don't extend
Env.applyEnvBindings for this. That helper mutates an existing data object via dotted paths — different shape. New file uses a simple binding registry, exports own env object.
- Don't add a separate
Env.parseBoolFromEnv(name) shorthand wrapper as an interim API. The fix is the registry, not a thin wrapper.
- Don't move dotenv to a non-dev dependency — it's already a devDependency; production load is via the deployed environment, dotenv just bridges local dev. Audit ensures no breakage on prod boot when no .env file is present (dotenv.config() silently no-ops).
- Don't pre-suppose the registry's filesystem location —
ai/config/env.mjs is my proposal; @tobiu may prefer ai/env.mjs, config/env.mjs, or another path. Will confirm via PR body if no operator direction at file-write time.
Depends on
Sub 13 (collapse Orchestrator over-engineered Service-DI) — Sub 13 lands first to avoid Orchestrator merge conflicts on the 14-call-site rewrite.
Closes
Epic #11831 (the masterclass refactor that started this) — Sub 14 is the genuine final cleanup; Subs 8-12 were folder-structure derivatives.
Authored by: [Claude Opus 4.7] (Claude Code)
Parent Epic
#11831 — Sub 14. Closes the env-binding anti-pattern that survived Sub 1's masterclass refactor.
Premise
Sub 1 (#11833) committed
ai/daemons/orchestrator/Orchestrator.mjsto a consumer pattern of:get pollIntervalMs() { return Env.parseNumber(process.env.NEO_ORCHESTRATOR_POLL_INTERVAL_MS, 'NEO_ORCHESTRATOR_POLL_INTERVAL_MS') ?? AiConfig.orchestrator.intervals.pollMs; }The env-var name is duplicated at every consumer (13 call-sites in Orchestrator alone; pre-survey will find more across
ai/). Each consumer also re-implements the lookup-parse-fallback dance. The currentsrc/util/Env.mjs:12JSDoc even codifies this pattern as canonical:Three failure modes this creates:
dotenvis already a devDependency, but consumers readprocess.env.Xdirectly; nothing wires.env→process.env.Prescription
Create
ai/config/env.mjs(new file) with the shape:import 'dotenv/config'; // .env file → process.env at module load import Env from '../../src/util/Env.mjs'; // Single declaration per var: name → parser. Adding a new env var means // one entry here, then `env.<NAME>` everywhere. const bindings = { NEO_ORCHESTRATOR_POLL_INTERVAL_MS: Env.parseNumber, NEO_ORCHESTRATOR_SUMMARY_SWEEP_INTERVAL_MS: Env.parseNumber, NEO_ORCHESTRATOR_KB_SYNC_INTERVAL_MS: Env.parseNumber, NEO_ORCHESTRATOR_BACKUP_INTERVAL_MS: Env.parseNumber, NEO_ORCHESTRATOR_PRIMARY_DEV_SYNC_INTERVAL_MS: Env.parseNumber, NEO_ORCHESTRATOR_DREAM_INTERVAL_MS: Env.parseNumber, NEO_ORCHESTRATOR_GOLDEN_PATH_INTERVAL_MS: Env.parseNumber, NEO_ORCHESTRATOR_SWARM_HEARTBEAT_INTERVAL_MS: Env.parseNumber, NEO_ORCHESTRATOR_KB_SYNC_ENABLED: Env.parseBool, NEO_ORCHESTRATOR_PRIMARY_DEV_SYNC_ENABLED: Env.parseBool, NEO_ORCHESTRATOR_BRIDGE_DAEMON_ENABLED: Env.parseBool, NEO_ORCHESTRATOR_SWARM_HEARTBEAT_ENABLED: Env.parseBool, NEO_ORCHESTRATOR_GOLDEN_PATH_REPO_ENRICHMENT_ENABLED: Env.parseBool, // + all NEO_* vars discovered via pre-survey grep across ai/, buildScripts/, scripts subfolders }; const env = {}; for (const [name, parse] of Object.entries(bindings)) { env[name] = parse(process.env[name], name); // returns undefined when var absent (preserves ?? fallback chain) } export default env;Consumer shape post-migration:
import env from '../../config/env.mjs'; get pollIntervalMs() { return env.NEO_ORCHESTRATOR_POLL_INTERVAL_MS ?? AiConfig.orchestrator.intervals.pollMs; }Pre-survey (Stage 0 of implementation):
git grep -nE "Env\.parse(Bool|Number|Port|Url|String)\(process\.env" --include='*.mjs'to find every consumergit grep -nE "process\.env\.NEO_" --include='*.mjs'to find any direct-read consumers that bypass Env.parseX entirely (those may need separate handling or migration)Acceptance Criteria
ai/config/env.mjscreated with binding registry covering all NEO_* vars discovered via pre-survey.dotenv/configimported at module load — local.envfiles auto-loaded intoprocess.env.Env.parseX(process.env.NAME, 'NAME')→import env from '...'; env.NAMEpattern.src/util/Env.mjs:12JSDoc updated — recommends the registry pattern, not the consumer-side redundant call.ai/config/env.mjs:env.NAME === undefinedenv.NAME === undefined.env-file load:.env.testfile populatesprocess.env(mocked via dotenv programmatic API)process.env.Xset BEFORE module load wins over.env.X(default dotenv behavior — assert it holds)Env.parseX(process.env.NAME, 'NAME')call-sites inai/(grep-clean).Avoided Traps
env— eager binding at module load is correct. Env vars don't change at runtime in production; lazy adds complexity without value.envkeyed by semantic name (env.kbSyncEnabled) instead of env-var name (env.NEO_ORCHESTRATOR_KB_SYNC_ENABLED). The semantic mapping is the consumer's call (different consumers may want different defaults via??). Keepenvas the typed env-var slot, not the policy.Env.applyEnvBindingsfor this. That helper mutates an existingdataobject via dotted paths — different shape. New file uses a simple binding registry, exports ownenvobject.Env.parseBoolFromEnv(name)shorthand wrapper as an interim API. The fix is the registry, not a thin wrapper.ai/config/env.mjsis my proposal; @tobiu may preferai/env.mjs,config/env.mjs, or another path. Will confirm via PR body if no operator direction at file-write time.Depends on
Sub 13 (collapse Orchestrator over-engineered Service-DI) — Sub 13 lands first to avoid Orchestrator merge conflicts on the 14-call-site rewrite.
Closes
Epic #11831 (the masterclass refactor that started this) — Sub 14 is the genuine final cleanup; Subs 8-12 were folder-structure derivatives.
Authored by: [Claude Opus 4.7] (Claude Code)