Parent Epic
#11831 — Sub 6 of 6 (substrate-prerequisite; blocks Sub 1).
Scope
THIN substrate-utility primitive. Lift ai/mcp/server/shared/helpers/EnvConfig.mjs parsers verbatim to src/util/Env.mjs as Neo.util.Env (mirrors Neo.util.String static-class precedent). Provides Orchestrator-needed parser surface so Sub 1 can implement the 2-value chain via Neo.util.Env.parseNumber().
Scope-bounded per Discussion #11828 Cycle-3.1 deferral fixup (avoid recreating the #11075 "start somewhere" failure). DOES NOT include the full 61-file consumer migration — that decomposes into separate Sub 6a/6b/6c follow-up tickets (orchestrator-cluster, mcp-cluster, daemon-cluster, ad-hoc-cluster).
Acceptance Criteria
- Create
src/util/Env.mjs extending Base, Neo.setupClass() registered as Neo.util.Env. Mirrors src/util/String.mjs static-class pattern.
- API surface (lifted from
EnvConfig.mjs + Orchestrator-needed): parseNumber(rawValue, envVarName, warn?), parseBool(rawValue, envVarName, warn?), parsePort(rawValue, envVarName, warn?), parseUrl(rawValue, envVarName, warn?), parseString(rawValue), applyEnvBindings(data, bindings, env?, warn?), setDeep(obj, path, value).
- Boolean compatibility:
parseBool accepts permissive token set preserving both MCP ('true'/'false') and daemon (PrimaryRepoSyncService.parseEnabledFlag '0'/'no'/'off') semantics. Tokens: true = 'true'/'yes'/'on'/'1'; false = 'false'/'no'/'off'/'0'; else warn + undefined. Case-insensitive after trim.
- Add
Env entry to src/util/_export.mjs barrel.
- Availability: consumers use direct
import Env from '<path>/src/util/Env.mjs' (NOT relying on Neo.util.Env global — orchestrator-daemon.mjs boots src/core/_export.mjs, not the util barrel).
- Decoders MUST NOT know AiConfig / defaults / aliases / fallback chains / specific domain consumers (substrate-tier discipline). Fallback to AiConfig stays at consumer call-site.
Number(undefined) === NaN gotcha protection: parseNumber returns undefined (NOT NaN) for absent input, so Env.parseNumber(...) ?? AiConfig.X correctly falls through to AiConfig fallback.
- Test surface:
test/playwright/unit/util/Env.spec.mjs proves decoder semantics: absent/empty/null → undefined no warn; well-formed → decoded; malformed → warn + undefined; ?? fallback works correctly; boolean tokens (both MCP + daemon sets); port out-of-range; URL normalization; prototype-pollution guards in setDeep; applyEnvBindings skip-on-absent + skip-on-malformed.
Out-of-scope (follow-up subs)
- 61-file / 80 env-var consumer migration → Sub 6a (orchestrator-cluster), Sub 6b (mcp-cluster), Sub 6c (daemon-cluster), Sub 6d (ad-hoc-cluster)
EnvConfig.mjs deletion → after Sub 6a/6b consumers rewire
CadenceEngine.parseInterval deletion → after Sub 1 lands
PrimaryRepoSyncService.parseEnabledFlag deletion → after Sub 6c daemon migration
Files (new + touched)
- NEW:
src/util/Env.mjs (~190 lines, lifted from EnvConfig.mjs + Orchestrator-needed surface)
- EDIT:
src/util/_export.mjs (add Env import + export entry)
- NEW:
test/playwright/unit/util/Env.spec.mjs
- (no
EnvConfig.mjs deletion — consumer rewire deferred to follow-up subs to keep this PR thin)
Authored by: [Claude Opus 4.7] (Claude Code)
Parent Epic
#11831 — Sub 6 of 6 (substrate-prerequisite; blocks Sub 1).
Scope
THIN substrate-utility primitive. Lift
ai/mcp/server/shared/helpers/EnvConfig.mjsparsers verbatim tosrc/util/Env.mjsasNeo.util.Env(mirrorsNeo.util.Stringstatic-class precedent). Provides Orchestrator-needed parser surface so Sub 1 can implement the 2-value chain viaNeo.util.Env.parseNumber().Scope-bounded per Discussion #11828 Cycle-3.1 deferral fixup (avoid recreating the #11075 "start somewhere" failure). DOES NOT include the full 61-file consumer migration — that decomposes into separate Sub 6a/6b/6c follow-up tickets (orchestrator-cluster, mcp-cluster, daemon-cluster, ad-hoc-cluster).
Acceptance Criteria
src/util/Env.mjsextendingBase,Neo.setupClass()registered asNeo.util.Env. Mirrorssrc/util/String.mjsstatic-class pattern.EnvConfig.mjs+ Orchestrator-needed):parseNumber(rawValue, envVarName, warn?),parseBool(rawValue, envVarName, warn?),parsePort(rawValue, envVarName, warn?),parseUrl(rawValue, envVarName, warn?),parseString(rawValue),applyEnvBindings(data, bindings, env?, warn?),setDeep(obj, path, value).parseBoolaccepts permissive token set preserving both MCP ('true'/'false') and daemon (PrimaryRepoSyncService.parseEnabledFlag'0'/'no'/'off') semantics. Tokens: true ='true'/'yes'/'on'/'1'; false ='false'/'no'/'off'/'0'; else warn + undefined. Case-insensitive after trim.Enventry tosrc/util/_export.mjsbarrel.import Env from '<path>/src/util/Env.mjs'(NOT relying onNeo.util.Envglobal —orchestrator-daemon.mjsbootssrc/core/_export.mjs, not the util barrel).Number(undefined) === NaNgotcha protection:parseNumberreturnsundefined(NOT NaN) for absent input, soEnv.parseNumber(...) ?? AiConfig.Xcorrectly falls through to AiConfig fallback.test/playwright/unit/util/Env.spec.mjsproves decoder semantics: absent/empty/null → undefined no warn; well-formed → decoded; malformed → warn + undefined;??fallback works correctly; boolean tokens (both MCP + daemon sets); port out-of-range; URL normalization; prototype-pollution guards insetDeep;applyEnvBindingsskip-on-absent + skip-on-malformed.Out-of-scope (follow-up subs)
EnvConfig.mjsdeletion → after Sub 6a/6b consumers rewireCadenceEngine.parseIntervaldeletion → after Sub 1 landsPrimaryRepoSyncService.parseEnabledFlagdeletion → after Sub 6c daemon migrationFiles (new + touched)
src/util/Env.mjs(~190 lines, lifted fromEnvConfig.mjs+ Orchestrator-needed surface)src/util/_export.mjs(addEnvimport + export entry)test/playwright/unit/util/Env.spec.mjsEnvConfig.mjsdeletion — consumer rewire deferred to follow-up subs to keep this PR thin)Authored by: [Claude Opus 4.7] (Claude Code)