Hands-on Lab2,629 words

Enablement project — a configuration a team could read back

Enablement project — a configuration a team could read back

Build a local harness that answers the questions a team actually asks about its Claude Code setup: who does this setting reach, will this check really run, what does an unattended job load, and why did nothing happen. The harness uses fixed fixtures. It tests your configuration decisions; it does not run Claude Code, execute a hook, connect a tool server or call any API. Every team name, file fixture and failure report below is a house fixture; every command, exit code and file name is quoted from the documentation.

Use Python 3's standard library. No credentials or paid services are required. Complete each stage before reading its expected output, then save and run your work as unit7_project.py.

Difficulty: intermediate configuration practice. Estimated duration: 90-120 minutes. Prerequisites: the Unit 7 topic lecture.

Stage 1 - Resolve the stack

Maps to CCARP-U7.T1.LO1.S1.

Four sources set overlapping keys. Resolve each key the way the documentation says.

SourceSets
usermodel, spinnerTipsEnabled, permissions.allow
shared projectspinnerTipsEnabled, permissions.allow
project localpermissions.allow
managedspinnerTipsEnabled

Write resolve(key) returning the winning source for a scalar key, and resolve_list(key) returning the combined entries for a list key. Then record where an environment variable sits in the stack.

Expected output: spinnerTipsEnabled resolves to managed, because nothing you set overrides it. model resolves to user, because no higher source sets it. permissions.allow does not resolve to a single source: the lists combine, so all three files' entries apply. Your note says environment variables are not a level in this stack, and that which one applies is decided per variable-and-key pair.

Stage 2 - Place each requirement at a scope

Maps to CCARP-U7.T1.LO1.S2 and .S3.

Five requirements arrive from a platform team. Write place(requirement) returning the file or scope each belongs in.

RequirementHouse fixture
Everyone who clones runs the same lint hookmust not drift
One engineer prefers a different modelpersonal
Nobody may disable the audit hooknon-negotiable
The team shares a ticketing tool servershared
That server needs a per-machine API keysecret

Expected output: the lint hook goes in the committed shared project file; the personal model goes in that engineer's project local file, which needs no commit; the audit hook goes in managed settings, which nothing a developer sets overrides; the tool server goes in project scope, the one server scope shared through version control; and the key is not placed in a file at all — it is referenced by environment variable expansion, which the documentation offers so teams can share configurations while keeping sensitive values out of them.

Stage 3 - Tell an instruction from an enforcement

Maps to CCARP-U7.T1.LO2.S1 and .S2.

Write mechanism(rule) returning instruction or hook, and event(rule) returning the specific event for anything that becomes a hook. A rule that has to stop something needs the event documented as able to block.

RuleMust run at a fixed point
Prefer two-space indentationno
Run the test suite after each file edityes
Explain the repository layout to new sessionsno
Refuse a commit that touches the vendor directoryyes

Expected output: two instructions and two hooks, on two different events. The test suite reports after each edit, so it is PostToolUse. Refusing a commit has to prevent it, so it is PreToolUse, the only per-tool-call event documented as able to block; PostToolUse is documented as unable to, because the tool already ran. Your note records why the split exists: instruction files are context, not enforced configuration, delivered as a user message after the system prompt with no guarantee of strict compliance, while a hook runs at a fixed lifecycle event regardless of what Claude decides. It also records that an async hook cannot enforce anything, because the action it would have controlled has already completed.

Stage 4 - Decide what an unattended run may load

Maps to CCARP-U7.T1.LO2.S3.

Write loads(mode) over the two documented modes, and guard(repo_is_ours) returning the documented options for scripting over a repository you did not write.

JobRepository
nightly-own-repoours
triage-vendor-reponot ours

Expected output: in default print mode the repository's committed hooks run and its tool servers connect, and the session shows no trust dialog and no per-server approval prompt; in bare mode neither is read. For the job over the repository the team does not own, guard returns all three documented options: review the settings files, start with bare mode, or turn hooks off for that run. Your note records that the pipeline branches on the process exit status, 0 on success and non-zero on failure, and that a failure inside the run is printed as the result on standard output.

