@promptctl/cc-candybar 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/index.mjs +83 -76
  2. package/package.json +6 -6
  3. package/schema/cc-candybar.schema.json +193 -4
  4. package/src/check.ts +49 -27
  5. package/src/click/wire.ts +16 -0
  6. package/src/config/action.ts +57 -22
  7. package/src/config/default-dsl-config.ts +424 -55
  8. package/src/config/dsl-loader.ts +14 -2
  9. package/src/config/dsl-types.ts +59 -0
  10. package/src/config/loader/actions.ts +283 -109
  11. package/src/config/loader/cross-ref.ts +148 -28
  12. package/src/config/loader/emit-schema.ts +2 -0
  13. package/src/config/loader/globals.ts +118 -31
  14. package/src/config/loader/merge.ts +58 -1
  15. package/src/config/loader/persist-target.ts +32 -0
  16. package/src/config/loader/presets.ts +107 -0
  17. package/src/config/option-domain.ts +164 -0
  18. package/src/config/presets.ts +156 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +61 -7
  21. package/src/daemon/config-overrides-store.ts +322 -0
  22. package/src/daemon/paths.ts +10 -0
  23. package/src/daemon/render-payload.ts +84 -19
  24. package/src/daemon/server.ts +68 -55
  25. package/src/daemon/verbs/config-validators.ts +127 -0
  26. package/src/daemon/verbs/index.ts +129 -2
  27. package/src/daemon/verbs/state-validators.ts +98 -586
  28. package/src/daemon/verbs/validator-registry.ts +457 -0
  29. package/src/demo/dsl.ts +17 -10
  30. package/src/dsl/node-registry.ts +54 -39
  31. package/src/dsl/render.ts +158 -46
  32. package/src/help-text.ts +59 -0
  33. package/src/index.ts +2 -47
  34. package/src/install/index.ts +18 -4
  35. package/src/render/action.ts +155 -33
  36. package/src/render/active-segment.ts +78 -0
  37. package/src/render/menu.ts +16 -11
  38. package/src/render/picker.ts +51 -13
  39. package/src/render/segment-color.ts +74 -0
  40. package/src/segments/git.ts +389 -48
  41. package/src/template-engine/colors.ts +67 -45
  42. package/src/template-engine/engine.ts +11 -12
  43. package/src/themes/index.ts +1 -4
  44. package/src/themes/palette-resolvers.ts +22 -30
  45. package/src/themes/policy.ts +37 -16
