@brainjar/cli 0.1.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.
@@ -0,0 +1,22 @@
1
+ # Git Discipline
2
+
3
+ This rule governs git behavior — commits, branches, and history.
4
+
5
+ ## Commit Workflow
6
+
7
+ - Stage only the relevant files. No blind `git add -A`.
8
+ - Write meaningful commit messages. Say what changed and why.
9
+ - One logical change per commit. Don't mix refactors with features.
10
+
11
+ ## Safety
12
+
13
+ - Don't commit secrets, credentials, or .env files. Ever.
14
+ - Don't amend published commits. Create a new commit instead.
15
+ - Never force push to main/master without explicit user approval.
16
+ - Don't skip hooks (--no-verify) unless the user explicitly asks.
17
+ - When in doubt about a destructive git operation, ask first.
18
+
19
+ ## Branches
20
+
21
+ - Don't delete branches without confirming they're merged or abandoned.
22
+ - Don't switch branches with uncommitted changes — stash or commit first.
@@ -0,0 +1,26 @@
1
+ # Security
2
+
3
+ This rule enforces secure coding practices.
4
+
5
+ ## Secrets
6
+
7
+ - Never commit credentials, API keys, tokens, or .env files.
8
+ - If you encounter hardcoded secrets in the codebase, flag them immediately.
9
+ - Use environment variables or secret managers for sensitive values.
10
+
11
+ ## Input Boundaries
12
+
13
+ - Validate and sanitize all external input — user input, API responses, file reads.
14
+ - Don't trust data from outside the system boundary.
15
+ - Use parameterized queries. Never interpolate user input into SQL or shell commands.
16
+
17
+ ## Common Vulnerabilities
18
+
19
+ - Watch for injection: SQL, command, XSS, template injection.
20
+ - Don't disable security features (CORS, CSRF, auth checks) to "make it work."
21
+ - Prefer allowlists over denylists for validation.
22
+
23
+ ## Dependencies
24
+
25
+ - Flag known-vulnerable dependencies if you notice them.
26
+ - Don't add dependencies without confirming with the user first.
@@ -0,0 +1,24 @@
1
+ # Craftsman
2
+
3
+ Quality work, clearly communicated.
4
+
5
+ ## Voice
6
+
7
+ - Clear and direct. Say what you mean without padding.
8
+ - Match the user's pace. Brief questions get brief answers. Complex problems get thorough treatment.
9
+ - State conclusions first, then reasoning if needed. Don't bury the answer.
10
+ - Be concrete. Examples over abstractions, specifics over generalities.
11
+
12
+ ## Character
13
+
14
+ - Honest about tradeoffs. Every choice has a cost — surface it.
15
+ - Admit uncertainty. "I'm not sure" is better than a confident wrong answer.
16
+ - Respect the user's time. Don't repeat what they already know.
17
+ - Take ownership. If you broke something, say so and fix it.
18
+
19
+ ## Standards
20
+
21
+ - Ship working code. Compiles, passes tests, handles errors.
22
+ - Finish what you start. No stubs, no TODOs, no "left as an exercise."
23
+ - Read before writing. Understand existing code before changing it.
24
+ - Leave things better than you found them — but only the things you're touching.
@@ -0,0 +1,19 @@
1
+ ---
2
+ tags:
3
+ - persona
4
+ rules:
5
+ - default
6
+ ---
7
+
8
+ # {{title}}
9
+
10
+ One-line description of this persona.
11
+
12
+ ## Direct mode
13
+ -
14
+
15
+ ## Subagent mode
16
+ -
17
+
18
+ ## Always
19
+ -
@@ -0,0 +1,11 @@
1
+ ---
2
+ tags:
3
+ - rule
4
+ ---
5
+
6
+ # {{title}}
7
+
8
+ Describe what this rule enforces and why.
9
+
10
+ ## Constraints
11
+ -
@@ -0,0 +1,20 @@
1
+ ---
2
+ tags:
3
+ - soul
4
+ ---
5
+
6
+ # {{title}}
7
+
8
+ Describe the voice, character, and standards this soul embodies.
9
+
10
+ ## Voice
11
+
12
+ -
13
+
14
+ ## Character
15
+
16
+ -
17
+
18
+ ## Standards
19
+
20
+ -
package/src/seeds.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { mkdir, readdir, writeFile, copyFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { paths } from './paths.js'
4
+
5
+ const SEEDS_DIR = join(import.meta.dir, 'seeds')
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Obsidian vault configuration
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function obsidianAppearanceConfig() {
12
+ return JSON.stringify({
13
+ accentColor: '',
14
+ baseFontSize: 16,
15
+ }, null, 2)
16
+ }
17
+
18
+ /**
19
+ * Obsidian file-explorer exclusion via userIgnoreFilters.
20
+ * Excludes private/state files from the vault file explorer.
21
+ */
22
+ function obsidianAppConfigWithExclusions() {
23
+ return JSON.stringify({
24
+ showLineNumber: true,
25
+ strictLineBreaks: true,
26
+ useMarkdownLinks: false,
27
+ alwaysUpdateLinks: true,
28
+ userIgnoreFilters: [
29
+ 'identities/',
30
+ 'state.yaml',
31
+ '.session',
32
+ '.gitignore',
33
+ ],
34
+ }, null, 2)
35
+ }
36
+
37
+ function obsidianCorePlugins() {
38
+ return JSON.stringify({
39
+ 'file-explorer': true,
40
+ 'global-search': true,
41
+ 'graph': true,
42
+ 'tag-pane': true,
43
+ 'templates': true,
44
+ 'outline': true,
45
+ 'editor-status': true,
46
+ 'starred': true,
47
+ 'command-palette': true,
48
+ 'markdown-importer': false,
49
+ 'word-count': true,
50
+ }, null, 2)
51
+ }
52
+
53
+ function obsidianTemplatesConfig() {
54
+ return JSON.stringify({
55
+ folder: 'templates',
56
+ }, null, 2)
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Seed functions
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** Copy a seed .md file from src/seeds/ to a target path. */
64
+ async function copySeed(seedRelPath: string, destPath: string) {
65
+ await copyFile(join(SEEDS_DIR, seedRelPath), destPath)
66
+ }
67
+
68
+ /** Seed the default rule pack — the baseline every persona references */
69
+ export async function seedDefaultRule(rulesDir: string) {
70
+ const defaultDir = join(rulesDir, 'default')
71
+ await mkdir(defaultDir, { recursive: true })
72
+
73
+ const seedDir = join(SEEDS_DIR, 'rules', 'default')
74
+ const files = await readdir(seedDir)
75
+ await Promise.all(
76
+ files.filter(f => f.endsWith('.md')).map(f =>
77
+ copySeed(join('rules', 'default', f), join(defaultDir, f))
78
+ )
79
+ )
80
+ }
81
+
82
+ /** Seed starter content: soul, personas, and rules */
83
+ export async function seedDefaults() {
84
+ await Promise.all([
85
+ // Soul
86
+ copySeed('souls/craftsman.md', join(paths.souls, 'craftsman.md')),
87
+
88
+ // Personas
89
+ copySeed('personas/engineer.md', join(paths.personas, 'engineer.md')),
90
+ copySeed('personas/planner.md', join(paths.personas, 'planner.md')),
91
+ copySeed('personas/reviewer.md', join(paths.personas, 'reviewer.md')),
92
+
93
+ // Rules
94
+ copySeed('rules/git-discipline.md', join(paths.rules, 'git-discipline.md')),
95
+ copySeed('rules/security.md', join(paths.rules, 'security.md')),
96
+ ])
97
+ }
98
+
99
+ /** Set up ~/.brainjar/ as an Obsidian vault */
100
+ export async function initObsidian(brainjarDir: string) {
101
+ const obsidianDir = join(brainjarDir, '.obsidian')
102
+ const templatesDir = join(brainjarDir, 'templates')
103
+
104
+ await mkdir(obsidianDir, { recursive: true })
105
+ await mkdir(templatesDir, { recursive: true })
106
+
107
+ await Promise.all([
108
+ writeFile(join(obsidianDir, 'app.json'), obsidianAppConfigWithExclusions()),
109
+ writeFile(join(obsidianDir, 'appearance.json'), obsidianAppearanceConfig()),
110
+ writeFile(join(obsidianDir, 'core-plugins.json'), obsidianCorePlugins()),
111
+ writeFile(join(obsidianDir, 'templates.json'), obsidianTemplatesConfig()),
112
+ copySeed('templates/soul.md', join(templatesDir, 'soul.md')),
113
+ copySeed('templates/persona.md', join(templatesDir, 'persona.md')),
114
+ copySeed('templates/rule.md', join(templatesDir, 'rule.md')),
115
+ ])
116
+ }
package/src/state.ts ADDED
@@ -0,0 +1,414 @@
1
+ import { readFile, writeFile, readdir, access, rename, mkdir, rm, stat } from 'node:fs/promises'
2
+ import { join, basename } from 'node:path'
3
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
+ import { getBrainjarDir, getLocalDir, paths } from './paths.js'
5
+
6
+ const SLUG_RE = /^[a-zA-Z0-9_-]+$/
7
+
8
+ /** Normalize a layer name: strip .md extension if present, then validate. */
9
+ export function normalizeSlug(value: string, label: string): string {
10
+ const slug = value.endsWith('.md') ? value.slice(0, -3) : value
11
+ if (!SLUG_RE.test(slug)) {
12
+ throw new Error(
13
+ `Invalid ${label}: "${value}". Names must contain only letters, numbers, hyphens, and underscores.`
14
+ )
15
+ }
16
+ return slug
17
+ }
18
+
19
+ /**
20
+ * Resolve a single rule's content: validate slug, try directory (sorted .md files),
21
+ * fall back to single .md file. Pushes warnings for invalid names, empty dirs, missing rules.
22
+ * Returns an array of trimmed content strings.
23
+ */
24
+ export async function resolveRuleContent(rule: string, warnings: string[]): Promise<string[]> {
25
+ let safe: string
26
+ try {
27
+ safe = normalizeSlug(rule, 'rule')
28
+ } catch {
29
+ warnings.push(`Rule "${rule}" has an invalid name — skipped`)
30
+ return []
31
+ }
32
+
33
+ const rulePath = join(paths.rules, safe)
34
+
35
+ // Try directory first
36
+ try {
37
+ const files = await readdir(rulePath)
38
+ const mdFiles = files.filter(f => f.endsWith('.md')).sort()
39
+ if (mdFiles.length === 0) {
40
+ warnings.push(`Rule "${rule}" directory exists but contains no .md files`)
41
+ }
42
+ const contents: string[] = []
43
+ for (const file of mdFiles) {
44
+ const content = await readFile(join(rulePath, file), 'utf-8')
45
+ contents.push(content.trim())
46
+ }
47
+ return contents
48
+ } catch {
49
+ // Not a directory — fall back to single .md file
50
+ }
51
+
52
+ try {
53
+ const content = await readFile(`${rulePath}.md`, 'utf-8')
54
+ return [content.trim()]
55
+ } catch {}
56
+
57
+ warnings.push(`Rule "${rule}" not found in ${paths.rules}`)
58
+ return []
59
+ }
60
+
61
+ export async function listAvailableRules(): Promise<string[]> {
62
+ const entries = await readdir(paths.rules, { withFileTypes: true }).catch(() => [])
63
+ const available: string[] = []
64
+ for (const entry of entries) {
65
+ if (entry.isDirectory()) {
66
+ available.push(entry.name)
67
+ } else if (entry.name.endsWith('.md')) {
68
+ available.push(basename(entry.name, '.md'))
69
+ }
70
+ }
71
+ return available.sort()
72
+ }
73
+
74
+ export interface State {
75
+ backend: string | null
76
+ identity: string | null
77
+ soul: string | null
78
+ persona: string | null
79
+ rules: string[]
80
+ }
81
+
82
+ const DEFAULT_STATE: State = { backend: null, identity: null, soul: null, persona: null, rules: [] }
83
+
84
+ export async function requireBrainjarDir(): Promise<void> {
85
+ try {
86
+ await access(getBrainjarDir())
87
+ } catch {
88
+ throw new Error(`~/.brainjar/ not found. Run \`brainjar init\` first.`)
89
+ }
90
+ }
91
+
92
+ export interface LayerFrontmatter {
93
+ rules: string[]
94
+ }
95
+
96
+ export function parseLayerFrontmatter(content: string): LayerFrontmatter {
97
+ const result: LayerFrontmatter = { rules: [] }
98
+ const normalized = content.replace(/\r\n/g, '\n')
99
+ const match = normalized.match(/^---\n([\s\S]*?)\n---/)
100
+ if (!match) return result
101
+
102
+ const parsed = parseYaml(match[1])
103
+ if (parsed && typeof parsed === 'object') {
104
+ if (Array.isArray(parsed.rules)) result.rules = parsed.rules.map(String)
105
+ }
106
+
107
+ return result
108
+ }
109
+
110
+ export function stripFrontmatter(content: string): string {
111
+ return content.replace(/\r\n/g, '\n').replace(/^---\n[\s\S]*?\n---\n*/, '').trim()
112
+ }
113
+
114
+ export function parseIdentity(content: string) {
115
+ const parsed = parseYaml(content)
116
+ return {
117
+ name: parsed?.name as string | undefined,
118
+ email: parsed?.email as string | undefined,
119
+ engine: parsed?.engine as string | undefined,
120
+ }
121
+ }
122
+
123
+ export async function loadIdentity(slug: string) {
124
+ const content = await readFile(join(paths.identities, `${slug}.yaml`), 'utf-8')
125
+ return { slug, content, ...parseIdentity(content) }
126
+ }
127
+
128
+ /** Return a valid slug or null. Prevents path traversal from state.yaml. */
129
+ function safeName(value: unknown): string | null {
130
+ if (typeof value !== 'string' || !value) return null
131
+ return SLUG_RE.test(value) ? value : null
132
+ }
133
+
134
+ export async function readState(): Promise<State> {
135
+ let raw: string
136
+ try {
137
+ raw = await readFile(paths.state, 'utf-8')
138
+ } catch (e) {
139
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { ...DEFAULT_STATE }
140
+ throw new Error(`Could not read state.yaml: ${(e as Error).message}`)
141
+ }
142
+
143
+ let parsed: unknown
144
+ try {
145
+ parsed = parseYaml(raw)
146
+ } catch (e) {
147
+ throw new Error(`state.yaml is corrupt: ${(e as Error).message}`)
148
+ }
149
+
150
+ if (!parsed || typeof parsed !== 'object') return { ...DEFAULT_STATE }
151
+
152
+ return {
153
+ backend: ((parsed as any).backend === 'claude' || (parsed as any).backend === 'codex') ? (parsed as any).backend : null,
154
+ identity: safeName((parsed as any).identity),
155
+ soul: safeName((parsed as any).soul),
156
+ persona: safeName((parsed as any).persona),
157
+ rules: Array.isArray((parsed as any).rules)
158
+ ? (parsed as any).rules.map(String).filter((r: string) => SLUG_RE.test(r))
159
+ : [],
160
+ }
161
+ }
162
+
163
+ const LOCK_TIMEOUT_MS = 5000
164
+ const LOCK_STALE_MS = 10000
165
+ const LOCK_POLL_MS = 50
166
+
167
+ /**
168
+ * Acquire an exclusive directory-based lock, run fn, then release.
169
+ * Uses mkdir (atomic on all filesystems) as the lock primitive.
170
+ * Stale locks older than 10s are automatically broken.
171
+ */
172
+ async function withLock<T>(lockDir: string, label: string, fn: () => Promise<T>): Promise<T> {
173
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
174
+
175
+ while (true) {
176
+ try {
177
+ await mkdir(lockDir)
178
+ break
179
+ } catch (e) {
180
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
181
+
182
+ // Break stale locks
183
+ try {
184
+ const info = await stat(lockDir)
185
+ if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
186
+ await rm(lockDir, { force: true, recursive: true })
187
+ continue
188
+ }
189
+ } catch {}
190
+
191
+ if (Date.now() > deadline) {
192
+ throw new Error(`Could not acquire ${label} lock — another brainjar process may be running.`)
193
+ }
194
+ await new Promise(r => setTimeout(r, LOCK_POLL_MS))
195
+ }
196
+ }
197
+
198
+ try {
199
+ return await fn()
200
+ } finally {
201
+ await rm(lockDir, { force: true, recursive: true })
202
+ }
203
+ }
204
+
205
+ export async function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
206
+ return withLock(`${paths.state}.lock`, 'state', fn)
207
+ }
208
+
209
+ export async function writeState(state: State): Promise<void> {
210
+ const doc = {
211
+ backend: state.backend ?? null,
212
+ identity: state.identity ?? null,
213
+ soul: state.soul ?? null,
214
+ persona: state.persona ?? null,
215
+ rules: state.rules,
216
+ }
217
+ const tmp = `${paths.state}.tmp`
218
+ await writeFile(tmp, stringifyYaml(doc))
219
+ await rename(tmp, paths.state)
220
+ }
221
+
222
+ // --- Local state ---
223
+
224
+ /** Local state only stores overrides. undefined = cascade, null = explicit unset. */
225
+ export interface LocalState {
226
+ identity?: string | null
227
+ soul?: string | null
228
+ persona?: string | null
229
+ rules?: {
230
+ add?: string[]
231
+ remove?: string[]
232
+ }
233
+ }
234
+
235
+ /** Override state from env vars. Same shape as LocalState, read-only. */
236
+ export type EnvState = LocalState
237
+
238
+ export type Scope = 'global' | 'local' | '+local' | '-local' | 'env' | '+env' | '-env'
239
+
240
+ /** Effective state after merging global + local + env, with scope annotations. */
241
+ export interface EffectiveState {
242
+ backend: string | null
243
+ identity: { value: string | null; scope: Scope }
244
+ soul: { value: string | null; scope: Scope }
245
+ persona: { value: string | null; scope: Scope }
246
+ rules: { value: string; scope: Scope }[]
247
+ }
248
+
249
+ export async function readLocalState(): Promise<LocalState> {
250
+ let raw: string
251
+ try {
252
+ raw = await readFile(paths.localState, 'utf-8')
253
+ } catch (e) {
254
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {}
255
+ throw new Error(`Could not read local state.yaml: ${(e as Error).message}`)
256
+ }
257
+
258
+ let parsed: unknown
259
+ try {
260
+ parsed = parseYaml(raw)
261
+ } catch (e) {
262
+ throw new Error(`Local state.yaml is corrupt: ${(e as Error).message}`)
263
+ }
264
+
265
+ if (!parsed || typeof parsed !== 'object') return {}
266
+
267
+ const result: LocalState = {}
268
+ const p = parsed as Record<string, unknown>
269
+
270
+ // For each layer: if key is present, include it (even if null)
271
+ if ('identity' in p) result.identity = p.identity === null ? null : safeName(p.identity)
272
+ if ('soul' in p) result.soul = p.soul === null ? null : safeName(p.soul)
273
+ if ('persona' in p) result.persona = p.persona === null ? null : safeName(p.persona)
274
+
275
+ if (p.rules && typeof p.rules === 'object') {
276
+ const r = p.rules as Record<string, unknown>
277
+ result.rules = {}
278
+ if (Array.isArray(r.add)) {
279
+ result.rules.add = r.add.map(String).filter((s: string) => SLUG_RE.test(s))
280
+ }
281
+ if (Array.isArray(r.remove)) {
282
+ result.rules.remove = r.remove.map(String).filter((s: string) => SLUG_RE.test(s))
283
+ }
284
+ }
285
+
286
+ return result
287
+ }
288
+
289
+ export async function writeLocalState(local: LocalState): Promise<void> {
290
+ const localDir = getLocalDir()
291
+ await mkdir(localDir, { recursive: true })
292
+
293
+ // Build a clean doc — only include keys that are present in local
294
+ const doc: Record<string, unknown> = {}
295
+ if ('identity' in local) doc.identity = local.identity ?? null
296
+ if ('soul' in local) doc.soul = local.soul ?? null
297
+ if ('persona' in local) doc.persona = local.persona ?? null
298
+ if (local.rules) {
299
+ const rules: Record<string, string[]> = {}
300
+ if (local.rules.add?.length) rules.add = local.rules.add
301
+ if (local.rules.remove?.length) rules.remove = local.rules.remove
302
+ if (Object.keys(rules).length) doc.rules = rules
303
+ }
304
+
305
+ const tmp = `${paths.localState}.tmp`
306
+ await writeFile(tmp, stringifyYaml(doc))
307
+ await rename(tmp, paths.localState)
308
+ }
309
+
310
+ /** Read override state from BRAINJAR_* env vars. Pure, no I/O.
311
+ * If extraEnv is provided, those values take precedence over process.env. */
312
+ export function readEnvState(extraEnv?: Record<string, string>): EnvState {
313
+ const env = extraEnv ? { ...process.env, ...extraEnv } : process.env
314
+ const result: EnvState = {}
315
+
316
+ if (env.BRAINJAR_IDENTITY !== undefined) {
317
+ result.identity = env.BRAINJAR_IDENTITY === '' ? null : safeName(env.BRAINJAR_IDENTITY)
318
+ }
319
+ if (env.BRAINJAR_SOUL !== undefined) {
320
+ result.soul = env.BRAINJAR_SOUL === '' ? null : safeName(env.BRAINJAR_SOUL)
321
+ }
322
+ if (env.BRAINJAR_PERSONA !== undefined) {
323
+ result.persona = env.BRAINJAR_PERSONA === '' ? null : safeName(env.BRAINJAR_PERSONA)
324
+ }
325
+
326
+ const addRaw = env.BRAINJAR_RULES_ADD
327
+ const removeRaw = env.BRAINJAR_RULES_REMOVE
328
+ if (addRaw !== undefined || removeRaw !== undefined) {
329
+ result.rules = {}
330
+ if (addRaw) {
331
+ result.rules.add = addRaw.split(',').map(s => s.trim()).filter(s => SLUG_RE.test(s))
332
+ }
333
+ if (removeRaw) {
334
+ result.rules.remove = removeRaw.split(',').map(s => s.trim()).filter(s => SLUG_RE.test(s))
335
+ }
336
+ }
337
+
338
+ return result
339
+ }
340
+
341
+ export async function withLocalStateLock<T>(fn: () => Promise<T>): Promise<T> {
342
+ const localDir = getLocalDir()
343
+ await mkdir(localDir, { recursive: true })
344
+ return withLock(`${paths.localState}.lock`, 'local state', fn)
345
+ }
346
+
347
+ /** Apply overrides from a given scope onto an existing effective state. */
348
+ function applyOverrides(
349
+ base: EffectiveState,
350
+ overrides: LocalState | EnvState,
351
+ scope: 'local' | 'env',
352
+ ): EffectiveState {
353
+ const plusScope = `+${scope}` as Scope
354
+ const minusScope = `-${scope}` as Scope
355
+
356
+ const identity = 'identity' in overrides
357
+ ? { value: overrides.identity ?? null, scope: scope as Scope }
358
+ : base.identity
359
+
360
+ const soul = 'soul' in overrides
361
+ ? { value: overrides.soul ?? null, scope: scope as Scope }
362
+ : base.soul
363
+
364
+ const persona = 'persona' in overrides
365
+ ? { value: overrides.persona ?? null, scope: scope as Scope }
366
+ : base.persona
367
+
368
+ // Rules: take active rules from base, apply adds/removes
369
+ const adds = new Set(overrides.rules?.add ?? [])
370
+ const removes = new Set(overrides.rules?.remove ?? [])
371
+
372
+ const rules: EffectiveState['rules'] = []
373
+ const seen = new Set<string>()
374
+
375
+ // Process existing rules (keep active ones, mark newly removed)
376
+ for (const r of base.rules) {
377
+ if (r.scope.startsWith('-')) {
378
+ // Already removed by a lower scope — keep the removal marker
379
+ rules.push(r)
380
+ seen.add(r.value)
381
+ continue
382
+ }
383
+ if (removes.has(r.value)) {
384
+ rules.push({ value: r.value, scope: minusScope })
385
+ } else {
386
+ rules.push(r)
387
+ }
388
+ seen.add(r.value)
389
+ }
390
+
391
+ // Add new rules from this scope (that aren't already present)
392
+ for (const r of adds) {
393
+ if (!seen.has(r)) {
394
+ rules.push({ value: r, scope: plusScope })
395
+ }
396
+ }
397
+
398
+ return { backend: base.backend, identity, soul, persona, rules }
399
+ }
400
+
401
+ /** Pure merge: global → local → env, each scope overrides the previous. */
402
+ export function mergeState(global: State, local: LocalState, env?: EnvState): EffectiveState {
403
+ // Start with global as the base effective state
404
+ const base: EffectiveState = {
405
+ backend: global.backend,
406
+ identity: { value: global.identity, scope: 'global' },
407
+ soul: { value: global.soul, scope: 'global' },
408
+ persona: { value: global.persona, scope: 'global' },
409
+ rules: global.rules.map(r => ({ value: r, scope: 'global' as Scope })),
410
+ }
411
+
412
+ const withLocal = applyOverrides(base, local, 'local')
413
+ return env ? applyOverrides(withLocal, env, 'env') : withLocal
414
+ }