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.
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).
#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).
#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. 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_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, and permissions deny/ask/allow. 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. 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; 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.