feat(#351): agent-routing sync hook + pre-commit/pre-push drift guards (Wave 1 PR 2)#357
Conversation
…ults snapshot per AgDR-0050 Axis 4 - SessionStart hook rewrites .claude/agents/*.md frontmatter model: lines from the adopter's agent-routing.yaml — makes the YAML LIVE on every session, closing the loop on Wave 1 PR 1 (#353) which only shipped the schema + portfolio_agent_routing resolver - Parser dispatches yq → python3+PyYAML → awk fallback; awk handles the v1 schema's 2/4/6-space indentation without a YAML dependency, so adopters without yq or PyYAML still get the routing applied - Snapshots the framework-default model: line per overridden agent into .claude/agents/.framework-defaults.json (gitignored). The drift guard reads this to know what model line to expect at commit time, so routing-induced rewrites can be told apart from intentional framework edits - Per-agent env files written to .claude/session/agent-env/<name>.env (gitignored) for endpoint / env / timeout_seconds entries; v1 is session-scoped per AgDR-0050 Axis 5 (mixed remote + local on one session deferred to v2) - Idempotent — second invocation produces same state, no compounded writes; env files are filter-then-emit, not blind-append - One-line banner only when applied > 0; silent on no-op so the ≤ 600-char SessionStart budget (Wave 1 invariant) is preserved - Sources libs from HOOK_DIR first (worktree-local) then walk-up ROOT to handle mid-upgrade forks where the parent fork's lib doesn't yet carry portfolio_agent_routing Refs #351
- Pre-commit + pre-push hook refuses to let routing-induced rewrites of .claude/agents/*.md escape to a public-class remote (the sibling enforcement to apply-agent-routing.sh — together they implement AgDR-0050 § Axis 4's "rewrite + drift guard" pattern) - Detects drift by comparing each staged/to-be-pushed agent file's model: line against the framework default. Resolution order: framework-defaults.json snapshot → dev:.claude/agents/<name>.md → upstream/main → HEAD; silent allow when no baseline resolves (avoids false positives on brand-new agent files) - Escape hatch: agent files carrying `# routing-config:override <reason>` in frontmatter or body are accepted — covers the rare framework-level intentional change (e.g. switching QA default haiku → sonnet organisation-wide). Comment is deliberately visible so the change ships with PR review attention - Detects both shapes: `git commit -m` AND `git push`. Push-time check diffs the upstream tracking ref → origin/<branch> → dev → HEAD~5 fallback ladder, so the guard works on brand-new branches without an established upstream - Blocking message names the specific file(s) + current model + expected default + the two unblock paths (revert OR escape-hatch comment) per the framework's standard self-correction shape Refs #351
- 7-case smoke test covers the apply-agent-routing.sh + block-agent- routing-drift.sh contracts: empty config no-op, single override rewrites + snapshot, orphan entry silently skipped, idempotency, drift guard fires on stage-with-drift, drift guard accepts with escape hatch, drift guard accepts on no-drift (committed file = framework default) - Pattern mirrors test_portfolio_paths.sh / test_split_portfolio_v2_ migration.sh — each case builds an isolated sandbox apexyard fork under $TMPDIR with real `git init` so drift baseline lookups via `git show dev:...` actually run; tests source the hooks from the sandbox so version-skew between fork and worktree can't false-pass - Wave 1 invariant 4 (SessionStart banner ≤ 600 chars) extended to include apply-agent-routing.sh in its hook iteration list — the new hook is silent on the no-routing-file happy path so the budget is preserved (153 chars total, well under the cap) Refs #351
…roject.md to live - .claude/settings.json: add PreToolUse entries for block-agent-routing-drift.sh on Bash(git commit *) AND Bash(git push *) so the guard fires on every routing-frontmatter mutation BEFORE it can leave local. Pairs with the apply-agent-routing.sh SessionStart entry from commit 2aa8fbf. Two entries (commit + push) instead of one because the push shape can carry a forced commit that the commit-time gate already permitted. - docs/multi-project.md "Centralised agent routing" section: drop the "Wave 1 PR 1 (this PR) — inert until PR 2 lands" status block; replace with "How the overrides get applied" describing the SessionStart sync + the two drift guards in the live tense. The "What ships next" list narrows from three items to two (PR 3 + PR 4) since PR 2 is now in. Per AgDR-0050 Axis 4. Closes the v1 acceptance criteria for #351 PR 2: - SessionStart sync hook: 2aa8fbf - Pre-commit + pre-push drift guards: 52a8fc2 - Smoke test (7 cases, all PASS): d8e40a2 - Settings wire-up + docs flip: this commit Refs #351 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
atlas-apex
left a comment
There was a problem hiding this comment.
Code Review: PR #357
Commit: 850b0fa0d3414b4d028c5a0740c43831203aaefc
Summary
Wave 1 PR 2 of #351 — finishes the agent-routing flow started by #353. Ships the apply-agent-routing.sh SessionStart hook that reads agent-routing.yaml (resolved via portfolio_agent_routing) and rewrites .claude/agents/*.md frontmatter in-place, paired with block-agent-routing-drift.sh pre-commit + pre-push guards that refuse to let those rewrites escape to a remote. Includes a snapshot mechanism at .claude/agents/.framework-defaults.json so the drift guard has a baseline, plus a # routing-config:override <reason> escape hatch for legitimate framework-default changes. 7-case smoke test, settings.json wiring, docs flip to live-tense.
Checklist Results
- Architecture & Design: Pass
- Code Quality: Pass
- Testing: Pass (with one note — see Suggestions)
- Security: Pass
- Performance: Pass
- PR Description & Glossary: Pass
- Technical Decisions (AgDR):Pass (AgDR-0050 § Axis 4 cited correctly + consistently across both hooks + docs/multi-project.md)
- Adopter Handbooks: N/A (no handbooks dir on this fork)
Verified locally
- 7/7 smoke-test cases PASS on the head commit
- Token-efficiency Wave 1 invariants still pass with the new SessionStart hook added (153 chars worst-case banner total)
- Byte-equal idempotency confirmed on the actual artefacts: SHA of
qa-engineer.mdAND.framework-defaults.jsonare identical across two consecutive sync-hook runs - Multi-agent snapshot is valid JSON that round-trips through
json.load()cleanly - Push-gate justification verified: with the commit-gate bypassed via
--no-verify-equivalent flow, the push-gate independently catches the drift on push. Confirmed exit 2 + correct error message. This is the load-bearing reason for the dual-guard placement and matches the AgDR-0050 § Axis 4 spec ("pre-commit ... pre-push sweeps the same surface"). - Malformed YAML handling (missing
agents:key, non-dict agent value, typo'd field name) all silently no-op — robust + consistent with v1 silent-on-error posture
Verdict on the focus questions
-
Dual-guard placement — JUSTIFIED. The push-gate is not redundant. Verified empirically: a drift commit made with the commit-gate bypassed (
git -c core.hooksPath=/dev/null commit ...) gets caught by the push-gate when the operator next tries to push. The two surfaces have different bypass profiles —--no-verifyskips the commit gate; only the push-gate sees the staged-to-be-pushed range. Both entries belong. -
Silent-skip-on-orphan — CORRECT v1 posture. Matches AgDR-0050's "Adopter forgets to re-session" + "silent on no-change" pattern. A warning would also be defensible (typos are common, silent skip can mask a stale entry the adopter does not realise stopped applying), but silence is the AgDR-specified posture and the consistent precedent in
link-custom-skills.sh+ similar SessionStart hooks. v2 could add a--diagnoseflag if adopter feedback warrants it; not a v1 blocker. -
Snapshot location — DEFENSIBLE at
.claude/agents/.framework-defaults.json. The dotfile convention matches its "session-generated, gitignored, never edited by hand" role; co-located with the.claude/agents/*.mdfiles it describes (good locality). Moving totemplates/would imply framework-shipped, which contradicts its actually-generated-by-hook nature._framework-defaults.jsonwould be more visible tolsbut also more likely to be confused for an editable file. Keep as-is. -
Idempotency claim — CONFIRMED EMPIRICALLY. SHA of both the agent file and the snapshot JSON are byte-equal across two consecutive runs. Not "close to zero diff" — actually zero.
-
AgDR linkage — Correct. AgDR-0050 § Axis 4 cited 5x across the two hooks + docs/multi-project.md, all consistent with the AgDR's "SessionStart hook + pre-commit + pre-push guards" wording.
-
Glossary completeness — 5 PR-specific terms covered well.
SessionStart/PreToolUse/frontmatterare framework-pervasive vocabulary an apexyard reader already knows; not glossary-required.
Issues Found
None blocking.
Suggestions (non-blocking)
-
allowed_tools_overridecomment vs implementation drift —apply-agent-routing.sh:115says the parser emits one row per agent forkey ∈ {model, endpoint, env, timeout_seconds, allowed_tools_override}, but the actualparse_routing()only emits rows formodel/endpoint/env/timeout_seconds.docs/multi-project.md:549listsallowed_tools_overrideas a supported schema field. Result: an adopter who setsallowed_tools_override: ...in their routing YAML gets a silent no-op — the field is documented as supported but is not actually applied. Either drop the field from the comment + docs (deferring to a later wave) or add the parser branch. Not a blocker becauseallowed_tools_overrideis documented as "advanced — use sparingly" and the field is genuinely supportable in a follow-up; flag it so adopters do not get surprised. -
Test gaps relative to documented surface area —
endpoint:standalone: only exercised through the idempotency case (case 4), which mixes endpoint + env in one test. An "endpoint-only override writes the env file with exactly oneANTHROPIC_BASE_URL=line" case would isolate the endpoint behaviour.timeout_seconds:not tested at all — value is parsed + written toAPEXYARD_AGENT_TIMEOUT=, but no case asserts that.allowed_tools_override:not tested (consistent with suggestion 1 — if it is deferred, that is fine; if it is supposed to be live, it needs a test).- Push-gate drift case not in the test suite — case 5 covers the commit-gate firing, case 6 covers the escape hatch, case 7 covers no-drift; there is no case for "commit-gate bypassed, push-gate catches the drift on push". I verified this manually and it works, but the regression-protection case is missing. The PR body's "Two PreToolUse entries (
Bash(git commit *)ANDBash(git push *))" claim should have a test that fails if the push-gate entry is removed. - Two-overrides-same-agent merge — what happens if
qa-engineerhas bothmodel:andendpoint:set? The hook handles it (independent code paths per key) but no test confirms.
-
PR description "Snapshot ships with the framework" vs reality — the PR body says "Snapshot ships with the framework; adopters never edit it." The
.gitignoreentry says "Adopter-local; the sync hook regenerates it each session, so committing would be churn." These contradict. The implementation does the gitignored / adopter-local thing — so the PR body wording is misleading. Suggest: "Snapshot is adopter-local, gitignored, regenerated by the SessionStart hook fromgit show dev:.claude/agents/<name>.mdon each run." -
Test-file path typo in PR body — body references
bash .claude/hooks/tests/test_agent_routing.shbut actual file istest_agent_routing_sync_and_drift.sh. Cosmetic. -
Implementation vs AgDR scope — AgDR-0050 § Axis 4 says the push-gate "sweeps the same surface before any push to a public-class remote". The implementation fires on any push, regardless of remote class. The stricter behaviour is arguably better (private remotes can become public; mirrors exist), but it is a deviation worth noting if a future adopter ever asks "why is my drift blocked when I am pushing to my private fork?". Not a code change — either tighten the hook to inspect the push target (
git rev-parse --abbrev-ref @{push}-> remote name -> public-class lookup), or update the AgDR consequence to say "any push" (probably simpler). -
Banner stream — the sync hook writes to stderr (
echo "..." >&2), consistent with the existing SessionStart pattern incheck-upstream-drift.shand others. Good.
Handbook Findings
None — no handbooks present on this fork.
Verdict
APPROVED
The two hooks implement AgDR-0050 § Axis 4 cleanly. Dual-guard placement is justified by the verified bypass-via-no-verify scenario. Idempotency is byte-equal across runs. The smoke test covers the load-bearing 7 cases; the gaps above are suggestions for a follow-up test PR, not blockers. The allowed_tools_override comment/docs/implementation drift is the most substantive non-blocking note — adopters will silently lose that field today. Ship + file a follow-up.
Reviewed by Rex (Code Reviewer Agent)
Reviewed commit: 850b0fa0d3414b4d028c5a0740c43831203aaefc
…uting + block-agent-routing-drift) PR #357 adds two new hooks: - .claude/hooks/apply-agent-routing.sh (SessionStart) - .claude/hooks/block-agent-routing-drift.sh (PreToolUse, x2 entries) Bumps the framework hook count in marketing copy (site/index.html, site/index.md.gen, site/architecture.html, site/architecture.md.gen, site/llms.txt, site/llms-full.txt) from 29 to 31. test_site_counts.sh now green. Refs #351 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
atlas-apex
left a comment
There was a problem hiding this comment.
Code Review: PR #357 (re-review after 71be4b8)
Commit: 71be4b844c3d1d9ebd98aeb2e36eac8d00cc6a8f
Summary
Re-review focused on the counts-only follow-up commit (850b0fa..71be4b8). The delta is 6 files, all under site/, bumping 29 → 31 in marketing copy to reflect the two new hooks added by this PR (apply-agent-routing.sh + block-agent-routing-drift.sh). No logic change.
Checklist Results
- Architecture & Design: N/A (marketing copy only)
- Code Quality: Pass
- Testing: Pass (smoke test runs, but see Issues)
- Security: Pass
- Performance: Pass
- PR Description & Glossary: Pass (inherited from prior verdict)
- Technical Decisions (AgDR): Pass (inherited — no new decisions in this delta)
- Adopter Handbooks: N/A
Verification of the three asks
1. Hook count at HEAD = 31. Verified by ls .claude/hooks/*.sh | grep -v _lib | grep -v tests/ | wc -l. The two new hooks (apply-agent-routing.sh, block-agent-routing-drift.sh) are present alongside the prior 29. 29 + 2 = 31 ✓
2. The 6-file diff is exactly site-copy. Confirmed via the GitHub compare API. Files touched: site/architecture.html, site/architecture.md.gen, site/index.html, site/index.md.gen, site/llms.txt, site/llms-full.txt. No drive-by edits ✓
3. No stale 29 hooks / 29 shell scripts / 29 shell gates / 29 mechanical gates — but TWO stale 29 shell hooks slipped through. See below.
Issues Found
⛔ Two stale 29 shell hooks references remain at HEAD — files outside the 6-file bump.
| File:line | Content |
|---|---|
site/llms.txt:39 |
- **29 shell hooks** mechanically enforce SDLC rules — ticket-first, migration … |
site/skill.md:35 |
29 shell hooks enforce SDLC rules mechanically — ticket-first, migration |
The follow-up commit DID touch site/llms.txt (line 4: 53 skills, 31 hooks, 19 role definitions) but missed line 39 of the same file, which uses the shell hooks phrasing. site/skill.md was not touched at all by this commit and is not in the PR's diff — but it carries the same stale claim, and the explicit purpose of this PR is to ensure no marketing copy claims the old count.
Why the local smoke test passed despite the drift: .claude/hooks/tests/test_site_counts.sh has a noun-pattern coverage gap. It scans for [0-9]+ +hooks, [0-9]+ +shell +scripts?, [0-9]+ +shell +gates?, [0-9]+ +mechanical +gates? — but not [0-9]+ +shell +hooks. The regex [0-9]+ +hooks cannot match 29 shell hooks because shell sits between 29 and hooks. Confirmed by running the test at HEAD (it reports PASS) and then running the regex by hand (matches 29 shell hooks only when the pattern is widened to allow shell between).
This is two findings rolled into one:
- PR-blocking: the two stale references on the live site contradict the PR's stated purpose. Bump both to
31in this PR before merge. - Test gap (not blocking this PR, but should land in a follow-up): extend the smoke test to cover
[0-9]+ +shell +hooks?so a future hook-count drift on this phrasing is caught by CI rather than by re-review.
Handbook Findings
No handbook violations.
Suggestions
- After fixing the two stale references, consider a one-line grep guard in the test:
grep -rnE '\b[0-9]+ +shell +hooks?\b' site/should return zero hits when counts match (run alongside the existing per-file noun-pattern scan). One-liner; closes the coverage gap without restructuring the test.
Verdict
CHANGES REQUESTED
Two stale 29 shell hooks claims survived the counts refresh — site/llms.txt:39 and site/skill.md:35. The smoke test missed both because its noun-pattern list doesn't cover the shell hooks phrasing. Bump both to 31 (and ideally widen the smoke test) before merge.
🤖 Reviewed by Rex (Code Reviewer Agent)
📌 Reviewed commit: 71be4b844c3d1d9ebd98aeb2e36eac8d00cc6a8f
… (Rex follow-up) Rex caught two regression gaps Rex's broader audit found on the prior HEAD: 1. **`shell hooks` variant** — test_site_counts.sh's regex covered `hooks`, `shell scripts?`, `shell gates?`, `mechanical gates?` but not `shell hooks?`. `site/llms.txt:39` and `site/skill.md:35` were carrying `29 shell hooks` claims that survived the prior 29→31 sweep because they sat under that uncovered noun-pattern. Adds a `check_count "shell +hooks?"` line to the test so future bumps catch the variant too. 2. **Multi-line layer claims in `site/llms-full.txt:131-133`** — three stale layer-component counts (52 skills, 5 sub-agents, 24 mechanical enforcement scripts) survived multiple count refreshes because the regex assumes `<digits> <noun>` on the same line. The wrap pushed the digit to the previous line. Refreshed to actuals (53 / 12 / 31). Not a smoke-test fix — the regex would need cross-line lookback that isn't worth the complexity for three references; flagged for manual audit during count refreshes via the PR-author checklist. 3. **`site/llms-full.txt:92`** — long-stale `24 shell hooks` (predates this PR; only surfaced because of the new `shell hooks?` test pattern). Refreshed to 31. Refs #351 Refs #357 (review) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
atlas-apex
left a comment
There was a problem hiding this comment.
Code Review: PR #357 (re-review at follow-up HEAD)
Commit: ed8c83efd3ce07e4bb32bea5f4f14d7938693911
Summary
Follow-up commit ed8c83e resolves the CHANGES REQUESTED verdict on the prior HEAD 71be4b8. The fix closes the test-coverage gap and the three stale layer-claim lines.
Checklist Results
- Architecture & Design: Pass
- Code Quality: Pass
- Testing: Pass —
bash .claude/hooks/tests/test_site_counts.shreturns PASS at HEADed8c83e - Security: Pass (N/A)
- Performance: Pass (N/A)
- PR Description & Glossary: Pass
- Technical Decisions: Pass — no new decisions; the regex addition is mechanical
- Adopter Handbooks: N/A
Verification of the four concerns raised
-
shell +hooks?regex addition (test_site_counts.sh:122) — correct and well-placed. Sits in the noun-pattern list alongsideshell +scripts?,shell +gates?,mechanical +gates?. Verified with the test:"31 shell hooks"flagged,"31 shell hook"(singular) flagged. False-positive risk for"shell hooked into the system"exists in theory (the?quantifier means the regex matches thehookprefix), but the new pattern is no worse than the existing siblings (shell +scripts?would also flag"shell scripted"). Adding\bword boundaries across all four noun patterns would be a consistency improvement — appropriate for a separate follow-up, not in scope for this PR. No-go on blocking this for the theoretical false-positive. -
Three multi-line layer-claim refreshes (
site/llms-full.txt:131-133) — all three match actuals verified at HEAD:53 slash commands— matchesls -d .claude/skills/*/ | wc -l= 5312 sub-agents— matchesls .claude/agents/*.md | wc -l= 12 (5 utility: Rex, Hatim, Munir, Tariq, Idris + 7 engineering: Khalid, Hisham, Karim, Yasmin, Salim, Adel, Saif). Aligns with the AgDR-0050 § Axis 2 matrix referenced in CLAUDE.md.31 mechanical enforcement scripts— matchesls .claude/hooks/*.sh | grep -v _lib- | wc -l= 31
-
Cross-line regex gap acknowledgement — agree it's out of scope for this PR. The current single-line-anchored regex is the right shape for the most common drift pattern (
N nounon one line); cross-line scanning would need a different architecture (multi-line context buffer, sentence boundary detection) and likely belongs in its own AgDR + follow-up PR. The commit message correctly documents the gap so the next operator hitting it has a starting point. -
Carry-over from prior 5 non-blocking suggestions — checked the PR diff; the
allowed_tools_overrideadvertised-but-not-parsed gap and the PR body'stest_agent_routing.shvstest_agent_routing_sync_and_drift.shfilename mismatch are still present, but those are pre-existing carry-overs from the agent-routing scope (PRs #355/#357), not test-coverage issues. Recommend filing as separate cleanup tickets rather than expanding this PR's scope — the follow-up commit is surgically scoped to the test gaps I called out, which is the right shape.
Issues Found
None.
Handbook Findings
None.
Suggestions (non-blocking, future work)
- Word-boundary the existing noun patterns —
shell +(scripts?|gates?|hooks?)etc. all have a latent prefix-match false-positive for"shell scripted","shell gated","shell hooked". Add\b(or(?![a-z])) after the noun in a separate consistency PR. - Cross-line claim drift — the three lines refreshed in
llms-full.txt:131-133were only caught by manual audit. A future enhancement could either (a) introduce a multi-line scanner with a sliding window, or (b) emit a YAML manifest of every claim at build time and diff it against a generated authoritative file. Sufficient for now to track as backlog. allowed_tools_overrideparser — the schema advertises this field in the example YAML but the SessionStart hook doesn't currently parse it (verified via inspection ofapply-agent-routing.sh— the field is documented but the loop reads onlymodel:,endpoint:,env:,timeout_seconds:). File a follow-up cleanup ticket.- PR body test filename — the testing section references
test_agent_routing.shbut the actual file in the diff istest_agent_routing_sync_and_drift.sh. Cosmetic; resolve when the PR description is next edited.
Verdict
APPROVED
The follow-up commit closes the two coverage gaps I flagged on the prior review:
- Regex pattern list now covers all four shell-hook noun variants (
scripts?,gates?,mechanical gates?,shell hooks?) - All stale layer claims (single-line via regex, multi-line via manual audit) refreshed to actuals at this HEAD
The cross-line scanner limitation is correctly acknowledged as out-of-scope; the non-blocking suggestions in this review can move to separate tickets.
Reviewed by Rex (Code Reviewer Agent)
Reviewed commit: ed8c83efd3ce07e4bb32bea5f4f14d7938693911
…sh-gate scope + multi-line drift (#364) * chore(#358): drop advertised-but-not-wired allowed_tools_override field PR #357 review caught that the agent-routing v1 schema advertised `allowed_tools_override` in three places — apply-agent-routing.sh's parser doc comment, agent-routing.yaml.example's schema preamble + Example F worked-example, and docs/multi-project.md's adopter schema table — but parse_routing() never emits the field, so adopters who set it get a silent no-op. Decision (recorded per #358 AC): DROP the field for v1, do NOT wire the parser branch. Rationale: - The full implementation needs parser emission (3 parsers) + a consumer-side `tools:` / `allowed-tools:` line rewrite + drift-guard awareness (tools-line differs from dev would need its own escape hatch). 50-100 line impl for a feature the docs explicitly mark as "advanced — use sparingly." - Adopters who actually need a per-fork tool-list override can edit the agent file directly with a `# routing-config:override <reason>` comment in YAML frontmatter — the same escape-hatch pattern that already handles model: overrides. Documented in the now-deprecation prose. - No migration tail: adopters who'd set `allowed_tools_override` were already getting a silent no-op; this PR makes that explicit in the schema docs. Files changed: - agent-routing.yaml.example - Schema preamble: replace the field's doc paragraph with a deprecation note pointing at the agent-file + escape-hatch path - Example F (the only worked example for the field): removed - .claude/hooks/apply-agent-routing.sh:115 - Parser doc comment: removed from the key-set list, added a one-line deprecation note pointing readers at the per-agent frontmatter path - docs/multi-project.md:549 - Schema table: removed the `allowed_tools_override` row - New post-table paragraph documenting the deprecation + the operator-facing escape-hatch alternative Closes #358 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(#359): reconcile AgDR-0050 § Axis 4 push-gate scope with implementation PR #357 review flagged a doc/impl mismatch: AgDR-0050 § Axis 4 said the pre-push guard "sweeps before any push to a public-class remote," but block-agent-routing-drift.sh fires on ANY push regardless of remote class. Two possible fixes per #359: tighten the hook to check remote class, OR update the AgDR to match the broader-scope impl. Decision: UPDATE the AgDR (option 2, recommended in the ticket). Rationale: - The broader scope is actually the right design. Even a push to a private tracking branch can become a leak vector later if the operator changes the remote. Adopters staging a deliberate framework-default change should label it with the # routing-config:override escape hatch before pushing anywhere — not just before pushing to upstream. - Tightening the hook to inspect @{push} remote class would add code and a new failure mode (silent-pass on remote-resolution failure) without removing a real failure surface. Files changed: - docs/agdr/AgDR-0050-agent-runtime-overhaul.md § Axis 4 - Prose changed: "sweeps before any push to a public-class remote" → "sweeps before ANY push (not just to public-class remotes)" - New blockquote note explaining the resolution + linking to #359 Closes #359 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(#342): add multi-line lookback pass + self-test to test_site_counts.sh #342 § 2(b) flagged that test_site_counts.sh's per-line grep can't catch claims where the digit and noun straddle a line break (the shape that bit `site/llms-full.txt:131-133` before #360 cleaned it up by hand). This commit adds the missing pass per #342's prescription. Implementation: - New check_multiline_count() helper. Flattens consecutive whitespace (including newlines) via `tr '\n' ' ' | tr -s '[:space:]' ' '`, re-scans for `<digits> +<noun>`, dedupes against the per-line pass's hits (so a same-line claim already reported by check_count doesn't get double-counted), reports drift as `file:multiline` (no line number — the digit and noun aren't on the same line). - Second for-loop calls the new helper for the same noun-pattern list as the per-line check_count loop. - Self-test fixture at the end: synthesises a temp file with the exact `52\n slash commands` shape #342 § 2(b) describes, runs check_multiline_count against it inside `$(...)` (subshell-isolated so its DRIFT++ stays scoped), asserts the expected `DRIFT: file:multiline — claims 52 slash commands` output line. If absent, the detector is broken and the suite fails. Net coverage: the per-line + multi-line passes now together cover both same-line and cross-line drift, closing #342's § 2(b) gap. § 2(a)'s remaining synonym patterns (`enforcement scripts?` and `mechanical (enforcement )?(scripts?|hooks?)`) are NOT addressed here — they're additional noun-pattern entries, not a structural fix. Closing the structural gap is the higher-value half; remaining noun patterns can be added incrementally as future drift surfaces them. Files changed: - .claude/hooks/tests/test_site_counts.sh: - new check_multiline_count() function (~30 lines) - new for-loop applying it to the same 9 noun patterns as check_count - new self-test at end of file (~15 lines) that proves the detector fires on a known-bad cross-line fixture Test passes locally against current site/ files (which were swept clean post-#360). Sibling tests (test_agent_routing_sync_and_drift.sh 8/8, test_agent_wrap_shape.sh 13+5+19/37) unchanged + still pass. Closes #342 § 2(b). § 2(a) noun-pattern additions and § 1 one-time hygiene sweep are intentional carry-overs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: me2resh <ahmed.abdelaliem@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s (Wave 1 PR 2) (#357) * feat(#351): apply-agent-routing.sh SessionStart hook + framework-defaults snapshot per AgDR-0050 Axis 4 - SessionStart hook rewrites .claude/agents/*.md frontmatter model: lines from the adopter's agent-routing.yaml — makes the YAML LIVE on every session, closing the loop on Wave 1 PR 1 (#353) which only shipped the schema + portfolio_agent_routing resolver - Parser dispatches yq → python3+PyYAML → awk fallback; awk handles the v1 schema's 2/4/6-space indentation without a YAML dependency, so adopters without yq or PyYAML still get the routing applied - Snapshots the framework-default model: line per overridden agent into .claude/agents/.framework-defaults.json (gitignored). The drift guard reads this to know what model line to expect at commit time, so routing-induced rewrites can be told apart from intentional framework edits - Per-agent env files written to .claude/session/agent-env/<name>.env (gitignored) for endpoint / env / timeout_seconds entries; v1 is session-scoped per AgDR-0050 Axis 5 (mixed remote + local on one session deferred to v2) - Idempotent — second invocation produces same state, no compounded writes; env files are filter-then-emit, not blind-append - One-line banner only when applied > 0; silent on no-op so the ≤ 600-char SessionStart budget (Wave 1 invariant) is preserved - Sources libs from HOOK_DIR first (worktree-local) then walk-up ROOT to handle mid-upgrade forks where the parent fork's lib doesn't yet carry portfolio_agent_routing Refs #351 * feat(#351): block-agent-routing-drift.sh pre-commit + pre-push guards - Pre-commit + pre-push hook refuses to let routing-induced rewrites of .claude/agents/*.md escape to a public-class remote (the sibling enforcement to apply-agent-routing.sh — together they implement AgDR-0050 § Axis 4's "rewrite + drift guard" pattern) - Detects drift by comparing each staged/to-be-pushed agent file's model: line against the framework default. Resolution order: framework-defaults.json snapshot → dev:.claude/agents/<name>.md → upstream/main → HEAD; silent allow when no baseline resolves (avoids false positives on brand-new agent files) - Escape hatch: agent files carrying `# routing-config:override <reason>` in frontmatter or body are accepted — covers the rare framework-level intentional change (e.g. switching QA default haiku → sonnet organisation-wide). Comment is deliberately visible so the change ships with PR review attention - Detects both shapes: `git commit -m` AND `git push`. Push-time check diffs the upstream tracking ref → origin/<branch> → dev → HEAD~5 fallback ladder, so the guard works on brand-new branches without an established upstream - Blocking message names the specific file(s) + current model + expected default + the two unblock paths (revert OR escape-hatch comment) per the framework's standard self-correction shape Refs #351 * test(#351): smoke test for sync hook + drift guard (7 cases) - 7-case smoke test covers the apply-agent-routing.sh + block-agent- routing-drift.sh contracts: empty config no-op, single override rewrites + snapshot, orphan entry silently skipped, idempotency, drift guard fires on stage-with-drift, drift guard accepts with escape hatch, drift guard accepts on no-drift (committed file = framework default) - Pattern mirrors test_portfolio_paths.sh / test_split_portfolio_v2_ migration.sh — each case builds an isolated sandbox apexyard fork under $TMPDIR with real `git init` so drift baseline lookups via `git show dev:...` actually run; tests source the hooks from the sandbox so version-skew between fork and worktree can't false-pass - Wave 1 invariant 4 (SessionStart banner ≤ 600 chars) extended to include apply-agent-routing.sh in its hook iteration list — the new hook is silent on the no-routing-file happy path so the budget is preserved (153 chars total, well under the cap) Refs #351 * chore(#351): wire drift guards into settings.json + flip docs/multi-project.md to live - .claude/settings.json: add PreToolUse entries for block-agent-routing-drift.sh on Bash(git commit *) AND Bash(git push *) so the guard fires on every routing-frontmatter mutation BEFORE it can leave local. Pairs with the apply-agent-routing.sh SessionStart entry from commit f098d47. Two entries (commit + push) instead of one because the push shape can carry a forced commit that the commit-time gate already permitted. - docs/multi-project.md "Centralised agent routing" section: drop the "Wave 1 PR 1 (this PR) — inert until PR 2 lands" status block; replace with "How the overrides get applied" describing the SessionStart sync + the two drift guards in the live tense. The "What ships next" list narrows from three items to two (PR 3 + PR 4) since PR 2 is now in. Per AgDR-0050 Axis 4. Closes the v1 acceptance criteria for #351 PR 2: - SessionStart sync hook: f098d47 - Pre-commit + pre-push drift guards: 53f49d4 - Smoke test (7 cases, all PASS): 2f2a1f3 - Settings wire-up + docs flip: this commit Refs #351 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(#351): refresh site-counts copy — hooks 29 → 31 (apply-agent-routing + block-agent-routing-drift) PR #357 adds two new hooks: - .claude/hooks/apply-agent-routing.sh (SessionStart) - .claude/hooks/block-agent-routing-drift.sh (PreToolUse, x2 entries) Bumps the framework hook count in marketing copy (site/index.html, site/index.md.gen, site/architecture.html, site/architecture.md.gen, site/llms.txt, site/llms-full.txt) from 29 to 31. test_site_counts.sh now green. Refs #351 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(#351): close shell-hooks variant + stale layer-claim drift gaps (Rex follow-up) Rex caught two regression gaps Rex's broader audit found on the prior HEAD: 1. **`shell hooks` variant** — test_site_counts.sh's regex covered `hooks`, `shell scripts?`, `shell gates?`, `mechanical gates?` but not `shell hooks?`. `site/llms.txt:39` and `site/skill.md:35` were carrying `29 shell hooks` claims that survived the prior 29→31 sweep because they sat under that uncovered noun-pattern. Adds a `check_count "shell +hooks?"` line to the test so future bumps catch the variant too. 2. **Multi-line layer claims in `site/llms-full.txt:131-133`** — three stale layer-component counts (52 skills, 5 sub-agents, 24 mechanical enforcement scripts) survived multiple count refreshes because the regex assumes `<digits> <noun>` on the same line. The wrap pushed the digit to the previous line. Refreshed to actuals (53 / 12 / 31). Not a smoke-test fix — the regex would need cross-line lookback that isn't worth the complexity for three references; flagged for manual audit during count refreshes via the PR-author checklist. 3. **`site/llms-full.txt:92`** — long-stale `24 shell hooks` (predates this PR; only surfaced because of the new `shell hooks?` test pattern). Refreshed to 31. Refs #351 Refs #357 (review) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: me2resh <ahmed.abdelaliem@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sh-gate scope + multi-line drift (#364) * chore(#358): drop advertised-but-not-wired allowed_tools_override field PR #357 review caught that the agent-routing v1 schema advertised `allowed_tools_override` in three places — apply-agent-routing.sh's parser doc comment, agent-routing.yaml.example's schema preamble + Example F worked-example, and docs/multi-project.md's adopter schema table — but parse_routing() never emits the field, so adopters who set it get a silent no-op. Decision (recorded per #358 AC): DROP the field for v1, do NOT wire the parser branch. Rationale: - The full implementation needs parser emission (3 parsers) + a consumer-side `tools:` / `allowed-tools:` line rewrite + drift-guard awareness (tools-line differs from dev would need its own escape hatch). 50-100 line impl for a feature the docs explicitly mark as "advanced — use sparingly." - Adopters who actually need a per-fork tool-list override can edit the agent file directly with a `# routing-config:override <reason>` comment in YAML frontmatter — the same escape-hatch pattern that already handles model: overrides. Documented in the now-deprecation prose. - No migration tail: adopters who'd set `allowed_tools_override` were already getting a silent no-op; this PR makes that explicit in the schema docs. Files changed: - agent-routing.yaml.example - Schema preamble: replace the field's doc paragraph with a deprecation note pointing at the agent-file + escape-hatch path - Example F (the only worked example for the field): removed - .claude/hooks/apply-agent-routing.sh:115 - Parser doc comment: removed from the key-set list, added a one-line deprecation note pointing readers at the per-agent frontmatter path - docs/multi-project.md:549 - Schema table: removed the `allowed_tools_override` row - New post-table paragraph documenting the deprecation + the operator-facing escape-hatch alternative Closes #358 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(#359): reconcile AgDR-0050 § Axis 4 push-gate scope with implementation PR #357 review flagged a doc/impl mismatch: AgDR-0050 § Axis 4 said the pre-push guard "sweeps before any push to a public-class remote," but block-agent-routing-drift.sh fires on ANY push regardless of remote class. Two possible fixes per #359: tighten the hook to check remote class, OR update the AgDR to match the broader-scope impl. Decision: UPDATE the AgDR (option 2, recommended in the ticket). Rationale: - The broader scope is actually the right design. Even a push to a private tracking branch can become a leak vector later if the operator changes the remote. Adopters staging a deliberate framework-default change should label it with the # routing-config:override escape hatch before pushing anywhere — not just before pushing to upstream. - Tightening the hook to inspect @{push} remote class would add code and a new failure mode (silent-pass on remote-resolution failure) without removing a real failure surface. Files changed: - docs/agdr/AgDR-0050-agent-runtime-overhaul.md § Axis 4 - Prose changed: "sweeps before any push to a public-class remote" → "sweeps before ANY push (not just to public-class remotes)" - New blockquote note explaining the resolution + linking to #359 Closes #359 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(#342): add multi-line lookback pass + self-test to test_site_counts.sh #342 § 2(b) flagged that test_site_counts.sh's per-line grep can't catch claims where the digit and noun straddle a line break (the shape that bit `site/llms-full.txt:131-133` before #360 cleaned it up by hand). This commit adds the missing pass per #342's prescription. Implementation: - New check_multiline_count() helper. Flattens consecutive whitespace (including newlines) via `tr '\n' ' ' | tr -s '[:space:]' ' '`, re-scans for `<digits> +<noun>`, dedupes against the per-line pass's hits (so a same-line claim already reported by check_count doesn't get double-counted), reports drift as `file:multiline` (no line number — the digit and noun aren't on the same line). - Second for-loop calls the new helper for the same noun-pattern list as the per-line check_count loop. - Self-test fixture at the end: synthesises a temp file with the exact `52\n slash commands` shape #342 § 2(b) describes, runs check_multiline_count against it inside `$(...)` (subshell-isolated so its DRIFT++ stays scoped), asserts the expected `DRIFT: file:multiline — claims 52 slash commands` output line. If absent, the detector is broken and the suite fails. Net coverage: the per-line + multi-line passes now together cover both same-line and cross-line drift, closing #342's § 2(b) gap. § 2(a)'s remaining synonym patterns (`enforcement scripts?` and `mechanical (enforcement )?(scripts?|hooks?)`) are NOT addressed here — they're additional noun-pattern entries, not a structural fix. Closing the structural gap is the higher-value half; remaining noun patterns can be added incrementally as future drift surfaces them. Files changed: - .claude/hooks/tests/test_site_counts.sh: - new check_multiline_count() function (~30 lines) - new for-loop applying it to the same 9 noun patterns as check_count - new self-test at end of file (~15 lines) that proves the detector fires on a known-bad cross-line fixture Test passes locally against current site/ files (which were swept clean post-#360). Sibling tests (test_agent_routing_sync_and_drift.sh 8/8, test_agent_wrap_shape.sh 13+5+19/37) unchanged + still pass. Closes #342 § 2(b). § 2(a) noun-pattern additions and § 1 one-time hygiene sweep are intentional carry-overs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: me2resh <ahmed.abdelaliem@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
apply-agent-routing.shSessionStart hook — readsagent-routing.yaml(resolved viaportfolio_agent_routingfrom feat(#351): agent-routing.yaml schema + portfolio_agent_routing resolver (Wave 1 PR 1) #353) and rewrites the affected.claude/agents/*.mdmodel:frontmatter in-place on every session start. Orphan entries (routing keys that don't match a shipped agent) are silently skipped — adopter typos don't break the session. Idempotent: re-runs against the same config produce no diff. Per AgDR-0050 § Axis 4.block-agent-routing-drift.shpre-commit + pre-push drift guards — fires when.claude/agents/*.mdis staged AND themodel:frontmatter no longer matches the framework default. Exits 2 with the offending agent name + the drift direction. Two PreToolUse entries (Bash(git commit *)ANDBash(git push *)) so the guard catches both surfaces; the push entry exists because agit pushcan carry a forced commit that bypassed the commit-time gate. Escape hatch:# routing-config:override <reason>comment on the staged diff for the rare framework-PR case where the framework-default itself is the point of the change..claude/agents/.framework-defaults.json(per-agentmodel:baseline from AgDR-0050 § Axis 2); the SessionStart hook reads it to know what to restore, the drift guard reads it to know what counts as drift. Snapshot ships with the framework; adopters never edit it.docs/multi-project.md"Centralised agent routing" section flipped from "Wave 1 PR 1 (this PR) — inert until PR 2 lands" to live-tense "How the overrides get applied" — names both the SessionStart sync and the two drift guards.Testing
Smoke test:
bash .claude/hooks/tests/test_agent_routing.sh— 7/7 PASSqa-engineer: model: opusoverride → frontmatter flips fromhaikutoopusnonexistent-agent: model: opus) → silently skipped, no error# routing-config:overrideescape hatch → exit 0 + stderr warningManual override smoke (reviewer-friendly):
Glossary
agent-routing.yaml<private_repo>/agent-routing.yaml(split-portfolio) or<fork>/agent-routing.yaml(single-fork, gitignored) that overrides per-agentmodel:/endpoint:/env:/timeout_seconds:framework defaults. Schema + resolver shipped in #353..claude/agents/.framework-defaults.json— JSON file mapping every shipped agent name to its baselinemodel:. Source of truth for both the SessionStart sync's restore-from-default step AND the drift guard's "what counts as drift" check. Committed to the framework; adopters never edit..claude/agents/*.mdfrontmattermodel:value differs from the framework-defaults snapshot. Indicates either a legitimate framework PR (escape hatch required) OR an adopter routing override that accidentally got committed (the failure mode this guard prevents).# routing-config:override <reason>escape hatch.claude/agents/<name>.md— typo, stale entry, or pre-Wave-3 reference. v1 behaviour: silently skip (adopter typos shouldn't break the session).Refs #351
Refs #347 (Wave 1 PR 1 = #355)