Skip to content
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. - 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

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

#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

python
def _json_type_name(value: Any) -> str

Name value's JSON type the way a settings file spells it.

#_dict_at

python
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

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; 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

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_canonical_settings

python
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

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

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 and the canonical settings keys (defaults.CANONICAL_PROFILE_SETTINGS) inside profileDefaults. 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, 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

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.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search