Stage 5 - Read back a silent failure

Maps to CCARP-U7.T1.LO3.S1, .S2 and .S3.

Three reports arrive from developers, and three more from the unattended pipeline. Write first_reading(report) returning the documented check that settles each, audience(exit_code) returning who sees a hook's standard error, and running_job(symptom) returning the documented cause of each pipeline symptom.

ReportHouse fixture
R1"my setting is ignored"
R2"none of my settings apply since I edited the file"
R3"the hook prints a warning and nothing reacts"
J1"the job exited 143 with no result"
J2"the job waited, then ended with nothing"
J3"CI never runs on the commits the action pushes"

Expected output: R1 is the status check, which separates a file that never loaded from a value a higher level took. R2 is a strict JSON syntax error reported as a Settings Error at the next start. R3 is the exit code: standard error from a hook that exits 0 reaches the debug log only, never the transcript, and Claude never sees it — so audience(0) is nobody, audience(2) is Claude, and any other code produces a non-blocking notice in the transcript. The three pipeline symptoms are somebody else stopping your run rather than your configuration: a termination signal, which leaves the in-progress turn unfinished with no result recorded and still runs the session-end hooks; the background wait ceiling of ten minutes of continuous idle waiting, after which whatever is running is stopped and its partial result dropped; and the default repository token, which the platform does not trigger workflows on. Your note records that the debug flag does not print to the terminal, and that a hook working for its author and not for a teammate is a trust difference: an interactive session holds hooks back from every settings file — the author's own user file included — until the folder is trusted, or a parent directory whose trust extends to it. The untracked local file skips that step for its permission allow rules, not for hooks, so it does not explain the symptom.

Reference solution

