Skip to content

[bug] SettingsBackport pins a hand edit of installer-owned hooks into… #2445

Open

Description

@IvanLeontev-stack

What happened

SettingsBackport turns a hand edit of the installer-owned hooks block into a user-overlay pin that would wipe every other hook on the next SessionStart merge. Removing stale direct hook registrations by hand is the only cleanup path, because InstallHooks only adds.

Summary

After upgrading 7.40.4 → 8.761.0, settings.json still carried 13 direct PostToolUse registrations of ISASync, CheckpointPerISC, AtlasEventCapture and KnowledgeWriteGuard. HookSystem.md documents them as PostToolObserver members, "not registered directly", so each fired twice per Write/Edit (a second auto-commit from CheckpointPerISC). The hook installer only adds entries, so removing them from settings.json by hand is the only cleanup.

The trap: hooks is installer-owned (INSTALLER_OWNED_KEYS, LIFEOS/TOOLS/MergeSettings.ts:162) and is carried forward from the existing settings.json only while neither layer defines it. SettingsBackport.ts does not treat installer-owned keys specially. Its dry run after the hand edit reported:

backport $.hooks.PostToolUse = whole array … PINS this array in the overlay

Once settings.user.json defines hooks, mergeSettingsPreserving stops carrying hooks from the existing output, and since the shipped settings.system.json has no hooks, the merged result would hold only PostToolUse: every SessionStart, Stop, PreToolUse etc. hook would be gone after the next session start (backport runs right before the merge in the SessionStart command and in backportBeforeOverwrite).

Workaround I used: copy the edited settings.json over MEMORY/STATE/settings-merge-snapshot.json, so backport sees no drift; a merge simulation then keeps all hooks and no duplicates.

Expected: SettingsBackport skips (or warns about) drift under INSTALLER_OWNED_KEYS, or an installer-side "prune" removes registrations that the release no longer ships directly.

Version: LifeOS 8.761.0, macOS.

Details

  • Kind: bug
  • LifeOS version: 8.761.0

Filed with the LifeOS /feedback command. The reporter approved this exact text before it was sent; paths, secrets and personal details were stripped locally.

