feat: extract shared hook-utils.sh to an authoring-time SSOT#24
Conversation
Fourth migrated plugin: formats and lints Python (.py/.pyi) on Write|Edit via the consuming repo's own Ruff config. Follows the biome-format model: - Opt-in gate on a governing Ruff config (.ruff.toml, ruff.toml, or pyproject.toml with [tool.ruff]) — a pyproject.toml without [tool.ruff] does not opt in, mirroring Ruff's own discovery. No config -> untouched. - ruff check --fix (safe fixes only) + ruff format, then a --no-fix verify pass whose concise diagnostics surface as advisory additionalContext. - --unfixable F401 protects just-added imports from mid-edit auto-deletion (extends, not replaces, the consumer's own unfixable list — verified against ruff 0.15). - Version-aware syntax errors come from Ruff's own parser (target-version / requires-python floor), replacing the source repo's separate AST-check layer. - --force-exclude honors consumer excludes for explicitly-passed paths; --no-cache keeps .ruff_cache out of consumer repos. - Binary resolved from the repo's .venv (bin/ or Scripts/) or PATH; never downloaded. Advisory: always exits 0. Kill switch HOOK_RUFF_FORMAT_ENABLED; telemetry envelope contract v1.0 unchanged. Verified: 43/43 hook contract tests + 37/37 lib tests against ruff 0.15.20 (Windows Git Bash); claude plugin validate passes for the plugin and the catalog (--strict). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NNwc8U9MbgWsd5sbrfpPh
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NNwc8U9MbgWsd5sbrfpPh
The membership check in hook::read_file_path compared lexically normalized paths, so an in-project symlink pointing outside CLAUDE_PROJECT_DIR passed the prefix check while Ruff's writes landed outside the workspace (Cursor security review on PR #23, MEDIUM). Canonicalize both sides of the comparison via realpath/readlink -f before normalizing: escaping file and directory symlinks are now rejected, and a project root reached through a symlink (e.g. macOS /tmp) no longer causes false rejections. Hosts with no resolver fall back to the historical lexical comparison rather than silently disabling the hook. Adds six guard tests (lexical accept/reject plus four symlink cases, SKIP-passing where the host cannot create symlinks). Both scripts also pick up this repo's canonical shfmt style (.editorconfig: 2-space, no switch-case indent); the copies in the other plugins are synced in a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLwWqDP9dvakPSnnqRxwW4
Four plugins carried byte-identical hooks/hook-utils.sh copies, meeting the playbook's Rule-of-Three trigger. Establish lib/hook-utils.sh as the single source of truth, synced into each carrying plugin by scripts/sync-hook-utils.sh; plugins stay self-contained under cache isolation with no runtime coupling and unchanged install UX. The new hook-utils-sync CI lane fails on copy drift and, on PRs, when the lib changed without a version bump in every carrying plugin (the plugin version is the update cache key). All four plugin versions are bumped: the shared header no longer claims markdown-formatter heritage and the double-source guard drops the _MDFMT prefix. The dependency-plugin and marketplace-symlink alternatives are recorded in the playbook as rejected/deferred with doc citations and revisit triggers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NNwc8U9MbgWsd5sbrfpPh
|
Claude finished @kyle-sexton's task in 2m 45s —— View job PR #24 Review — feat: extract shared hook-utils.sh to an authoring-time SSOT
SummaryThe architecture decision is correct and well-justified: a single authoring-time SSOT with plain runtime copies is the right tradeoff given the constraints (cache isolation makes cross-plugin file references unviable; symlinks are skipped by Medium1.
head_version=$(jq -r '.version // empty' "$manifest")If a plugin has a if [[ ! -f "$manifest" ]]; then
echo "MISSING MANIFEST: $manifest" >&2
stale=1
continue
fi
head_version=$(jq -r '.version // empty' "$manifest")2. [[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0
readonly _HOOK_UTILS_LOADED=1The rename from # Non-namespaced: all copies are byte-identical; hooks run in isolated subprocesses.
[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0Low / Observations (no action required)3. Sync mode: no early existence check on
4. Deferred test consolidation is correctly scoped The playbook correctly records the 4 divergent 5.
Positive callouts
|
# Conflicts: # plugins/ruff-format/.claude-plugin/plugin.json # plugins/ruff-format/hooks/hook-utils.sh # plugins/ruff-format/hooks/ruff-format.sh # plugins/ruff-format/hooks/ruff-format.test.sh
|
Claude finished @kyle-sexton's task in 2m 14s —— View job PR #24 Review — feat: extract shared hook-utils.sh to an authoring-time SSOT
What changed in this syncThis Merge resolution is correct:
Medium (both still open from prior review)1.
head_version=$(jq -r '.version // empty' "$manifest")The base-ref path two lines up is guarded ( if [[ ! -f "$manifest" ]]; then
echo "MISSING MANIFEST: $manifest" >&2
stale=1
continue
fi
head_version=$(jq -r '.version // empty' "$manifest")2. # Guard against double-sourcing.
[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0
readonly _HOOK_UTILS_LOADED=1The rename from # Non-namespaced: all copies are byte-identical; hooks run in isolated subprocesses.
[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0Positive callouts
VerdictThis PR is well-architected and the merge conflict was resolved correctly. The two medium findings remain open and should be addressed before merge — they are both small, self-contained fixes. |
|
Claude finished @kyle-sexton's task in 1m 33s —— View job PR #24 Review — feat: extract shared hook-utils.sh to an authoring-time SSOT
Status of Prior FindingsThis PR merged with both medium findings from prior review rounds still open. They are now in Medium (both unresolved — now in main)1.
head_version=$(jq -r '.version // empty' "$manifest")The base-ref lookup two lines up at L57 is guarded with if [[ ! -f "$manifest" ]]; then
echo "MISSING MANIFEST: $manifest" >&2
stale=1
continue
fi
head_version=$(jq -r '.version // empty' "$manifest")2. # Guard against double-sourcing.
[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0
readonly _HOOK_UTILS_LOADED=1The comment explains what but not why the variable is intentionally non-namespaced. Without the reasoning, a future contributor will see a suspiciously global variable and re-namespace it (e.g. back to # Non-namespaced: all copies are byte-identical; hooks run in isolated subprocesses.
[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0Observations (unchanged from prior reviews)
Positive callouts (carry-forward)All architecture decisions and implementation quality notes from prior reviews stand:
VerdictThe two medium findings are small, self-contained fixes. Finding #2 ( |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7bcb499. Configure here.
| echo "Bump the version of every carrying plugin so consumers receive the lib change." >&2 | ||
| exit 1 | ||
| fi | ||
| echo "Lib changed vs $base and every carrying plugin bumped its version." |
There was a problem hiding this comment.
Bad ref skips bump gate
Medium Severity
In --check-bump, a failed git diff against the base ref is treated like a lib change, and carriers whose git show for plugin.json fails are skipped like new plugins. With an unresolvable base ref or unreadable base manifests, the script can exit 0 without detecting stale versions even when lib/hook-utils.sh changed and carrying plugins were not bumped.
Reviewed by Cursor Bugbot for commit 7bcb499. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7bcb499929
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ;; | ||
| --check-bump) | ||
| base="${2:?usage: sync-hook-utils.sh --check-bump <base-ref>}" | ||
| if git diff --quiet "$base" -- "$src"; then |
There was a problem hiding this comment.
Fail closed when the bump base is unreadable
When the supplied base ref is missing or otherwise unreadable, this git diff --quiet runs inside an if, so Git's fatal exit is treated the same as “the lib changed”; the later git show "$base:$manifest" ... || true then makes every base version empty and skips all manifests. I verified scripts/sync-hook-utils.sh --check-bump no-such-ref prints Git's fatal error but still exits 0, which means a PR whose base ref was not fetched/resolved could pass the version-bump gate with stale plugin versions instead of failing closed.
Useful? React with 👍 / 👎.
## Summary Closes the follow-up recorded in the shared-code decision record (#24): the four per-plugin `hooks/hook-utils.test.sh` suites had diverged — three were logic-identical (differing only in hook-id/extension fixtures), ruff-format's added six `hook::read_file_path` membership-guard cases — despite testing the byte-identical lib. - The union (43 cases, ruff-format's superset with a neutralized `sample-hook` id and a lib-scoped header) now lives at `lib/hook-utils.test.sh`, beside the canonical source. - The `hook-utils-sync` CI lane runs the suite — since the lane already enforces that every plugin copy is byte-identical to the source, testing the source covers the copies. Lib unit tests now run in CI for the first time (previously manual-only). - The four per-plugin copies are deleted; plugins keep only their own black-box hook contract tests (`<plugin>.test.sh`). - All four plugin versions bump patch so installed copies drop the removed file; playbook follow-up note replaced with the settled state. ## Verification - `lib/hook-utils.test.sh`: 43/43 pass. - Remaining contract suites pass locally: bash-lint 31/31, markdown-formatter 41/41, ruff-format 46/46 (biome-format skips without a local Biome binary, as before). - `claude plugin validate` ×4 + `--strict` catalog pass; shellcheck, actionlint, markdownlint clean. - No references to the removed files anywhere in the repo. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012NNwc8U9MbgWsd5sbrfpPh <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Test and packaging-only changes; no shared runtime library behavior is modified in this diff. > > **Overview** > **Consolidates** duplicated `hook-utils` unit tests into a single suite at `lib/hook-utils.test.sh` and **removes** the four per-plugin `hooks/hook-utils.test.sh` copies (bash-lint, biome-format, markdown-formatter, ruff-format). > > The shared suite **generalizes** telemetry fixtures to a neutral `sample-hook` id and documents that byte-identical plugin copies are covered by testing the canonical lib. The **`hook-utils-sync` CI job** now **runs** `bash lib/hook-utils.test.sh` after the sync check, so lib unit tests are enforced in CI for the first time. > > **Docs** in `MIGRATION-PLAYBOOK.md` replace the deferred “merge the four suites” note with the settled single-suite model. **Patch version bumps** on all four carrying plugins so installs drop the removed test files from cached plugin trees. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fed7c3d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
) ## Summary Closes decision #55 (`metadata-claude-md-encode-github-conventions`) for this repo. Adds a **Branching & PRs** section to `CLAUDE.md` mirroring medley's citation pattern (`AGENTS.md` "Branching & PRs": terse rule inline + one pointer to the full convention source). - Inline rule: PRs required, squash merge, Conventional Commits branch `<type>/<description>` — already enforced here by `.github/workflows/pr-title.yml`. - Pointer: `melodic-software/standards` `conventions/` as the org-wide convention home. The pointer targets the `conventions/` directory rather than `conventions/engineering/naming.md` specifically: `naming.md` today covers only Clean-Code identifier naming (classes, files, variables), not branch/PR grammar. Promoting medley's branch-naming grammar into `standards` org-wide is tracked separately as decision #24 (`naming-branch-convention-org-wide`, WP-11). Once that lands, `naming.md` (or wherever it lands) becomes the sharper anchor — the directory-level pointer stays valid either way per `standards`' `conventions/engineering/reference-dont-duplicate.md` (cite a stable anchor, never a copy that can drift). Decisions Log: https://claude.ai/code/artifact/232ecdce-8316-4880-8c0a-dc3c7dcf3a63 ## Test plan - [x] `npx markdownlint-cli2 CLAUDE.md` — 0 errors - [x] Manual read-through against medley's `AGENTS.md` "Branching & PRs" and `.claude/rules/worktree/{branch-naming,pr-conventions}.md` for pattern fidelity 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Us4azyXMBuXGV2sshG7UuB --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…e IV, flip pre-build (#939) Dated Phase-4 note: operator ruling (2026-07-22) to move at maximum speed within the predicate's intent. Calendar gates stay binding; sanctioned levers are front-loaded seeding (genuine items only), parallel Phase IV ignition (gate already met), and pre-built Phase III flip machinery. Records same-day watch activity: drain PRs #23/#24/#26 + Dependabot #25 merged in kyle-sexton/autonomy-demo-scratch, queue reseeded (scratch#27–#31), predicate completions=6. ## Related - kyle-sexton/autonomy-demo-scratch#27, #28, #29, #30, #31. No linked issue — status-note documentation; nothing in this repo closes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>




Summary
Four plugins carry byte-identical
hooks/hook-utils.shcopies — the playbook's Rule-of-Three trigger. Per the decision reached with fresh-docs research (plugins-reference + plugin-dependencies, fetched 2026-07-03), the shared lib becomes an authoring-time SSOT with plain runtime copies:lib/hook-utils.shis the canonical source;scripts/sync-hook-utils.shpropagates it into every carrying plugin (sync/--check/--check-bump <ref>modes).hook-utils-syncCI lane (wired intoci-status): fails on copy drift, and on PRs fails when the lib changed without a version bump in every carrying plugin — the pluginversionis the update cache key, so an unbumped plugin never delivers the change.--plugin-dir/local installs; Windows-fragile).All four plugin versions bumped (patch): the shared header no longer claims markdown-formatter heritage and the sourcing guard drops the
_MDFMTprefix.Stacked on #23 (
feat/ruff-format-plugin) — do not merge into #23; retarget tomainafter #23 merges.Verification
scripts/sync-hook-utils.sh --checkpasses; all copies byte-identical to source (sha1e207414).hook-utils.test.shsuites pass (37/37 ×3, 43/43).claude plugin validate×4 and--strictcatalog validation pass; shellcheck, markdownlint, actionlint, editorconfig-checker clean locally.🤖 Generated with Claude Code
https://claude.ai/code/session_012NNwc8U9MbgWsd5sbrfpPh
Note
Medium Risk
Touches shared hook runtime utilities used on every PostToolUse edit (path scoping and telemetry), with mandatory plugin version bumps so consumers actually receive the lib change.
Overview
Centralizes duplicated
hooks/hook-utils.shacross four formatter/lint plugins intolib/hook-utils.sh, withscripts/sync-hook-utils.shto copy or verify plugin copies (--check,--check-bump <ref>).A new
hook-utils-syncCI job (included inci-status) fails PRs when copies drift from the lib or when the lib changes without a patch bump in every carrying plugin’splugin.json(version is the consumer update key).docs/MIGRATION-PLAYBOOK.mdrecords this SSOT-plus-copies model and why dependency plugins / marketplace symlinks were rejected or deferred.Synced plugin copies pick up shared headers/guard (
_HOOK_UTILS_LOADED) and a behavioral change:hook::physical_pathplushook::read_file_pathnow resolve symlinks beforeCLAUDE_PROJECT_DIRmembership checks. Carrying plugins are bumped (bash-lint 0.1.2, biome/ruff 0.1.1, markdown-formatter 0.1.3).Reviewed by Cursor Bugbot for commit 7bcb499. Bugbot is set up for automated code reviews on this repo. Configure here.