@@ -0,0 +1,164 @@
1
+ // [LAW:one-type-per-behavior] An option domain is a NAME → members lookup,
2
+ // regardless of where the members come from. Before this module, "themes" /
3
+ // "styles" / "looks" were three special-cased branches wearing a closed
4
+ // TypeScript union (OptionSource) — a hardcoded list of legal domain NAMES
5
+ // with extra steps. There is exactly one domain concept: a `from` value is
6
+ // either an INLINE literal domain (an authored array — zero registration,
7
+ // zero engine edit) or the NAME of a domain resolved through this registry.
8
+ // Adding a new registry-backed domain is one `registerOptionDomain` call
9
+ // (data), never a new union member every consumer must re-match on.
10
+ //
11
+ // [LAW:no-shared-mutable-globals] `_GLOBAL_OPTION_DOMAINS` is shared mutable
12
+ // state with exactly one owner (this module) and one explicit API
13
+ // (registerOptionDomain / resolveOptionDomain / knownOptionDomainNames). It
14
+ // holds only domains whose members are legitimately PROCESS-lifetime static
15
+ // (themes, styles — module-init snapshots, same reasoning as
16
+ // template-engine/funcs.ts's THEMES_LIST/STYLES_LIST caches). A domain whose
17
+ // members vary PER CONFIG (the merged `looks:` block — two daemon render-cache
18
+ // entries can hold different looks blocks for different configs
19
+ // simultaneously) can never live here; it is threaded explicitly as
20
+ // `perConfigDomains`, the same way `lookNames` already is.
21
+ //
22
+ // [LAW:one-source-of-truth] This is the ONE place a domain name resolves to
23
+ // its members. render/action.ts (rendering options) and
24
+ // daemon/verbs/state-validators.ts (deriving the click gate) both call
25
+ // through here instead of each hand-rolling the themes/styles/looks branch —
26
+ // the rendered options and the derived gate cannot diverge because there is
27
+ // no second resolver.
28
+
29
+ import {
30
+ CHARSETS,
31
+ COLOR_COMPATIBILITIES,
32
+ listResolvablePaletteNames,
33
+ STRIP_STYLES,
34
+ } from "../themes/policy.js";
35
+ import { presetNames } from "./presets.js";
36
+
37
+ // [LAW:types-are-the-program] The authoring shape of a `set … from` value: a
38
+ // bare string names a domain (resolved through the registry or a per-config
39
+ // override); a non-empty array of strings IS the domain, inline, needing no
40
+ // name and no registration. Mirrors the `cycle` field's array shape — an
41
+ // author already knows this pattern.
42
+ export type OptionDomain = string | readonly string[];
43
+
44
+ export type OptionDomainResolver = () => readonly string[];
45
+
46
+ interface DomainEntry {
47
+ readonly permanent: boolean;
48
+ readonly resolve: OptionDomainResolver;
49
+ }
50
+
51
+ const _GLOBAL_OPTION_DOMAINS = new Map<string, DomainEntry>();
52
+
53
+ function registerBuiltinDomain(
54
+ name: string,
55
+ resolve: OptionDomainResolver,
56
+ ): void {
57
+ _GLOBAL_OPTION_DOMAINS.set(name, { permanent: true, resolve });
58
+ }
59
+
60
+ // [LAW:one-source-of-truth] "themes"/"styles" become ORDINARY registrations —
61
+ // the same registerOptionDomain any future caller uses — reading the same
62
+ // canonical lists the set-state validator and the `themes()`/`styles()`
63
+ // template bindings already consult (listResolvablePaletteNames/
64
+ // STRIP_STYLES). No special-cased branch remains anywhere downstream.
65
+ registerBuiltinDomain("themes", () => listResolvablePaletteNames());
66
+ registerBuiltinDomain("styles", () => STRIP_STYLES);
67
+ // [LAW:one-source-of-truth] Same shape as themes/styles: the exact consts
68
+ // the loader's own field validation and the render layer's glyph/color-depth
69
+ // dispatch already derive from (themes/policy.ts CHARSETS/COLOR_COMPATIBILITIES)
70
+ // — a menu drawing from these can never enumerate a value the render layer
71
+ // would reject.
72
+ registerBuiltinDomain("charsets", () => CHARSETS);
73
+ registerBuiltinDomain("colorCompatibilities", () => COLOR_COMPATIBILITIES);
74
+
75
+ // [LAW:no-silent-fallbacks] A built-in domain can never be re-claimed — a
76
+ // config or feature registering a custom domain named "themes" gets a loud
77
+ // load-time error, never a silent shadow of the real theme list. Registering
78
+ // returns a disposer (same shape as registerStateValidator) so a caller with
79
+ // a bounded lifetime (a test, a future per-feature domain) can clean up.
80
+ export function registerOptionDomain(
81
+ name: string,
82
+ resolve: OptionDomainResolver,
83
+ ): () => void {
84
+ const existing = _GLOBAL_OPTION_DOMAINS.get(name);
85
+ if (existing) {
86
+ throw new Error(
87
+ `registerOptionDomain: option domain "${name}" is already registered` +
88
+ (existing.permanent
89
+ ? " (a built-in domain — built-ins cannot be reclaimed)"
90
+ : ""),
91
+ );
92
+ }
93
+ _GLOBAL_OPTION_DOMAINS.set(name, { permanent: false, resolve });
94
+ let active = true;
95
+ return () => {
96
+ if (!active) return;
97
+ active = false;
98
+ const entry = _GLOBAL_OPTION_DOMAINS.get(name);
99
+ if (entry && !entry.permanent) _GLOBAL_OPTION_DOMAINS.delete(name);
100
+ };
101
+ }
102
+
103
+ // [LAW:one-source-of-truth] THE single construction of a config's per-config
104
+ // domain overrides — the domains whose members are declared IN the config
105
+ // rather than in the registry above: "looks" (the merged `looks:` block) and
106
+ // "presets" (the merged `presets:` block). cross-ref.ts (checking a `from` name
107
+ // resolves), state-validators.ts (deriving the click gate), and dsl/render.ts
108
+ // (compiling render-time options) each need this map; before this function they
109
+ // each rebuilt it independently, three sites that could silently drift if a
110
+ // future per-config domain were added to only some of them.
111
+ //
112
+ // [LAW:locality-or-seam] The parameter is the CONFIG, structurally typed to the
113
+ // blocks read here — not one positional record per domain. Presets were the
114
+ // second per-config domain, and adding them under the old `(looks)` signature
115
+ // would have rippled a new argument through all three call sites; under this
116
+ // one, a third domain is a single line HERE and nothing else moves. Structural
117
+ // (rather than importing DslConfig) so this leaf module still never imports
118
+ // dsl-types.ts — that would cycle through dsl-types.ts -> action.ts ->
119
+ // option-domain.ts (type-only, but still a cycle this module stays clear of,
120
+ // per [LAW:one-way-deps]).
121
+ export function perConfigDomainsFor(config: {
122
+ readonly looks: Readonly<Record<string, unknown>>;
123
+ readonly presets: Readonly<Record<string, unknown>>;
124
+ }): ReadonlyMap<string, readonly string[]> {
125
+ return new Map([
126
+ ["looks", Object.keys(config.looks)],
127
+ // [LAW:one-source-of-truth] Not `Object.keys` — the floor is selectable
128
+ // whether or not a config declares it, and presetNames is where that is
129
+ // stated (once, for the gate and the render alike).
130
+ ["presets", presetNames(config.presets)],
131
+ ]);
132
+ }
133
+
134
+ // [LAW:one-source-of-truth] The full set of names `from` may legally name for
135
+ // THIS config: every globally-registered domain plus this config's per-config
136
+ // overrides (currently just "looks"). Used both to resolve a name and to spell
137
+ // out the legal set in an unknown-domain error.
138
+ export function knownOptionDomainNames(
139
+ perConfigDomains: ReadonlyMap<string, readonly string[]>,
140
+ ): readonly string[] {
141
+ return [
142
+ ...new Set([..._GLOBAL_OPTION_DOMAINS.keys(), ...perConfigDomains.keys()]),
143
+ ];
144
+ }
145
+
146
+ // [LAW:dataflow-not-control-flow] One total resolution: an inline array IS
147
+ // its own domain (no lookup); a string is a NAME resolved first against this
148
+ // config's per-config overrides, then the global registry. A name matching
149
+ // neither is a genuine error — the loader's cross-ref pass already proved
150
+ // every `from` name resolves before this runs, so a miss here is a
151
+ // caller/wiring bug, not a config-authoring mistake.
152
+ export function resolveOptionDomain(
153
+ from: OptionDomain,
154
+ perConfigDomains: ReadonlyMap<string, readonly string[]>,
155
+ ): readonly string[] {
156
+ if (typeof from !== "string") return from;
157
+ const local = perConfigDomains.get(from);
158
+ if (local) return local;
159
+ const entry = _GLOBAL_OPTION_DOMAINS.get(from);
160
+ if (entry) return entry.resolve();
161
+ throw new Error(
162
+ `unknown option domain "${from}" (have: ${knownOptionDomainNames(perConfigDomains).join(", ")})`,
163
+ );
164
+ }
@@ -0,0 +1,156 @@
1
+ // [LAW:one-source-of-truth] The preset RESOLUTION seam: the three questions a
2
+ // render asks about presets — which one is active, what layout does it stage,
3
+ // what display globals does it carry — answered in one place, from one map.
4
+ //
5
+ // A preset is to configuration what a look is to a theme, and that is the
6
+ // implementation instruction, not an analogy: the selection rides the SAME
7
+ // per-config-member seam looks rides (effectiveMemberName in themes/policy.ts),
8
+ // the domain is threaded as DATA through the same perConfigDomainsFor the click
9
+ // gate and the rendered options both read, and an unknown name collapses to the
10
+ // same kind of always-present floor. Nothing here is parallel machinery
11
+ // [LAW:one-type-per-behavior].
12
+ //
13
+ // WHERE THE PRESET LAYER SITS — the five-layer precedence chain, documented in
14
+ // full in docs/interaction-authoring.md ("persist / reset"), which is the ONE
15
+ // place it is written down:
16
+ //
17
+ // bundled default < user config file < persisted overrides
18
+ // < ACTIVE PRESET < session pick
19
+ //
20
+ // The preset's position is forced by its lifetime, not chosen. Everything to
21
+ // its left is resolved once per RenderCache entry (an entry serves many
22
+ // sessions: the user file and the overrides file are both read in buildState);
23
+ // everything from the preset rightward is resolved per render, because the pick
24
+ // is per session. The chain is therefore monotonic in "how late is this
25
+ // decided", which is why a preset overrides a persisted default (switching to a
26
+ // "compact" arrangement must actually change padding, even for a user who once
27
+ // persisted a padding they liked) while a session's own click still wins over
28
+ // the preset (a click is later still).
29
+
30
+ // [LAW:one-way-deps] Type-only, so nothing is emitted and option-domain.ts (a
31
+ // leaf that deliberately never imports dsl-types.ts) can import PRESET_NAMES
32
+ // from here without a runtime cycle.
33
+ import type {
34
+ DslConfig,
35
+ Globals,
36
+ LayoutNode,
37
+ PresetDecl,
38
+ } from "./dsl-types.js";
39
+ import { effectiveMemberName } from "../themes/policy.js";
40
+
41
+ // [LAW:one-source-of-truth] The floor preset's name, spelled once. `looks` has
42
+ // `"none"` (the identity adaptation); presets have `"default"` (the identity
43
+ // fragment — the empty PresetDecl, i.e. the config's own root and globals
44
+ // unchanged). The bundled default declares it and merge-by-name cannot remove
45
+ // it, so every merged DslConfig carries it by construction.
46
+ export const PRESET_FLOOR = "default";
47
+
48
+ // [LAW:dataflow-not-control-flow] The floor's fragment is the EMPTY one — no
49
+ // alternative root, no globals delta — which is exactly what "no preset chosen"
50
+ // already means. It is a value the lookup below starts from rather than a case
51
+ // the lookup handles, so the floor resolves whether or not any config declares
52
+ // it. The bundled default declares `default: {}` for a different job: to put the
53
+ // floor in the DOMAIN, so a `{{ menu }}` lists it and the derived click gate
54
+ // admits a click that returns to it. Domain membership and resolvability are two
55
+ // guarantees, and this one does not lean on the other.
56
+ const FLOOR_FRAGMENT: PresetDecl = {};
57
+
58
+ // [LAW:one-source-of-truth] THE preset domain: every arrangement selectable in
59
+ // this config — the declared alternatives with the floor always among them. The
60
+ // three readers that each need "the preset names" (perConfigDomainsFor, which
61
+ // feeds the rendered options AND the derived click gate; registerDslConfig's
62
+ // per-preset compile; the `presets` template binding) call this rather than
63
+ // spelling `Object.keys(config.presets)` themselves, so the menu you can see,
64
+ // the click the wire admits, and the layouts that were compiled are the same
65
+ // set by construction — a click returning to the floor cannot be rejected by a
66
+ // gate that forgot the floor was selectable.
67
+ //
68
+ // This is deliberately STRONGER than the looks seam it otherwise mirrors, where
69
+ // "none" is in the domain only because the bundled stdlib ships it. The floor's
70
+ // membership is a fact about the resolution, not about any config, so it is
71
+ // stated here once rather than depending on a merge going right.
72
+ export function presetNames(
73
+ presets: Readonly<Record<string, unknown>>,
74
+ ): readonly string[] {
75
+ return [...new Set([PRESET_FLOOR, ...Object.keys(presets)])];
76
+ }
77
+
78
+ // [LAW:one-type-per-behavior] The preset domain's instance of the shared
79
+ // per-config-member resolver — the same call shape effectiveLookName makes, one
80
+ // dimension over. A stale or deleted name collapses to PRESET_FLOOR rather than
81
+ // throwing, and the caller publishes this RESOLVED name as `preset.effective`
82
+ // so the bar's label and the bar's layout can never disagree
83
+ // [LAW:no-silent-failure].
84
+ export function effectivePresetName(
85
+ sessionPreset: string | null,
86
+ globalsPreset: string | undefined,
87
+ declaredPresets: Readonly<Record<string, PresetDecl>>,
88
+ ): string {
89
+ return effectiveMemberName(
90
+ sessionPreset,
91
+ globalsPreset,
92
+ PRESET_FLOOR,
93
+ declaredPresets,
94
+ );
95
+ }
96
+
97
+ // [LAW:single-enforcer] The one place an effective preset NAME becomes the
98
+ // fragment a render reads — a declared preset, or the floor's identity fragment
99
+ // layered under them so the floor never depends on being declared. By the time
100
+ // a name reaches here it must be one of those: effectivePresetName collapses
101
+ // unknown names to the floor.
102
+ // [LAW:no-defensive-null-guards] the throw is the loud failure for that broken
103
+ // invariant (a caller that skipped the resolution and passed a raw session
104
+ // string), never a silent empty-fragment fallback that would render one
105
+ // arrangement while the bar's label named another — the exact contract
106
+ // lookKeyByName holds for looks.
107
+ export function presetByName(
108
+ presets: Readonly<Record<string, PresetDecl>>,
109
+ name: string,
110
+ ): PresetDecl {
111
+ const preset = { [PRESET_FLOOR]: FLOOR_FRAGMENT, ...presets }[name];
112
+ if (preset === undefined) {
113
+ throw new Error(
114
+ `Preset "${name}" is not declared in this config — effectivePresetName ` +
115
+ `collapses unknown names to "${PRESET_FLOOR}", which always resolves; ` +
116
+ `a miss here means a raw name reached this function without going ` +
117
+ `through that resolution`,
118
+ );
119
+ }
120
+ return preset;
121
+ }
122
+
123
+ // A preset's layout AND the config path that layout was authored at, as a total
124
+ // function of the name: a preset that declares no `root` stages the config's own
125
+ // root, which lives at `root` and not under this preset's name.
126
+ //
127
+ // [LAW:one-source-of-truth] Both halves come from ONE decision on purpose. The
128
+ // fallback used to be resolved here while the diagnostic path was spelled
129
+ // separately at the compile site as `presets.<name>.root`, so the two disagreed
130
+ // for exactly the configs that never opted into presets at all: a plain config
131
+ // with no `presets:` block reported its own root's template errors under
132
+ // `presets.default.root`, naming a node the author never wrote. Returning the
133
+ // tree together with where it came from makes that drift unrepresentable rather
134
+ // than merely fixed [FRAMING:representation].
135
+ export function presetRoot(
136
+ config: DslConfig,
137
+ name: string,
138
+ ): { readonly node: LayoutNode; readonly path: string } {
139
+ // [LAW:dataflow-not-control-flow] A projection returning DATA, not a branch
140
+ // around an operation: both arms yield the same shape, and the discriminator
141
+ // (did this preset declare a root?) is a fact the fragment already carries.
142
+ const own = presetByName(config.presets, name).root;
143
+ return own === undefined
144
+ ? { node: config.root, path: "root" }
145
+ : { node: own, path: `presets.${name}.root` };
146
+ }
147
+
148
+ // [LAW:dataflow-not-control-flow] A preset's display globals, as a total
149
+ // function of the name: the config's globals with the preset's shallow-merged
150
+ // over them, per field — the SAME per-field cascade mergeWithDefault applies to
151
+ // `globals` everywhere else in the loader, so a preset naming `padding` says
152
+ // nothing about `charset`. The empty fragment yields the config's globals
153
+ // unchanged, so again no floor-shaped branch.
154
+ export function presetGlobals(config: DslConfig, name: string): Globals {
155
+ return { ...config.globals, ...presetByName(config.presets, name).globals };
156
+ }
@@ -363,7 +363,7 @@ export class GitDataProvider extends GitService {
363
363
  ): Promise<Outcome<PullRequest>> {
364
364
  // [LAW:no-silent-failure] A `failed` remote read (git couldn't run) must
365
365
  // surface; only `absent` (no remote configured) means "no forge PR".
366
- const remote = await this.inner.getRemoteOriginUrl(repoRoot);
366
+ const remote = await this.inner.getRepoRemoteUrl(repoRoot);
367
367
  if (remote.kind === "failed") return remote;
368
368
  if (remote.kind === "absent") return ABSENT;
369
369
  const remoteUrl = remote.value;
@@ -8,6 +8,8 @@ import {
8
8
  resolveDslConfigPath,
9
9
  dslConfigCandidatePaths,
10
10
  detectConfigCollisions,
11
+ mergeWithDefault,
12
+ applySegmentPaletteOverrides,
11
13
  ConfigError,
12
14
  } from "../../config/dsl-loader.js";
13
15
  import type { ValidatedConfig } from "../../config/dsl-types.js";
@@ -17,6 +19,12 @@ import {
17
19
  deriveActionValidators,
18
20
  registerStateValidator,
19
21
  } from "../verbs/state-validators.js";
22
+ import {
23
+ deriveConfigActionValidators,
24
+ registerConfigValidator,
25
+ } from "../verbs/config-validators.js";
26
+ import { loadOverrides } from "../config-overrides-store.js";
27
+ import { configOverridesPath } from "../paths.js";
20
28
  import { VariableStore } from "../../var-system/store.js";
21
29
  import { SourceRegistry } from "../../var-system/sources.js";
22
30
  import type { GitDataProvider } from "./git.js";
@@ -277,7 +285,39 @@ export class RenderCache {
277
285
  resolvedPath,
278
286
  DEFAULT_DSL_CONFIG,
279
287
  );
280
- const config = validateConfig(merged, resolvedPath ?? "<default>", source);
288
+ // [LAW:one-source-of-truth] The persistent config-overrides layer
289
+ // (candybar-config-engine-71o.2) is a SECOND application of the SAME
290
+ // mergeWithDefault cascade the user file already went through — no new
291
+ // merge semantics, just one more layer at bundled-default < user-file <
292
+ // overrides precedence (a `persist` write changes the DEFAULT; a session
293
+ // pick still overrides it per-session via effective* resolution,
294
+ // unchanged). Always applied, even when the overrides file is empty —
295
+ // an empty overrides object merges as a no-op, so there is no
296
+ // "has overrides?" branch [LAW:dataflow-not-control-flow]. One
297
+ // loadOverrides read serves BOTH halves below (globals + segment-palette)
298
+ // — the overrides file backs two different merge shapes, not two reads.
299
+ const overrides = loadOverrides(configOverridesPath(), dlog);
300
+ const withGlobalsOverrides = mergeWithDefault(
301
+ { globals: overrides.globals },
302
+ merged,
303
+ );
304
+ // [LAW:one-source-of-truth] The segment-scoped half of the SAME overrides
305
+ // file (candybar-config-engine-71o.6) — a later, narrower merge step, not
306
+ // a second override layer: mergeWithDefault's `segments` cascade replaces
307
+ // a named segment WHOLESALE, so a one-field palette override rides its
308
+ // own overlay (applySegmentPaletteOverrides) against the already-merged
309
+ // config instead, patching `palette` without dropping the segment's other
310
+ // fields. Order versus the globals merge above doesn't matter — the two
311
+ // touch disjoint parts of the config (`globals` vs `segments[name]`).
312
+ const withOverrides = applySegmentPaletteOverrides(
313
+ withGlobalsOverrides,
314
+ overrides.segmentPalette,
315
+ );
316
+ const config = validateConfig(
317
+ withOverrides,
318
+ resolvedPath ?? "<default>",
319
+ source,
320
+ );
281
321
 
282
322
  const store = new VariableStore();
283
323
  // [LAW:single-enforcer] Inject the daemon's shared GitDataProvider so
@@ -321,6 +361,14 @@ export class RenderCache {
321
361
  for (const { key, spec } of deriveActionValidators(config)) {
322
362
  validatorDisposers.push(registerStateValidator(key, spec));
323
363
  }
364
+ // [LAW:one-source-of-truth] The `persist` action table's twin
365
+ // derivation, registered through the SAME dispose-before-swap
366
+ // transaction — a config's persistent-config-writable-key surface
367
+ // lives and dies with this cache entry exactly like its SessionState
368
+ // surface does.
369
+ for (const { key, spec } of deriveConfigActionValidators(config)) {
370
+ validatorDisposers.push(registerConfigValidator(key, spec));
371
+ }
324
372
  } catch (err) {
325
373
  for (const dispose of validatorDisposers) dispose();
326
374
  registry.dispose();
@@ -331,7 +379,7 @@ export class RenderCache {
331
379
  // serves many sessions, but the effective theme is per-session SessionState;
332
380
  // freezing the palette per entry would let the rendered colors diverge from
333
381
  // the session's chosen theme. The server resolves basePalette per render
334
- // from the effective theme (resolverForThemeName ∘ effectiveThemeName).
382
+ // from the effective theme (paletteForThemeName ∘ effectiveThemeName).
335
383
  return {
336
384
  config,
337
385
  store,
@@ -380,11 +428,17 @@ export class RenderCache {
380
428
  // covers the exact same set of paths the next reload would consult — a
381
429
  // `--config` override collapses to one candidate; absent, the precedence
382
430
  // chain unfolds in full.
383
- const candidates = dslConfigCandidatePaths(
384
- entry.projectDir,
385
- entry.cwd,
386
- entry.configFile,
387
- );
431
+ //
432
+ // [LAW:dataflow-not-control-flow] configOverridesPath() rides the SAME
433
+ // candidate list, not a second watch branch: "reload rides the existing
434
+ // watcher" (candybar-config-engine-71o.2) means a persistent config write
435
+ // is just one more file in the resolution chain the loop below already
436
+ // handles uniformly (existence-gated, watched via its parent dir so the
437
+ // file's first-ever creation also triggers reload).
438
+ const candidates = [
439
+ ...dslConfigCandidatePaths(entry.projectDir, entry.cwd, entry.configFile),
440
+ configOverridesPath(),
441
+ ];
388
442
  const dirSet = new Map<string, Set<string>>();
389
443
  for (const candidate of candidates) {
390
444
  const dir = path.dirname(candidate);