python
"""Unit 7 project reference solution. Standard library only; no network calls. Every team name, file fixture and failure report is a house fixture. Every command, exit code and file name is quoted from the retained documentation. """ import json # ------------------------------------------------------------------ Stage 1 # Highest precedence first. Environment variables are deliberately absent: # the documentation states they are not a level in this stack. STACK = ("managed", "command_line", "project_local", "shared_project", "user") SOURCES = { "user": {"model": "opus", "spinnerTipsEnabled": False, "permissions.allow": ["Bash(npm test)"]}, "shared_project": {"spinnerTipsEnabled": True, "permissions.allow": ["Bash(npm run lint)"]}, "project_local": {"permissions.allow": ["Bash(git status)"]}, "managed": {"spinnerTipsEnabled": True}, } LIST_KEYS = {"permissions.allow"} def resolve(key): """A scalar key comes from the highest level that sets it.""" if key in LIST_KEYS: raise ValueError(f"{key} is a list key; lists combine rather than resolving") for level in STACK: if key in SOURCES.get(level, {}): return level return None def resolve_list(key): """A list key combines across files instead of one winning.""" combined = [] for level in STACK: combined.extend(SOURCES.get(level, {}).get(key, [])) return combined stage1 = { "spinnerTipsEnabled": resolve("spinnerTipsEnabled"), "model": resolve("model"), "permissions.allow": resolve_list("permissions.allow"), "env_vars": "not a level in this stack; decided per variable-and-key pair", } # ------------------------------------------------------------------ Stage 2 REQUIREMENTS = [ {"id": "lint-hook", "shared": True, "personal": False, "non_negotiable": False, "secret": False}, {"id": "model-preference", "shared": False, "personal": True, "non_negotiable": False, "secret": False}, {"id": "audit-hook", "shared": True, "personal": False, "non_negotiable": True, "secret": False}, {"id": "ticketing-server", "shared": True, "personal": False, "non_negotiable": False, "secret": False, "is_server": True}, {"id": "server-api-key", "shared": True, "personal": False, "non_negotiable": False, "secret": True, "is_server": True}, ] def place(requirement): if requirement["secret"]: # Not a file at all: the shared server file expands it from the environment. return "environment variable expansion, referenced from the shared server file" if requirement["non_negotiable"]: return "managed settings" if requirement["personal"]: return ".claude/settings.local.json" if requirement.get("is_server"): return "project scope, the shared server file at the project root" return ".claude/settings.json, committed" stage2 = {"placed": {r["id"]: place(r) for r in REQUIREMENTS}, "why_local_needs_no_commit": "Claude Code keeps the project local file out of git when it creates it"} # ------------------------------------------------------------------ Stage 3 RULES = [ {"id": "indentation", "fixed_point": False, "per": None, "must_block": False}, {"id": "tests-after-edit", "fixed_point": True, "per": "tool_call", "must_block": False}, {"id": "explain-layout", "fixed_point": False, "per": None, "must_block": False}, {"id": "block-vendor-commit", "fixed_point": True, "per": "tool_call", "must_block": True}, ] CADENCES = {"session": ("SessionStart", "SessionEnd"), "turn": ("UserPromptSubmit", "Stop", "StopFailure"), "tool_call": ("PreToolUse", "PostToolUse")} # Documented per-event: PreToolUse blocks the tool call; PostToolUse cannot, # because the tool already ran, and exit 2 there only shows Claude the failure. CAN_BLOCK = {"PreToolUse": True, "PostToolUse": False} def mechanism(rule): """A step that must happen at a fixed point is a hook, not an instruction.""" return "hook" if rule["fixed_point"] else "instruction" def event(rule): """Naming the cadence is not enough: only one per-tool-call event can block.""" if mechanism(rule) != "hook": return None candidates = CADENCES[rule["per"]] if rule["must_block"]: blocking = [e for e in candidates if CAN_BLOCK.get(e)] if not blocking: raise ValueError(f"no event in the {rule['per']} cadence can block") return blocking[0] return candidates[-1] stage3 = { "mechanisms": {r["id"]: mechanism(r) for r in RULES}, "events": {r["id"]: event(r) for r in RULES if event(r)}, "why": "instruction files are context, not enforced configuration; a hook runs at a " "fixed lifecycle event regardless of what Claude decides", "async_note": "an async hook cannot enforce anything: the action it would have " "controlled has already completed", } # ------------------------------------------------------------------ Stage 4 def loads(mode): if mode == "bare": return {"repo_hooks": False, "repo_servers": False, "note": "bare mode never reads them"} return {"repo_hooks": True, "repo_servers": True, "note": "no workspace trust dialog and no per-server approval prompt"} def guard(repo_is_ours): if repo_is_ours: return [] return ["review the repository's settings files", "start with bare mode", "turn hooks off for that run"] stage4 = { "default_mode": loads("default"), "bare_mode": loads("bare"), "jobs": {"nightly-own-repo": guard(True), "triage-vendor-repo": guard(False)}, "pipeline_signal": "the process exit status: 0 on success, non-zero on failure", "where_failures_print": "a failure inside the run is printed as the result on stdout", } # ------------------------------------------------------------------ Stage 5 REPORTS = [ {"id": "R1", "symptom": "a setting is ignored"}, {"id": "R2", "symptom": "none of my settings apply since I edited the file"}, {"id": "R3", "symptom": "the hook prints a warning and nothing reacts"}, ] def first_reading(report): symptom = report["symptom"] if "none of my settings" in symptom: return ("strict JSON syntax error", "a comment or trailing comma is reported as a Settings Error at the next start") if "setting is ignored" in symptom: return ("the status command", "it separates a file that never loaded from a value a higher level took") return ("the hook exit code", "stderr from a hook that exits 0 reaches the debug log only; exit 2 on an " "event that can block also blocks, and on PostToolUse it only shows Claude " "the failure, because the tool already ran") def audience(exit_code): if exit_code == 0: return "nobody: stderr goes to the debug log only, never the transcript" if exit_code == 2: return ("Claude: the blocking reason on an event that can block, which JSON cannot " "override; on PostToolUse the failure is shown and nothing is blocked") return "the transcript: a hook error notice with the first line of stderr" PIPELINE = [ {"id": "J1", "symptom": "the job exited 143 with no result"}, {"id": "J2", "symptom": "the job waited, then ended with nothing"}, {"id": "J3", "symptom": "CI never runs on the commits the action pushes"}, ] def running_job(symptom): """Three failures of a job that was already running, not of its config.""" if "143" in symptom: return ("a termination signal", "the in-progress turn is left unfinished with no result recorded; " "SessionEnd hooks still run, and resuming continues that turn") if "waited" in symptom: return ("the background wait ceiling", "10 minutes of continuous idle waiting by default, then whatever is " "still running is stopped and its partial result dropped") return ("the default repository token", "the platform does not trigger workflows on commits made with it; stop " "passing it so the action authenticates as its own app") stage5 = { "readings": {r["id"]: first_reading(r) for r in REPORTS}, "pipeline": {j["id"]: running_job(j["symptom"]) for j in PIPELINE}, "audiences": {code: audience(code) for code in (0, 1, 2)}, "debug_flag": "does not print to the terminal; write the log to a path you name", "author_versus_teammate": "an interactive session holds hooks back until the folder is trusted, while an " "allow rule in the author's own untracked local file never needed that step", } # ------------------------------------------------------------- Stage checks # Each assertion restates the expected output printed under its stage above, so # running the file confirms the configuration came out the shape it claimed. CHECKS = [ ("stage 1 gives the managed value the contested key", stage1["spinnerTipsEnabled"] == "managed" and stage1["model"] == "user"), ("stage 1 combines the list key across all three files", len(stage1["permissions.allow"]) == 3), ("stage 2 keeps the secret out of every file", "environment variable expansion" in stage2["placed"]["server-api-key"] and stage2["placed"]["audit-hook"] == "managed settings"), ("stage 2 sends the shared server to project scope", "project scope" in stage2["placed"]["ticketing-server"]), ("stage 3 finds two instructions and two hooks", sorted(stage3["mechanisms"].values()) == ["hook", "hook", "instruction", "instruction"]), ("stage 3 reports after the edit and blocks before the commit", stage3["events"]["tests-after-edit"] == "PostToolUse" and stage3["events"]["block-vendor-commit"] == "PreToolUse"), ("stage 4 has the default mode running repository content", stage4["default_mode"]["repo_hooks"] and not stage4["bare_mode"]["repo_servers"]), ("stage 4 guards only the repository the team does not own", stage4["jobs"]["nightly-own-repo"] == [] and len(stage4["jobs"]["triage-vendor-repo"]) == 3), ("stage 5 routes each report to a different reading", len({r[0] for r in stage5["readings"].values()}) == 3), ("stage 5 says a hook that exits 0 reached nobody", stage5["audiences"][0].startswith("nobody")), ("stage 5 scopes exit 2 to the events that can block", "can block" in stage5["audiences"][2]), ("stage 5 separates the three running-job failures", len({p[0] for p in stage5["pipeline"].values()}) == 3 and stage5["pipeline"]["J1"][0] == "a termination signal"), ] if __name__ == "__main__": print(json.dumps({"stage1": stage1, "stage2": stage2, "stage3": stage3, "stage4": stage4, "stage5": stage5}, indent=2, default=str)) failed = [name for name, passed in CHECKS if not passed] if failed: raise SystemExit("failed: " + "; ".join(failed)) print("all stage checks passed")

What this project does not establish

The harness checks the shape of your configuration decisions, not that they are right for your organization. It cannot tell you what your administrators should enforce, whether a given hook is safe to run, or which of your repositories deserves the guarded treatment. Command hooks execute shell commands with your full user permissions, so review and test them before adding them, whatever this harness says about where they belong.

Several documented behaviours here carry a minimum version, and a few describe what changed before one. Check the current documentation before relying on a version-sensitive detail, because the retained snapshots were current at their recorded retrieval date and no later.

Ready to study Claude Certified Architect - Professional (CCAR-P)?

Practice tests, flashcards, and all study notes — free, no sign-up needed.

Start Studying — Free