On this page
Unified reconcile core: make every managed target EXACTLY canonical.
#claudewheel.reconcile
#claudewheel.reconcile
Unified reconcile core: make every managed target EXACTLY canonical.
This module owns the single reconciliation core for the whole guardrail surface. It merges what used to be two separate, differently-shaped sync paths -- patch_profiles' additive hooks/disallowedTools sync and this module's permissions reconciliation -- into one compare-then-write core that brings each target file's guardrail sections into EXACT agreement with the canonical model:
- hooks: the ENTIRE hooks structure is replaced with the canonical wiring (defaults.build_canonical_shared_settings). User-added hook entries are pruned -- extras belong in defaults.py, not in per-profile drift. - disallowedTools: made exactly equal to defaults.DISALLOWED_TOOLS. In profile settings it lives under the claudewheel namespace; the inert top-level disallowedTools key (which Claude Code ignores) is dropped. In shared-settings.json it lives at the top level. - permissions.deny / permissions.ask: made exactly equal to guardrail.canonical_deny_rules() / canonical_ask_rules() -- missing canonical entries added, non-canonical entries pruned. - permissions.allow: only guardrail.ALLOW_CONFLICTS entries removed; all other allow entries are left alone and nothing is ever added to allow. - the canonical settings keys: every key in defaults.CANONICAL_PROFILE_SETTINGS -- which is the one list of them -- made exactly equal to its canonical value, at the top level of a profile's settings and inside profileDefaults in shared-settings.json.
This DELIBERATELY replaces the old additive, user-extras-preserving semantics of patch_profiles (merge_hooks etc.): extras are pruned.
Hook SCRIPT deployment is part of canonical: the core deploys any missing guardrail hook scripts to the scripts dir, because wiring that references missing scripts is not canonical.
The "default" profile (Claude Code's built-in ~/.claude) is UNCONDITIONALLY excluded: the core never reads from or writes to it, even when profile discovery enumerates it.
All writes go through the mode-preserving atomic save_settings path, and every target is compared before writing -- a file already canonical is left byte-identical (no write happens).
Malformed data is refused, not repaired: a target whose settings file nests a non-object where a guardrail container belongs ("profileDefaults": null, "claudewheel": []) is skipped with a reason naming the file and the key, exactly as an unreadable file is, and the remaining targets are reconciled normally.
#PermissionDiff
The additions and removals needed to reconcile one permissions block.
#is_empty
def is_empty(self) -> boolTrue when no additions or removals are needed (already canonical).
#change_count
def change_count(self) -> intTotal number of individual add/remove operations in this diff.
#_reconcile_list
def _reconcile_list(current: list[str], canonical: list[str]) -> tuple[list[str], list[str]]Compute (to_add, to_remove) so current becomes exactly canonical.
to_add preserves canonical order (missing canonical entries in the order they appear in the model). to_remove preserves current order (entries present now but absent from the canonical set).
#MalformedSettingsError
A guardrail key holds a value of the wrong JSON type.
Raised by :func:_dict_at when a settings file nests something other than an object under a key the reconciliation must descend into ("profileDefaults": null, "claudewheel": [], a string "permissions"). Malformed data is reported and skipped, never quietly normalized -- the per-target driver turns this into a skip_reason and leaves the file untouched.
#_json_type_name
def _json_type_name(value: Any) -> strName value's JSON type the way a settings file spells it.
#_dict_at
def _dict_at(container: dict[str, Any], key: str) -> dict[str, Any]Return container[key] as a dict; raise when it is not one.
setdefault is unsafe on a hand-edited settings file: a key present with a non-dict value ("profileDefaults": null, a list, a string) is handed straight back, and the caller's .get/.setdefault on it raises AttributeError. This is the one rule for every nested guardrail container:
- a MISSING key is created empty and returned -- absence is ordinary bootstrap, not malformation; - an existing dict is returned as is; - a present-but-non-dict value is malformed data, and raises :class:MalformedSettingsError naming the key and the type found. container is left exactly as it was: a value nobody can interpret is never silently repaired, because repairing it would overwrite whatever the author meant with no record that it existed.
#compute_settings_diff
def compute_settings_diff(container: dict[str, Any]) -> PermissionDiffCompute the reconciliation diff for a dict holding a permissions block.
container is either a profile settings.json dict or a profileDefaults dict -- both nest their arrays under permissions. A missing permissions block (or missing arrays) is treated as empty; a present-but-non-dict permissions value raises :class:MalformedSettingsError (see :func:_dict_at). The allow array is only inspected when present; nothing is ever added to allow.
#apply_settings_diff
def apply_settings_diff(container: dict[str, Any], diff: PermissionDiff) -> NoneMutate container in place to enact diff via the permission primitives.
Removals run before additions. Uses permission.add_rule (append-only) and permission.remove_rule so JSON IO and the permissions-block shape stay consistent with the rest of the codebase.
#_reconcile_permissions
def _reconcile_permissions(container: dict[str, Any]) -> list[str]Make container's permissions deny/ask exact and prune allow conflicts.
Returns human-readable change descriptions (empty when already canonical).
#_reconcile_hooks
def _reconcile_hooks(container: dict[str, Any], canonical_hooks: dict[str, Any]) -> list[str]Set container['hooks'] to EXACTLY canonical_hooks.
Replaces the entire hooks structure -- user-added hook entries are pruned. No-op (no mutation, empty return) when the hooks are already canonical.
#_reconcile_profile_disallowed
def _reconcile_profile_disallowed(settings: dict[str, Any]) -> list[str]Make a profile's claudewheel.disallowedTools exactly canonical.
Also drops the inert top-level disallowedTools key (Claude Code ignores it -- profiles carry the list under the claudewheel namespace).
#_reconcile_shared_disallowed
def _reconcile_shared_disallowed(shared: dict[str, Any]) -> list[str]Make shared-settings.json's top-level disallowedTools exactly canonical.
#_reconcile_canonical_settings
def _reconcile_canonical_settings(container: dict[str, Any]) -> list[str]Make every defaults.CANONICAL_PROFILE_SETTINGS key exact in container.
A key that is absent or carries a different value is set to the canonical value; one already equal is left alone. Returns one change description per key written (empty when the container is already canonical).
#reconcile_profile_dict
def reconcile_profile_dict(settings: dict[str, Any], canonical: dict[str, Any]) -> list[str]Reconcile one profile settings.json dict IN PLACE to exact canonical.
Reconciles hooks, the claudewheel.disallowedTools list, permissions deny/ask/allow, and the canonical settings keys (defaults.CANONICAL_PROFILE_SETTINGS). Non-guardrail keys are left untouched. Returns human-readable change descriptions (empty when already canonical).
#reconcile_shared_dict
def reconcile_shared_dict(shared: dict[str, Any], canonical: dict[str, Any]) -> list[str]Reconcile the shared-settings.json dict IN PLACE to exact canonical.
Reconciles the top-level hooks and disallowedTools plus the profileDefaults.permissions deny/ask/allow and the canonical settings keys (defaults.CANONICAL_PROFILE_SETTINGS) inside profileDefaults. Non-guardrail keys are left untouched. Returns human-readable change descriptions.
#_referenced_scripts
def _referenced_scripts(hooks: dict[str, Any]) -> list[str]Collect the ordered, unique script basenames referenced by hooks.
#TargetReport
The outcome of reconciling one target file.
#ReconcileReport
The aggregate outcome of a workspace reconciliation pass.
#changed_any
def changed_any(self) -> boolTrue when anything was (or would be) written.
#_process_settings_file
def _process_settings_file(path: Path, reconcile_fn: Callable[[dict[str, Any], dict[str, Any]], list[str]], canonical: dict[str, Any], label: str, dry_run: bool) -> TargetReportLoad, reconcile, compare, and (unless dry-run) write one settings file.
Compare-then-write: the file is written only when its guardrail sections actually differed from canonical, so an already-canonical file is left byte-identical. A missing/unreadable file is reported and skipped, and so is a file holding a malformed guardrail container (a JSON value of the wrong type where an object belongs) -- both are per-target skips naming the file, never a repair and never an abort of the remaining targets. A write error is captured (never raised) so a launch-time reconcile never aborts.
#reconcile_workspace
def reconcile_workspace(ws: 'Workspace', *, dry_run: bool, profile: str | None=None, deploy_hook_scripts: bool=True) -> ReconcileReportReconcile every managed target to exact canonical. The single core.
Deploys any missing guardrail hook scripts (unless dry_run), then reconciles each discovered profile's settings.json and -- when not scoped to a single profile -- shared-settings.json. The "default" profile is unconditionally excluded. When profile names a single profile, only that profile is touched and shared-settings is left alone.
#_print_report
def _print_report(report: ReconcileReport, dry_run: bool) -> NonePrint a human-readable summary of a reconciliation pass.
#run_reconcile
def run_reconcile(ws: 'Workspace', dry_run: bool, profile: str | None=None) -> intReconcile the workspace to exact canonical and print a report.
Backs both the reconcile-permissions and patch-profiles CLI commands (they are now the same operation). Returns 0 on success, 1 when a scoped profile is not found.