Skip to content

feat(#351): agent-routing sync hook + pre-commit/pre-push drift guards (Wave 1 PR 2)#357

Merged
atlas-apex merged 6 commits into
devfrom
feature/GH-351-agent-routing-sync-hook-and-drift-guards
May 21, 2026
Merged

feat(#351): agent-routing sync hook + pre-commit/pre-push drift guards (Wave 1 PR 2)#357
atlas-apex merged 6 commits into
devfrom
feature/GH-351-agent-routing-sync-hook-and-drift-guards

Conversation

@atlas-apex

Copy link
Copy Markdown
Collaborator

Summary

  • apply-agent-routing.sh SessionStart hook — reads agent-routing.yaml (resolved via portfolio_agent_routing from feat(#351): agent-routing.yaml schema + portfolio_agent_routing resolver (Wave 1 PR 1) #353) and rewrites the affected .claude/agents/*.md model: 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.sh pre-commit + pre-push drift guards — fires when .claude/agents/*.md is staged AND the model: frontmatter no longer matches the framework default. Exits 2 with the offending agent name + the drift direction. Two PreToolUse entries (Bash(git commit *) AND Bash(git push *)) so the guard catches both surfaces; the push entry exists because a git push can 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.
  • Framework-defaults snapshot lives at .claude/agents/.framework-defaults.json (per-agent model: 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 PASS

    1. Empty config → no-op (no agent files rewritten)
    2. qa-engineer: model: opus override → frontmatter flips from haiku to opus
    3. Orphan entry (nonexistent-agent: model: opus) → silently skipped, no error
    4. Idempotent re-run against an already-applied config → zero diff
    5. Drift guard FIRES on staged drift without escape hatch → exit 2
    6. Drift guard ACCEPTS staged drift WITH # routing-config:override escape hatch → exit 0 + stderr warning
    7. Drift guard ACCEPTS clean-default commit (no routing-frontmatter changes) → exit 0
  • Manual override smoke (reviewer-friendly):

    cat > agent-routing.yaml <<YAML
    agents:
      qa-engineer:
        model: opus
    YAML
    bash .claude/hooks/apply-agent-routing.sh
    grep '^model:' .claude/agents/qa-engineer.md   # → model: opus
    
    git checkout .claude/agents/qa-engineer.md     # restore default
    # The drift guard then accepts the next commit because no routing-frontmatter is staged.

Glossary

Term Definition
agent-routing.yaml Adopter-authored YAML at <private_repo>/agent-routing.yaml (split-portfolio) or <fork>/agent-routing.yaml (single-fork, gitignored) that overrides per-agent model: / endpoint: / env: / timeout_seconds: framework defaults. Schema + resolver shipped in #353.
Framework defaults snapshot .claude/agents/.framework-defaults.json — JSON file mapping every shipped agent name to its baseline model:. 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.
Drift Any state where a staged .claude/agents/*.md frontmatter model: 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 A literal comment on the staged diff that opts the commit out of the drift gate. Used when a framework PR is intentionally changing a framework default (rare). Visible + auditable per AgDR-0040's escape-hatch convention pattern.
Orphan entry A routing-config key that doesn't match any shipped .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)

me2resh and others added 4 commits May 20, 2026 20:36
…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 atlas-apex left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md AND .framework-defaults.json are 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

  1. 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-verify skips the commit gate; only the push-gate sees the staged-to-be-pushed range. Both entries belong.

  2. 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 --diagnose flag if adopter feedback warrants it; not a v1 blocker.

  3. 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/*.md files it describes (good locality). Moving to templates/ would imply framework-shipped, which contradicts its actually-generated-by-hook nature. _framework-defaults.json would be more visible to ls but also more likely to be confused for an editable file. Keep as-is.

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

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

  6. Glossary completeness — 5 PR-specific terms covered well. SessionStart / PreToolUse / frontmatter are framework-pervasive vocabulary an apexyard reader already knows; not glossary-required.

Issues Found

None blocking.

Suggestions (non-blocking)

  1. allowed_tools_override comment vs implementation driftapply-agent-routing.sh:115 says the parser emits one row per agent for key ∈ {model, endpoint, env, timeout_seconds, allowed_tools_override}, but the actual parse_routing() only emits rows for model/endpoint/env/timeout_seconds. docs/multi-project.md:549 lists allowed_tools_override as a supported schema field. Result: an adopter who sets allowed_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 because allowed_tools_override is documented as "advanced — use sparingly" and the field is genuinely supportable in a follow-up; flag it so adopters do not get surprised.

  2. 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 one ANTHROPIC_BASE_URL= line" case would isolate the endpoint behaviour.
    • timeout_seconds: not tested at all — value is parsed + written to APEXYARD_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 *) AND Bash(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-engineer has both model: and endpoint: set? The hook handles it (independent code paths per key) but no test confirms.
  3. PR description "Snapshot ships with the framework" vs reality — the PR body says "Snapshot ships with the framework; adopters never edit it." The .gitignore entry 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 from git show dev:.claude/agents/<name>.md on each run."

  4. Test-file path typo in PR body — body references bash .claude/hooks/tests/test_agent_routing.sh but actual file is test_agent_routing_sync_and_drift.sh. Cosmetic.

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

  6. Banner stream — the sync hook writes to stderr (echo "..." >&2), consistent with the existing SessionStart pattern in check-upstream-drift.sh and 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 atlas-apex left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 31 in 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 atlas-apex left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.sh returns PASS at HEAD ed8c83e
  • 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

  1. shell +hooks? regex addition (test_site_counts.sh:122) — correct and well-placed. Sits in the noun-pattern list alongside shell +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 the hook prefix), but the new pattern is no worse than the existing siblings (shell +scripts? would also flag "shell scripted"). Adding \b word 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.

  2. Three multi-line layer-claim refreshes (site/llms-full.txt:131-133) — all three match actuals verified at HEAD:

    • 53 slash commands — matches ls -d .claude/skills/*/ | wc -l = 53
    • 12 sub-agents — matches ls .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 — matches ls .claude/hooks/*.sh | grep -v _lib- | wc -l = 31
  3. 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 noun on 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.

  4. Carry-over from prior 5 non-blocking suggestions — checked the PR diff; the allowed_tools_override advertised-but-not-parsed gap and the PR body's test_agent_routing.sh vs test_agent_routing_sync_and_drift.sh filename 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)

  1. Word-boundary the existing noun patternsshell +(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.
  2. Cross-line claim drift — the three lines refreshed in llms-full.txt:131-133 were 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.
  3. allowed_tools_override parser — the schema advertises this field in the example YAML but the SessionStart hook doesn't currently parse it (verified via inspection of apply-agent-routing.sh — the field is documented but the loop reads only model:, endpoint:, env:, timeout_seconds:). File a follow-up cleanup ticket.
  4. PR body test filename — the testing section references test_agent_routing.sh but the actual file in the diff is test_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

@atlas-apex atlas-apex merged commit 0af3288 into dev May 21, 2026
5 checks passed
@atlas-apex atlas-apex deleted the feature/GH-351-agent-routing-sync-hook-and-drift-guards branch May 21, 2026 00:18
atlas-apex added a commit that referenced this pull request May 21, 2026
…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>
me2resh added a commit that referenced this pull request Jun 5, 2026
…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>
me2resh added a commit that referenced this pull request Jun 5, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants