MergeSettings: nested __merge annotations under a user-only key are written raw into settings.json

Aperta Adatta ai principianti
#2,636 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 7 giorni

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
2/5
Tempo stimato
1-3 ore
Idoneità per principianti
67/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
bun, typescript
Ambito
backend

Direzione di ricerca

Inizia da LIFEOS/TOOLS/MergeSettings.ts:202-209, dove vengono uniti o clonati i valori presenti solo nell’utente, e segui come vengono gestite le annotazioni __merge annidate. Riproduci il problema con il comando Bun fornito, poi controlla i casi suggeriti in LIFEOS/TOOLS/MergeSettings.test.ts se viene aggiunto quel file di test. Il lavoro è completato quando le annotazioni Append e Remove presenti solo nell’utente vengono risolte nel risultato e i valori semplici restano invariati.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

Version

LifeOS 8.761.0 · attestation: 66 system file(s) differ from danielmiessler/[email protected]:

  • hooks/IsaCodeSyncGate.hook.ts
  • hooks/lib/presence-context.ts
  • hooks/lib/micro-integrity.ts
  • hooks/lib/blocker-text.ts
  • hooks/lib/herdr-tabs.ts
  • hooks/lib/work-kind.ts
  • hooks/BlockerText.hook.ts
  • hooks/StopGates.hook.ts
  • skills/LifeOSHooks/hooks/LifeOSHooks.ts
  • skills/VideoEssay/Tools/Theme.ts
  • skills/VideoEssay/Intents/Explainer.md
  • skills/Fabric/Patterns/create_better_frame/system.md
  • skills/Fabric/Patterns/extract_article_wisdom/README.md
  • skills/Fabric/Patterns/extract_wisdom/README.md
  • skills/Fabric/Patterns/rate_value/README.md
  • skills/Fabric/Patterns/extract_product_features/README.md
  • skills/Fabric/Patterns/show_fabric_options_markmap/system.md
  • commands/ew.md
  • commands/cv.md
  • commands/pv.md
  • commands/r.md
  • LIFEOS/TOOLS/ReleaseRecord.ts
  • LIFEOS/TOOLS/MergeSettings.ts
  • LIFEOS/TOOLS/ReviewSuite.ts
  • LIFEOS/TOOLS/lib/DenyHistoryScan.ts
  • LIFEOS/TOOLS/GenerateTelosSummary.ts
  • LIFEOS/TOOLS/VoiceMaster.ts
  • LIFEOS/TOOLS/ReviewCases.ts
  • LIFEOS/TOOLS/model-pricing.json
  • LIFEOS/TOOLS/CommitmentDetect.ts
  • LIFEOS/DOCUMENTATION/SystemUserBoundary.md
  • LIFEOS/DOCUMENTATION/Statvs/StatvsSystem.md
  • LIFEOS/DOCUMENTATION/Assay/AssaySystem.md
  • LIFEOS/DOCUMENTATION/Notifications/NotificationSystem.md
  • LIFEOS/PULSE/VoiceServer/voice.ts
  • LIFEOS/HERMES/Policy.ts
  • LIFEOS/ASSAY/Report.ts
  • LIFEOS/ASSAY/ReviewEntry.ts
  • LIFEOS/RELAY/context/HeadsUp.ts
  • LIFEOS/RELAY/context/LooseEnds.ts
  • …and 26 more
What is broken

When a key exists only in the user overlay (settings.user.json) and its value is an object, mergeSettings clones that object verbatim instead of merging it, so any {"__merge":"append"} or {"__merge":"remove"} annotation nested inside it is never resolved and is written raw into settings.json. The common trigger is hooks: settings.system.json ships no hooks key, so an overlay with hooks.PreToolUse as an append wrapper produces a settings.json whose PreToolUse is the literal wrapper object rather than an array, and the harness ignores or rejects those hooks. MergeSettings --check then reports a mismatch on every SessionStart until the overlay is hand-flattened.

Where (file:line)

LIFEOS/TOOLS/MergeSettings.ts:209 (reached from :202)

Repro on a clean tree
On a clean 8.761.0 install (MergeSettings.ts 1.3.16), from the install root:

    bun -e 'import { mergeSettings } from "./LIFEOS/TOOLS/MergeSettings.ts";
      const m = mergeSettings({ env: {} }, { hooks: { PreToolUse: { __merge: "append", values: [{ matcher: "Bash", hooks: [] }] } } });
      console.log(JSON.stringify(m.hooks.PreToolUse)); process.exit(Array.isArray(m.hooks.PreToolUse) ? 0 : 1);'

Expected: `[{"matcher":"Bash","hooks":[]}]`, exit 0.
Actual: `{"__merge":"append","values":[{"matcher":"Bash","hooks":[]}]}`, exit 1.

Same result end to end: put that `hooks.PreToolUse` wrapper in LIFEOS/USER/CONFIG/settings.user.json and run MergeSettings; settings.json carries the wrapper.

Cause: for a user-only key, mergeSettings calls `mergeSettings(undefined, user[key])`; the value is an object but not an annotation, the system side is not an object, so it falls through to `return cloneJsonValue(user)`.
Negative control

Probe: bun -e 'import { mergeSettings } from "./LIFEOS/TOOLS/MergeSettingsRelease.probe.ts"; const m = mergeSettings({ env: {} }, { hooks: { PreToolUse: { __merge: "append", values: [{ matcher: "Bash", hooks: [] }] } } }); console.log(JSON.stringify(m.hooks.PreToolUse)); process.exit(Array.isArray(m.hooks.PreToolUse) ? 0 : 1);' · exit 1 · captured 2026-11-06T21:00:09.379Z