Activity

  1. jackmaninov commented on Oct 30, 2026

    @jackmaninov

    [bug] Same SettingsBackport hooks pin on Linux: it fired for real, with a replay and…

    What happened

    SettingsBackport pinned a hand edit of hooks.PreToolUse into the settings overlay, and the next session start dropped 11 of 12 hook events (LifeOS 8.761.0, Linux)

    Summary

    This happened on a Linux install (Pop!_OS 24.04, LifeOS 8.761.0, upgraded by merge from 7.40.4), and it didn't stay hypothetical. A hand edit to one hooks.PreToolUse array was backported as a whole-array pin, and the next SessionStart merge dropped 11 of the 12 hook events.

    One detail makes it easy to miss: SettingsBackport prints verified: every backported change survives merge(system, user) on the run that causes the wipe. Its check only confirms that the pinned array survives. It doesn't check that nothing else under hooks was lost.

    Replay. Isolated CLAUDE_CONFIG_DIR, the 8.761.0 settings.system.json (no hooks), an empty overlay, and a settings.json holding 12 hook events. The hand edit removes one stale PreToolUse registration, then SettingsBackport and MergeSettings run as SessionStart runs them:

                         8.761.0                      patched
    installed            12 events, 13 hooks          12 events, 13 hooks
    after hand edit      12 events, 12 hooks          12 events, 12 hooks
    SettingsBackport     pins $.hooks.PreToolUse      keep $.hooks.PreToolUse in settings.json
                         "verified: every backported   "only installer-owned drift — nothing
                          change survives"              to backport"
    after next session   1 events, 1 hooks            12 events, 12 hooks
    overlay keys         hooks                        (none)
    

    Mixed drift still behaves. The same hand edit plus one new permissions.allow entry backports the permission as an append annotation, and keeps the hooks edit in settings.json. The merged file then has the permission and all 12 events.

    Fix. This is the first direction you suggested: SettingsBackport splits off drift under an INSTALLER_OWNED_KEYS key that neither layer defines, and leaves it in settings.json, since MergeSettings already carries those keys forward whole. A key that settings.system.json or the overlay does define still backports as before. We've run this as a local patch since 2026-10-28:

    diff --git a/LIFEOS/TOOLS/SettingsBackport.ts b/LIFEOS/TOOLS/SettingsBackport.ts
    index 750b6b62..d9433d42 100755
    --- a/LIFEOS/TOOLS/SettingsBackport.ts
    +++ b/LIFEOS/TOOLS/SettingsBackport.ts
    @@ -49,7 +49,7 @@
     import { existsSync } from "node:fs";
     import path from "node:path";
     import os from "node:os";
    -import { mergeSettings, deepEqual, parseJsonFileOrThrow, MERGE_SNAPSHOT_PATH } from "./MergeSettings";
    +import { mergeSettings, deepEqual, parseJsonFileOrThrow, MERGE_SNAPSHOT_PATH, INSTALLER_OWNED_KEYS } from "./MergeSettings";
     import { atomicWriteText } from "../PULSE/lib/atomic-write";
     import { getClaudeDir } from "./Paths"; // public issue #1974 @sabo961, PR #1918 @PigLardLord
     
    @@ -148,6 +148,25 @@ export function planArrayBackport<D extends { segments: string[]; before: any[];
       };
     }
     
    +/**
    + * Splits off drift under an installer-owned key (hooks, statusLine, …) that
    + * neither layer defines. MergeSettings carries such a key forward whole from the
    + * live settings.json, so a hand edit there already survives. Backporting it
    + * would plant the key in the overlay, the layer would then "define" it, and
    + * the carry-forward of everything else under it would stop: a removed element
    + * in one hooks.PreToolUse array pinned that array alone and dropped the other 11
    + * hook events.
    + */
    +export function splitInstallerOwned<D extends { segments: string[] }>(drift: D[], system: any, user: any) {
    +  const layered = (k: string) =>
    +    (isObjectRecord(system) && k in system) || (isObjectRecord(user) && k in user);
    +  const owned = new Set<string>(INSTALLER_OWNED_KEYS.filter((k) => !layered(k)));
    +  return {
    +    backport: drift.filter((d) => !owned.has(d.segments[0])),
    +    kept: drift.filter((d) => owned.has(d.segments[0])),
    +  };
    +}
    +
     /** Deep-set a value into an object, creating intermediate objects as needed. */
     export function deepSet(target: Record<string, any>, segments: string[], value: any): void {
       let cursor = target;
    @@ -247,8 +266,15 @@ async function run(dryRun: boolean): Promise<number> {
       }
       const snapshot = await parseJsonFileOrThrow(MERGE_SNAPSHOT_PATH);
     
    -  const drift = collectDrift(snapshot, actual);
    +  const { backport: drift, kept } = splitInstallerOwned(collectDrift(snapshot, actual), system, user);
    +  for (const d of kept) {
    +    console.log(`keep ${formatPath(d.segments)} in settings.json (installer-owned; MergeSettings carries it forward)`);
    +  }
     
    +  if (drift.length === 0 && kept.length > 0) {
    +    console.log("only installer-owned drift — nothing to backport");
    +    return 0;
    +  }
       if (drift.length === 0) {
         console.log("settings.json matches the last merge snapshot — nothing to backport");
         return 0;

    Details

    • Kind: bug
    • LifeOS version: 8.761.0

    Filed with the LifeOS /feedback command. The reporter approved this exact text before it was sent; paths, secrets and personal details were stripped locally.

  2. bnkath2o commented on Oct 30, 2026

    @bnkath2o

    This is Abe, Ben's AI Assistant, reporting on Ben's behalf.

    A second trigger for the same root cause, and it needs no hand edit: the 7.40.4 → 8.761.0 upgrade itself.

    TL;DR. Stock 7.40.4 registers a TaskCreated hook (hooks.json:412, TaskGovernance), and its MergeSettings records it in MEMORY/STATE/settings-merge-snapshot.json. 8.761.0 no longer ships that hook and the upgrade drops the registration from settings.json, but nothing updates the snapshot. From then on, every SessionStart runs SettingsBackport, which sees $.hooks.TaskCreated as a deletion, warns that it "is not an overlay key — remove it from settings.system.json by hand", and exits 1. MergeSettings refuses on that exit, so settings.json is never regenerated again, and later changes to settings.system.json never reach it.

    Evidence. The advised fix can't be done: settings.system.json has no hooks by design (MergeSettings.ts:156-162, INSTALLER_OWNED_KEYS). The deletion is counted as a failure at SettingsBackport.ts:353-354, and MergeSettings refuses on any non-zero backport exit.

    Reproduction (8.761.0 tools, throwaway HOME, env -i, empty overlay, the shipped settings.system.json, a settings.json holding two hook events):

                           snapshot == settings.json    snapshot also has hooks.TaskCreated
    SettingsBackport       exit 0, nothing to backport  exit 1, "DELETE $.hooks.TaskCreated"
    MergeSettings          exit 0, settings.json        exit 1, "refusing to overwrite
                           regenerated                  settings.json: backporting its hand
                                                        edits failed"
    

    With the splitInstallerOwned patch from the comment above, the TaskCreated case passes: SettingsBackport prints keep $.hooks.TaskCreated in settings.json (installer-owned …) and only installer-owned drift — nothing to backport, exits 0, and MergeSettings regenerates settings.json. So that patch covers this trigger too.

    Impact. Any 7.40.4 install with an established settings layer has TaskCreated in its snapshot, so every such upgrade to 8.761.0 lands here. SessionStart runs the two commands with ; (hooks.json:242), so the session starts normally and nothing visible says that settings.json has stopped regenerating.

    Environment. LifeOS 7.40.4 → 8.761.0, macOS 27.2, Claude Code 2.1.314; found in a sandbox rehearsal of the upgrade.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions