claudewheel v0.24.2 /claudewheel.reconcile
Edit
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

python
def is_empty(self) -> bool

True when no additions or removals are needed (already canonical).

#change_count

python
def change_count(self) -> int

Total number of individual add/remove operations in this diff.

#_reconcile_list

python
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

python
def compute_settings_diff(container: dict[str, Any]) -> PermissionDiff

Compute 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

python
def apply_settings_diff(container: dict[str, Any], diff: PermissionDiff) -> None

Mutate 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

python
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

python
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

python
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

python
def _reconcile_shared_disallowed(shared: dict[str, Any]) -> list[str]

Make shared-settings.json's top-level disallowedTools exactly canonical.

#reconcile_profile_dict

python
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

python
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

python
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

python
def changed_any(self) -> bool

True when anything was (or would be) written.

#_process_settings_file

python
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) -> TargetReport

Load, 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

python
def reconcile_workspace(ws: 'Workspace', *, dry_run: bool, profile: str | None=None, deploy_hook_scripts: bool=True) -> ReconcileReport

Reconcile 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

python
def _print_report(report: ReconcileReport, dry_run: bool) -> None

Print a human-readable summary of a reconciliation pass.

#run_reconcile

python
def run_reconcile(ws: 'Workspace', dry_run: bool, profile: str | None=None) -> int

Reconcile 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.

Search