Red looks like: {"__merge":"append","values":

{"__merge":"append","values":[{"matcher":"Bash","hooks":[]}]}
Suggested fix

At the end of mergeSettings, before the final clone, merge a user object against an empty layer: if (isObjectRecord(user)) return mergeSettings({}, user);. Nested append/remove annotations then resolve at any depth, plain values come out unchanged, and an invalid nested annotation still throws. The attached diff adds this plus MergeSettings.test.ts (append under a user-only key, the SessionStart preserving merge, remove under a user-only key, a user object replacing a system scalar, plain passthrough, invalid nested annotation). Running locally as a patch on 8.761.0; 6/6 tests pass and settings.json now matches the overlay whether PreToolUse is wrapped or flat.

diff --git a/LIFEOS/TOOLS/MergeSettings.test.ts b/LIFEOS/TOOLS/MergeSettings.test.ts
new file mode 100644
index 0000000..2557ad1
--- /dev/null
+++ b/LIFEOS/TOOLS/MergeSettings.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, test } from "bun:test";
+import { mergeSettings, mergeSettingsPreserving } from "./MergeSettings";
+
+// Local patch 1.3.17: annotations nested under a user-only key must resolve even when the
+// system layer has no such key (2026-11-06: hooks.PreToolUse wrapped in append, no system hooks).
+describe("user-only objects resolve nested merge annotations", () => {
+  const hookA = { matcher: "Bash", hooks: [{ type: "command", command: "a" }] };
+  const hookB = { matcher: "*", hooks: [{ type: "command", command: "b" }] };
+
+  test("hooks.PreToolUse wrapped in append with no system hooks becomes a plain array", () => {
+    const system = { env: { X: "1" } };
+    const user = { hooks: { PreToolUse: { __merge: "append", values: [hookA, hookB] } } };
+    const merged = mergeSettings(system, user);
+    expect(merged.hooks.PreToolUse).toEqual([hookA, hookB]);
+    expect(JSON.stringify(merged)).not.toContain("__merge");
+  });
+
+  test("the preserving merge used at SessionStart resolves it too", () => {
+    const merged = mergeSettingsPreserving({}, { hooks: { PreToolUse: { __merge: "append", values: [hookA] } } }, { hooks: {} });
+    expect(merged.hooks.PreToolUse).toEqual([hookA]);
+  });
+
+  test("remove annotations under a user-only key are dropped", () => {
+    const merged = mergeSettings({}, { hooks: { Stop: { __merge: "remove" }, PreToolUse: [hookA] } });
+    expect(merged.hooks).toEqual({ PreToolUse: [hookA] });
+  });
+
+  test("a user object replacing a system scalar still resolves its annotations", () => {
+    const merged = mergeSettings({ statusLine: "off" }, { statusLine: { list: { __merge: "append", values: [1] } } });
+    expect(merged.statusLine).toEqual({ list: [1] });
+  });
+
+  test("plain user-only values are unchanged", () => {
+    const user = { hooks: { PreToolUse: [hookA] }, flag: true, list: [1, { a: 2 }] };
+    expect(mergeSettings({}, user)).toEqual(user);
+  });
+
+  test("an invalid nested annotation still throws", () => {
+    expect(() => mergeSettings({}, { hooks: { PreToolUse: { __merge: "prepend", values: [] } } })).toThrow();
+  });
+});
diff --git a/LIFEOS/TOOLS/MergeSettings.ts b/LIFEOS/TOOLS/MergeSettings.ts
index 4e673d8..83822db 100755
--- a/LIFEOS/TOOLS/MergeSettings.ts
+++ b/LIFEOS/TOOLS/MergeSettings.ts
@@ -1,7 +1,11 @@
 #!/usr/bin/env bun
 
 /**
- * @version 1.3.16
+ * @version 1.3.17
+ * 1.3.17 (2026-11-06, local patch): a user-only object (no system counterpart, or a non-object one)
+ * is merged against an empty object instead of cloned verbatim, so nested append/remove annotations
+ * resolve even when the system layer has no such key (e.g. hooks.PreToolUse wrapped in append while
+ * settings.system.json ships no `hooks`). Before, the wrapper was written into settings.json as-is.
  * Deep-merges a system settings JSON value with a user overlay.
  * Objects merge recursively while preserving system key order.
  * New user-only object keys are appended after system keys at each level.
@@ -206,6 +210,12 @@ export function mergeSettings(system: any, user: any): any {
     return merged;
   }
 
+  // A user object with no object to merge into still carries annotations at any depth; resolve
+  // them against an empty layer rather than cloning the raw overlay (local patch 1.3.17).
+  if (isObjectRecord(user)) {
+    return mergeSettings({}, user);
+  }
+
   return cloneJsonValue(user);
 }
Environment
  • OS: macOS 26.6.2 (arm64)
  • Harness: claude-code
  • bun: 1.3.14 · git: 2.46.0
  • Doctor broken: hook-interpreters
Lingua principale
TypeScript
Stelle
19.7k
Fork
2.5k
Metriche di merge delle PR
Nessuna PR unita negli ultimi 30g

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di danielmiessler/LifeOS

Tutte le issue di danielmiessler/LifeOS

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.