@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.
- package/dist/index.mjs +83 -76
- package/package.json +6 -6
- package/schema/cc-candybar.schema.json +193 -4
- package/src/check.ts +49 -27
- package/src/click/wire.ts +16 -0
- package/src/config/action.ts +57 -22
- package/src/config/default-dsl-config.ts +424 -55
- package/src/config/dsl-loader.ts +14 -2
- package/src/config/dsl-types.ts +59 -0
- package/src/config/loader/actions.ts +283 -109
- package/src/config/loader/cross-ref.ts +148 -28
- package/src/config/loader/emit-schema.ts +2 -0
- package/src/config/loader/globals.ts +118 -31
- package/src/config/loader/merge.ts +58 -1
- package/src/config/loader/persist-target.ts +32 -0
- package/src/config/loader/presets.ts +107 -0
- package/src/config/option-domain.ts +164 -0
- package/src/config/presets.ts +156 -0
- package/src/daemon/cache/git.ts +1 -1
- package/src/daemon/cache/render.ts +61 -7
- package/src/daemon/config-overrides-store.ts +322 -0
- package/src/daemon/paths.ts +10 -0
- package/src/daemon/render-payload.ts +84 -19
- package/src/daemon/server.ts +68 -55
- package/src/daemon/verbs/config-validators.ts +127 -0
- package/src/daemon/verbs/index.ts +129 -2
- package/src/daemon/verbs/state-validators.ts +98 -586
- package/src/daemon/verbs/validator-registry.ts +457 -0
- package/src/demo/dsl.ts +17 -10
- package/src/dsl/node-registry.ts +54 -39
- package/src/dsl/render.ts +158 -46
- package/src/help-text.ts +59 -0
- package/src/index.ts +2 -47
- package/src/install/index.ts +18 -4
- package/src/render/action.ts +155 -33
- package/src/render/active-segment.ts +78 -0
- package/src/render/menu.ts +16 -11
- package/src/render/picker.ts +51 -13
- package/src/render/segment-color.ts +74 -0
- package/src/segments/git.ts +389 -48
- package/src/template-engine/colors.ts +67 -45
- package/src/template-engine/engine.ts +11 -12
- package/src/themes/index.ts +1 -4
- package/src/themes/palette-resolvers.ts +22 -30
- 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
|
+
}
|
package/src/daemon/cache/git.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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);
|