myco:single-source-of-truth-refactoring · git:20260611.cc74104 · 2026-06-11 · sha256 3aa2c40fb0dbd5f1
myco:single-source-of-truth-refactoring git:20260611.cc74104A
Immutable. This exact content is served forever at /api/v1/blob/3aa2c40fb0dbd5f1.
---
name: myco:single-source-of-truth-refactoring
description: |
Apply this skill whenever you encounter logic, values, or transformations
duplicated across multiple files in the Myco codebase — even if the user
doesn't explicitly ask for a refactor. Covers five recurring SSoT patterns:
(1) extracting shared read projections to read-projections.ts, (2)
centralizing provider capabilities in context-windows.ts, (3) replacing
magic strings with named constants in constants.ts, (4) creating semantic
wrapper functions in config/loader.ts and settings-merge.ts, and (5) adding
path properties to service state objects (DaemonServiceState). Also covers
two critical violation classes to detect during code review: parallel
ownership predicates (is-this-mine? checks duplicated across files) and
daemon variant dispatch (isDevServiceMode() ternaries at multiple call
sites). The root discipline is: name the thing, own it in one place, let
consumers reference it.
managed_by: myco
user-invocable: true
allowed-tools: Read, Edit, Write, Bash, Grep, Glob
---
# Single-Source-of-Truth Refactoring
The Myco codebase has a recurring architectural smell: logic, values, or transformations scattered across multiple files with no canonical owner. When two consumers independently compute or verify the same thing, they drift — silently. CI stays green while production ships divergent behavior. This skill documents five fix patterns and two violation classes discovered across six independent sessions of Myco development.
**Core discipline:** Name the thing, own it in one place, let consumers reference it.
## Prerequisites
- Identify the canonical home before touching code: is there already an obvious module that *should* own this value? (`constants.ts` for named scalars, `context-windows.ts` for provider capability defaults, service state objects for related file paths, `grove/paths.ts` for daemon-scoping decisions)
- Grep for all existing usages before extracting so no call site is missed:
```bash
grep -rn "the-pattern-or-literal" packages/
```
- Confirm tests cover the scattered behavior before deleting any duplicate path
## Procedure A: Detecting SSoT Violations During Code Review
Apply these three checks when reviewing any PR that touches shared infrastructure. The violations surface as runtime failures, not compile errors — catching them here saves production incidents.
### Check 1 — Re-computation of an already-available value
Flag any function that re-derives a value already available from a canonical source in the same call chain.
Ask: "Does a helper already exist that produces this?" If yes, the consumer should call the canonical source, not re-derive it inline.
**Example trigger:** A file computing `isDevServiceMode() ? 'service-dev' : 'service'` inline at its own call site when `currentDaemonVariant()` (in `grove/paths.ts`) already exists and is co-located with `isDevServiceMode()`.
### Check 2 — Parallel ownership predicates
Flag when two or more code paths independently answer "does this file/content belong to Myco?" using different checks.
Symptoms:
- One path uses a regex, another uses substring matching
- The match strings in each check don't exactly agree
- A file could pass one check and fail the other
**Real example (PR #355):** `installer.ts:1278` had regex `/\bmyco-run\.cjs\b|\bmyco-hook\.cjs\b|\blauncher\.cjs\b/` while `install-helpers.ts:isMycoHookCommand` used different substring checks. When they diverged, uninstall silently failed on files that only one path recognized as Myco-owned.
**Fix signal:** Consolidate into a single helper with a named constant for the match strings (see Procedure E2).
### Check 3 — Variant dispatch at multiple sites
Flag `isDevServiceMode() ? 'service-dev' : 'service'` (or equivalent daemon-scoping logic) appearing at more than one call site. This is a daemon-boundary decision — getting it wrong risks a cross-daemon Grove leak where a migration walker operates on the wrong daemon's projects.
**Canonical home:** `grove/paths.ts` → `currentDaemonVariant()`. All 5 previously duplicated sites (`migration-walker.ts:82`, `remove.ts:108-110`, `startup-pauses.ts:60`, `backup.ts:200`, `buffer-location.ts:78`) now delegate here.
## Procedure B: Extracting Shared Read Projections
**When:** Two or more consumers (agent tools, harness, API, context queries) independently define the same lean field shape for a database entity.
**Smell:** The same "pick these fields from a batch/session/spore" logic exists in both `read-tools.ts` and `context-queries.ts`. When they drift, agents receive a different field shape than what the harness validates against — silently.
**Fix:**
1. Create named projection functions in `read-projections.ts`:
```typescript
export function projectBatchForAgent(b: BatchRow, options: ProjectionOptions = {}) {
return { user_prompt: b.user_prompt, response_summary: b.response_summary };
}
export function projectSessionForAgent(s: SessionRow, options: ProjectionOptions = {}) {
return { title: s.title, summary: s.summary, /* counts */ };
}
export function projectSporeForAgent(sp: SporeRow, options: ProjectionOptions = {}) {
return { observation_type: sp.observation_type, title: sp.title, importance: sp.importance };
}
// projectEntityForAgent(), projectEdgeForAgent() follow the same pattern
```
2. Replace inline field picks in every consumer (`read-tools.ts`, `context-queries.ts`, any API layer) with calls to the shared projection functions.
3. Verify: a field shape change in `projectSessionForAgent()` now propagates to all consumers automatically — no grep required to find missed usages.
**Rule:** Any data transformation used by multiple consumers (tools + harness + API) belongs in a shared projection layer, not duplicated at each call site.
## Procedure C: Centralizing Provider Capability Defaults
**When:** Provider-specific metadata (context window sizes, capability flags) appears as switch statements or hardcoded constants in multiple files.
**Smell:** `run-accounting.ts`, `executor-state.ts`, and `openai.ts` each had independent `switch(provider)` blocks returning context window sizes. Adding a new provider required changes in all three.
**Fix:**
1. Define named constants in `context-windows.ts`:
```typescript
/** Inferred frontier-model context window when the provider does not expose one. */
export const DEFAULT_FRONTIER_CONTEXT_WINDOW_TOKENS = 200_000;
/** Inferred OpenAI-compatible cloud context window when the provider does not expose one. */
export const DEFAULT_COMPATIBLE_CONTEXT_WINDOW_TOKENS = 128_000;
/** Default context window Myco applies for local agent runs when no override is set. */
export const DEFAULT_LOCAL_AGENT_CONTEXT_WINDOW_TOKENS = 32_768;
```
2. Update `run-accounting.ts`'s `resolveContextWindow()` to import and use these constants as fallback defaults rather than repeating literals inline.
3. Remove hardcoded switch blocks from `run-accounting.ts`, `executor-state.ts`, `openai.ts`. Capability queries now route through the centralized constants.
**Rule:** Provider capability defaults belong in `context-windows.ts`. Adding a new provider's default context window is a single-line edit; without this file, it's a multi-file grep.
## Procedure D: Replacing Magic Strings and Inline Strategy Config
Two related smells — both resolved by extracting a named owner. Handle them together when they co-occur.
### D1 — Magic string literals
**When:** The same string literal (a fallback name, filename, event key, variant tag) appears in 3+ code paths.
**Fix:**
1. Add a named constant to `constants.ts`:
```typescript
// constants.ts
export const DEFAULT_SYMBIONT_NAME = 'claude-code';
```
2. Replace all occurrences — use grep to find every call site including interpolated forms:
```bash
grep -rn "DEFAULT_SYMBIONT_NAME\|'claude-code'" packages/myco/src/
```
3. Add or verify a regression test for the fallback path (model: `manifest-schema.test.ts`).
Because changing the string now requires touching `constants.ts` once, a literal change is a single diff line. Without the constant, grep-and-hope misses aliased or interpolated uses.
### D2 — Inline strategy configuration
**When:** `deepMerge(a, b, { arrayStrategy: 'replace' })` (or similar option objects embedding semantic decisions) appears at multiple call sites.
**Fix — create named semantic wrappers in the appropriate module file:**
- Config overlay wrapper in `config/loader.ts`:
```typescript
/** Config overlay uses replace semantics: arrays in source overwrite arrays in target. */
export function deepMergeConfig<T extends Record<string, unknown>>(target: T, source: Partial<T>): T {
return deepMerge(target, source, { arrayStrategy: 'replace' });
}
```
- Settings merge wrapper in `symbionts/settings-merge.ts`:
```typescript
/** Symbiont settings merge uses union semantics: arrays are concatenated and deduped. */
export function deepMergeSettings(
target: Record<string, unknown>,
source: Record<string, unknown>,
): Record<string, unknown> {
return deepMerge(target, source, { arrayStrategy: 'union' });
}
```
Both wrappers call the primitive `deepMerge()` from `utils/deep-merge.ts` with the appropriate strategy. Each wrapper lives in its domain module — not in the utility itself.
Named wrappers win over inlining because:
- `deepMergeConfig` signals intent at the call site — readers understand *this is config overlay* without deciphering the options object
- Changing strategy for all config merges is a single-line edit in the wrapper, not a grep across call sites
- New developers reading `deepMergeConfig` get context for free; inline option literals require knowing the semantics externally
Trade-off: 2–4 lines of wrapper noise. Semantic clarity wins for shared infrastructure.
### D3 — Config write path canonicalization
**When:** Multiple callers independently load a config file, spread its contents, mutate a field, and re-save — the load-spread-save anti-pattern.
**Fix:** Use `updateTierConfigRaw` (in `config/loader.ts`) as the single canonical write path for machine/grove config changes:
```typescript
// Before: load-spread-save anti-pattern (duplicates merge logic, error-prone)
const existing = await loadMachineConfig(vaultDir);
await saveMachineConfig(vaultDir, { ...existing, someField: newValue });
// After: canonical write path
await updateTierConfigRaw({ kind: 'machine', vaultDir }, (raw) => {
raw.someField = newValue;
return raw;
});
```
`updateTierConfigRaw` owns the read-modify-write cycle. Internally it relies on the `GROVE_TIER_FIELDS` constant (in `config/loader.ts`) as the shared definition of which fields belong at the grove tier — the same constant consumed by strip, retain, and lift operations so those three code paths can never independently drift.
### D4 — Shared extract handler and UI error string consolidation
**When:** Multiple API route handlers share the same validation + update logic, or UI components independently convert error values to strings.
**Config handler consolidation:** If two PUT handlers differ only in which config tier they write, extract the shared logic into a typed helper. The `handlePutTierConfig<TConfig>(body, options)` pattern in the config API is the canonical example — each endpoint delegates to the single function parameterized by tier kind:
```typescript
// Grove config PUT
return handlePutTierConfig<GroveConfig>(body, { kind: 'grove', groveId: req.groveId });
// Machine config PUT
return handlePutTierConfig<MachineConfig>(body, { kind: 'machine' });
```
**UI error string consolidation:** If components independently do `err instanceof Error ? err.message : String(err)` or equivalent, replace with the canonical `errorMessage(err)` helper. This function consolidates 12+ ad-hoc error-to-string patterns that existed across UI components. Any new UI code that converts a caught error to a string should use it rather than inlining the pattern again.
## Procedure E: Centralizing Service State Paths and Ownership Predicates
### E1 — Service state path properties
**When:** A filename (e.g., `"daemon.lock"`) is reconstructed via `path.join(dir, filename)` at 3+ locations, potentially with inconsistent join styles.
**Fix:** Add the path as a first-class property to the service state object, following the existing pattern:
```typescript
// Before: magic strings reconstructed in 3 places
const lockFile = path.join(daemonDir, "daemon.lock");
// After: centralized in DaemonServiceState (daemon/service-state.ts)
export interface DaemonServiceState {
statePath: DaemonStatePath; // already existed — the precedent
lockPath: string; // new property, same discipline
// ...
}
// All call sites become:
const lockFile = daemonService.lockPath;
```
`DaemonServiceState` already owned `statePath`; `lockPath` follows the same precedent. Any service object managing multiple related files should expose all paths as properties — not reconstruct them at call sites.
**OS portability note:** `path.join` behavior differs across platforms. Centralizing the join ensures every consumer gets the same result regardless of OS.
### E2 — Canonical ownership predicates
**When:** The question "does this content/file belong to Myco?" is answered by different checks in different modules.
**Fix:**
1. Audit all checks across the codebase — do they agree on which content is Myco-owned? (They often don't. That's the bug.)
2. Consolidate into a single helper with a named constant for the match strings:
```typescript
// symbionts/install-helpers.ts
export const MYCO_LAUNCHER_SUBSTRINGS = [
'myco-run.cjs',
'myco-hook.cjs',
'launcher.cjs',
];
export function containsMycoLauncherReference(content: string): boolean {
return MYCO_LAUNCHER_SUBSTRINGS.some(s => content.includes(s));
}
```
3. Replace all divergent checks (`isMycoHookCommand`, both `isConfigured` branches, `uninstallPluginHookFile`) with calls to the single helper.
**Why canonical:** When the checks diverge, uninstall silently fails — one path skips a file it doesn't recognize as Myco-owned. This is the same bug class as Antigravity marker detection divergence. Ownership detection must be a single check, or the invariant is coincidentally satisfied rather than structurally enforced.
## Cross-Cutting Gotchas
**Violations fail silently, not loudly.** SSoT violations rarely throw. CI stays green. Divergence surfaces in production: a file isn't uninstalled, a Grove migrates to the wrong daemon, an agent sees a different field shape than the harness validates against. When you see unexpected runtime behavior that tests "should" cover, check for duplicate ownership logic before assuming a test gap.
**Verify all smell classes after any SSoT refactor.** Use this table as a checklist:
| Smell | Canonical fix | Canonical location |
|-------|---------------|--------------------|
| Projection logic in 2+ consumers | Named projections module | `read-projections.ts` |
| Provider capability defaults in switch blocks | Named constants | `context-windows.ts` |
| Magic string literals scattered | Named constant | `constants.ts` |
| Strategy config inline at call sites | Named semantic wrappers | `config/loader.ts`, `settings-merge.ts` |
| Config write via load-spread-save | `updateTierConfigRaw()` + `GROVE_TIER_FIELDS` | `config/loader.ts` |
| Duplicated PUT handler validation logic | Typed extract helper (e.g. `handlePutTierConfig<T>`) | domain API module |
| Ad-hoc error-to-string in UI | `errorMessage()` canonical helper | UI lib module |
| File paths reconstructed at call sites | Service state path properties | `daemon/service-state.ts` |
| Parallel ownership predicates | Single canonical helper | `symbionts/install-helpers.ts` |
| Variant dispatch at N sites | Single `currentDaemonVariant()` | `grove/paths.ts` |
**When you discover a new violation class:** don't only fix the instance — add it to this table. The skill grows with the codebase.