diff --git a/docs/conventions/commit-convention/README.md b/docs/conventions/commit-convention/README.md index f387447639..c3f6bdb57e 100644 --- a/docs/conventions/commit-convention/README.md +++ b/docs/conventions/commit-convention/README.md @@ -77,13 +77,32 @@ pr_title_pattern: Same as `subject_pattern`. Contract points: -- **The pointer is optional and team-only.** Absent → today's markdown-H2 grammar, unchanged — - full back-compat, zero action for existing consumers. The pointer is honored from the - team-tracked file only (same policy floor: a gitignored overlay must not redirect the gate). - The path is ALWAYS repo-declared; the plugin hardcodes no doc-root convention and ships no - well-known search list in V1 (recorded decision: a search list is discoverable sugar that adds - probe order and shadowing questions with no consumer demanding it yet — the pointer alone keeps - every path choice in the consuming repo's hands). +- **The pointer is optional and team-only.** Absent → the well-known-default probe below, then + today's markdown-H2 grammar — full back-compat, zero action for existing consumers. The pointer + is honored from the team-tracked file only (same policy floor: a gitignored overlay must not + redirect the gate). +- **Three-rung neutral-file precedence (V2 — reopens V1's "no well-known search").** The neutral + file is resolved in a fixed order, identical on the enforcement resolver and the drafting read: + 1. an **explicit `convention_source` pointer** — the relocation override; the path stays + repo-owned, so a repo that keeps its convention elsewhere is unaffected; + 2. absent a pointer, the **well-known default path** + `docs/conventions/source-control/commit-convention.yml` when that file exists — the marketplace's + own dogfooded `docs/conventions//` layout, so the common case reads ONE tool-agnostic + file with no markdown pointer-parse and no pointer to sever; + 3. absent both, the **team markdown-H2** sections (legacy). + + Once a neutral file resolves via rung 1 or 2 it is authoritative and the fail-closed broken-file + contract applies; a key it omits still falls back per key to the markdown H2. + + **Both V1 reasons for shipping no well-known search are engaged, not overridden by fiat** (#163434 + is the demanding consumer; design: `docs/topics/commit-convention-well-known-path/`). V1 recorded + (i) "no consumer demanding it yet" — now void. And (ii) a search list "adds probe order and + shadowing questions" and "keeps every path choice in the consuming repo's hands." V2 answers (ii) + narrowly: it is a single fixed default path, **not** a search list, so probe order is the bounded + 3-rung precedence above rather than an open question; and the pointer is retained at rung 1, so + path ownership is preserved for any repo that wants it — the default is a convenience for the + common case, never a seizure of the path decision. The default is tool-agnostic by placement + (`docs/conventions/`, a plain docs path a non-Claude hook or CI reads directly), not `.claude/`-scoped. - **Value grammar (one-sed contract).** A key's value is everything after `^:` on the first matching column-0 line — whitespace-trimmed, one pair of matching surrounding quotes removed, no YAML escape processing. `sed -n 's/^subject_pattern:[[:space:]]*//p'` (plus quote-strip) is the diff --git a/docs/topics/commit-convention-well-known-path/design-resolution.md b/docs/topics/commit-convention-well-known-path/design-resolution.md new file mode 100644 index 0000000000..c071093bc0 --- /dev/null +++ b/docs/topics/commit-convention-well-known-path/design-resolution.md @@ -0,0 +1,145 @@ +# Design resolution — commit-convention well-known path + config-cascade rename + +Locks the architecture decision for melodic handoff-inbox item +`20260723-163434-source-control-setup-convention-default-and-config-surface` (F1–F4), plus the two +adjacent concerns it surfaced (a seam rename and a fleet provenance question). Design-before-code +artifact; the implementation follows the phased plan at the end. + +## Problem + +`/source-control:setup`'s neutral commit-convention SSOT (`convention_source`, shipped in #1141 / +source-control 0.23.0) landed as an **opt-in pointer target with markdown-H2 still primary**. The +pointer lives as a `## convention_source` H2 *body line* inside `.claude/source-control.md` — a file +authored for a model, so: + +- **F2 (config surface).** Every non-plugin consumer (commit-msg hook, CI, another agent) must + markdown-parse `.claude/source-control.md` to discover the path — three reads across two files, + one of them prose. No native config surface names the convention file (verified: 0.23.0 manifest + has 29 userConfig keys, none names a convention file). +- **F3 (fragility).** Any agent tidying `.claude/source-control.md` (`revise-claude-md`, a `.claude/` + tidier) can silently drop the H2 pointer. Resolution then fails **closed** (enforcement no-ops) or + drafting falls back to markdown/CC — correct behavior, but with **no signal** until a commit is + unexpectedly blocked or allowed. `setup check` surfaces no drift. +- **F1 (default steer).** Setup defaults to markdown-primary and positions the neutral SSOT as + "offer when it earns its keep" — steering *away* from the tool-agnostic file exactly in the mature + repos (a second enforcement consumer exists) that most benefit from it. +- **F4 (taste).** The neutral-YAML preamble template is heavier than it needs to be. + +The pointer feeds **two** resolution surfaces, verified: enforcement +(`lib/resolve-convention-pattern.sh`, read by the guardrails gate) and drafting +(`plugins/source-control/reference/config-resolution.md`, read by `/commit` + `/pull-request` + +`/setup`). Any precedence change must land identically on both or they diverge. + +## Decision + +**Principled option (b): a well-known default neutral path, defaulting to the marketplace's own +dogfooded `docs/conventions//` layout, with `convention_source` retained as a relocation +override.** + +For commit-convention the well-known default is `docs/conventions/source-control/commit-convention.yml` +— the exact path the demanding consumer (SW2030) chose. Resolution precedence, **mirrored across +both surfaces**: + +``` +1. explicit convention_source pointer (relocation override — path stays repo-owned) +2. well-known docs/conventions/source-control/commit-convention.yml (the default — read ONE file) +3. markdown-H2 in .claude/source-control.md (legacy / back-compat, per key) +``` + +Once a neutral file is resolved (via 1 or 2), the existing fail-closed contract applies unchanged — +a present-but-broken neutral file disables enforcement with a diagnostic rather than silently +re-reading markdown a migration may have retired. A key the neutral file omits still falls back to +the markdown H2 (per-key). Absent all three → today's behavior (inference / CC default), full +back-compat, zero action for existing consumers. + +This keeps **both** values the earlier b′/b deliberation isolated: + +- b′'s value — the pointer survives, so a repo that wants the file elsewhere keeps *path ownership* + (precedence rung 1). The fragility F3 targets is closed for the common case because the default + no longer requires a pointer at all. +- b's value — the common case reads **one** self-describing, tool-agnostic file with zero markdown + parse and zero indirection (precedence rung 2). + +### Why this passes the re-anchor disciplines + +- **reuse-or-replace.** `docs/conventions//` is *this marketplace's own* established layout + for convention concerns. A consumer adopting it reuses the established shape rather than standing + up a parallel one. The rejected alternatives — a bespoke pointer dotfile (b′) or a `.claude/`-scoped + default — were both inventions of a *new* location. +- **recheck-against-upstream.** The consumer-config-layering seam currently mandates `.claude/`, + but `standards` already deviates to `docs/standards/` (precedence ratified #649; location observed, + not yet ruled). A `docs/`-rooted config location has precedent — this generalizes it, not invents. +- **reason-dont-recite.** This reopens the commit-convention seam's V1 "no well-known search / path + stays repo-owned" decision. Both recorded V1 reasons are engaged, not overridden by fiat: reason + (i) "no consumer demanding it yet" is now void (163434 is the demanding consumer); reason (ii) + probe-order/shadowing + path-ownership is bounded by a fixed 3-rung precedence and preserved by + retaining the pointer as rung 1. +- **point-dont-copy.** The neutral file stays the single SSOT; the well-known path is a *probe*, not + a copy. Tool-agnosticism is preserved because `docs/conventions/` is a plain docs path a non-Claude + CI check or hook reads directly (unlike a `.claude/`-scoped default). + +### Rejected alternatives + +- **b′ alone (relocate pointer to a bespoke dotfile).** Invents a new location; keeps the indirection + and a 3rd tracked file; addresses fragility but not read-burden. Superseded by principled-b, which + keeps the pointer (b′'s only durable win) *and* removes the indirection for the common case. +- **(a) a userConfig key naming the file.** Per-user/per-machine, invisible to CI and a fresh-checkout + hook — cannot be the team-tracked source. Not pursued even as a supplement (no demand). +- **Defer F2 structurally (detection-only).** Cheapest, but leaves the silent-severance window open + and does not deliver the read-one-file consistency the operator asked for. + +## Scope and sequencing + +Cross-cutting; delivered PR-by-PR, source-control first. codebase-health (the only other markdown-H2 +config surface) and the provenance audit are tracked follow-ups, not folded in — cramming them would +violate the clean/well-thought bar. + +### Phase 1 — source-control well-known path + F1/F3/F4 (closes 163434) + +- `docs/conventions/commit-convention/README.md` — record the well-known default + 3-rung precedence; + update the V1 "no well-known search" note to the reopened decision with both reasons engaged. +- `lib/resolve-convention-pattern.sh` — pointer resolution gains rung 2 (well-known default) between + the explicit pointer and the markdown fallback. Safety + fail-closed contract unchanged. +- `plugins/guardrails/hooks/resolve-convention-pattern.sh` — synced byte-identical via + `scripts/sync-resolve-convention-pattern.sh`; guardrails manifest version bumped (forced by the + `--check-bump` CI gate at `ci.yml:296`). +- `plugins/source-control/reference/config-resolution.md` — drafting side gains the same 3-rung + precedence (identical to enforcement). +- `plugins/source-control/skills/setup/reference/apply-convention.md` — F1: recommend the neutral + SSOT as the default when a second enforcement consumer is detected; default the neutral file's path + to the well-known location (write `convention_source` only on relocation). F4: trim the YAML + preamble to a 1–2 line header. +- `plugins/source-control/skills/setup/SKILL.md` — F3: `check` gains drift probes (explicit pointer + target missing; well-known file present but markdown-H2 also sets keys → shadow warning). +- Tests: `lib/resolve-convention-pattern.test.sh` — well-known-default resolution, precedence order, + fail-closed on broken well-known file, back-compat (no neutral → markdown). +- Version bumps + CHANGELOG for source-control and guardrails. + +### Phase 2 — rename `consumer-config-layering` → `config-cascade` + +Mechanical docs rename (~15 citations) via `docs-hygiene:rename-references`; record the `docs/`-location +pattern the standards deviation already set. Name chosen via `naming:name-it-better` (blind 3-lens +fan-out): `config-cascade` — "cascade" is the one established term of art that natively carries both +per-key override and a ratified precedence-inversion (CSS `@layer`/`!important`), matching the +user→team→local + policy-floor model. + +### Tracked follow-ups (own issues, not this effort) + +- **codebase-health** — migrate the second markdown-H2 config surface to the same structured + first-class layered shape. +- **Provenance audit** — the operator does not recall ratifying `consumer-config-layering`; it and + its sibling `docs/conventions/*` seams accreted through agent-authored auto-merged PRs (#692, #649, + #925…). Audit which seams were human-ratified vs agent-accreted before further building on them. + +## Risks and mitigations + +- **Shadowing (well-known file present + markdown-H2 also set).** Bounded by the fixed 3-rung + precedence; surfaced by the F3 `setup check` shadow-warning probe. +- **Two-surface drift.** Enforcement and drafting must implement identical precedence — covered by + a resolver test asserting the order and a config-resolution.md doc that states the same 3 rungs. +- **Unintended enforcement from a stray well-known file.** The path is specific enough + (`docs/conventions/source-control/commit-convention.yml`) that its presence is intentional; and + enforcement only fires when the resolved neutral file carries `subject_pattern` (opt-in by + construction). +- **Fresh-docs mandate.** WebFetch the plugins-reference / skills / hooks pages and cite before the + plugin edits; version bumps are plain semver. diff --git a/lib/resolve-convention-pattern.sh b/lib/resolve-convention-pattern.sh index 43de85ef20..1497674588 100755 --- a/lib/resolve-convention-pattern.sh +++ b/lib/resolve-convention-pattern.sh @@ -51,6 +51,13 @@ readonly CC_ERE='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert) # shellcheck disable=SC2016 # backticks are a literal part of the deferral marker, not a substitution readonly PR_DEFERRAL='Same as `subject_pattern`.' +# Well-known default path for the neutral convention SSOT — the marketplace's +# own dogfooded docs/conventions// layout. When the team file declares +# no explicit convention_source pointer, the resolver probes this path so the +# common case reads ONE tool-agnostic file with no markdown pointer-parse. An +# explicit pointer always overrides it; see the pointer-resolution block below. +readonly WELL_KNOWN_NEUTRAL="docs/conventions/source-control/commit-convention.yml" + if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 @@ -134,6 +141,23 @@ yaml_value() { # enforcement (exit 1) with a diagnostic: fail closed, never fall back. NEUTRAL_FILE="" ptr="$(h2_value "$TEAM_FILE" "convention_source")" +# Precedence: an explicit convention_source (rung 1) always wins. Absent one, +# probe the well-known default path (rung 2) so the common case needs no pointer +# at all; absent that too, resolution falls through to the markdown H2 (rung 3) +# with full back-compat. The default is a plain repo-relative path and passes the +# same safety checks below as any pointer. +# +# POLICY FLOOR: the well-known rung activates ONLY when the file is git-TRACKED. +# Unlike an explicit convention_source — whose opt-in is a tracked edit to the +# team file — this rung has no other tracked signal, so honoring an untracked or +# gitignored file at this path would let a generated/local artifact silently +# override team policy (or, if malformed, disable enforcement) on one checkout. +# git unavailable, or the file untracked -> skip this rung and fall through to +# the markdown H2 (fail toward the prior behavior, never toward the hole). +if [[ -z "$ptr" && -f "$repo_root/$WELL_KNOWN_NEUTRAL" ]] && + git -C "$repo_root" ls-files --error-unmatch -- "$WELL_KNOWN_NEUTRAL" >/dev/null 2>&1; then + ptr="$WELL_KNOWN_NEUTRAL" +fi if [[ -n "$ptr" ]]; then case "$ptr" in /* | [A-Za-z]:* | *\\* | *..*) @@ -163,7 +187,13 @@ if [[ -n "$ptr" ]]; then fi canon_root="$(cd "$repo_root" 2>/dev/null && pwd -P)" canon_dir="$(cd "$(dirname "$NEUTRAL_FILE")" 2>/dev/null && pwd -P)" - if [[ -z "$canon_root" || -z "$canon_dir" || "$canon_dir/" != "$canon_root/"* ]]; then + # Pure string-prefix test, not a glob: a `!= "$canon_root/"*` match would let a + # glob metacharacter in the physical repo path (`[`, `*`, `?`) be interpreted + # rather than compared literally. Compare the leading bytes of "$canon_dir/" + # against "$canon_root/" directly. + canon_prefix="$canon_root/" + canon_dir_slash="$canon_dir/" + if [[ -z "$canon_root" || -z "$canon_dir" || "${canon_dir_slash:0:${#canon_prefix}}" != "$canon_prefix" ]]; then printf '%s\n' "resolve-convention-pattern: convention_source '$ptr' resolves outside the repository root (symlinked path segment); enforcement disabled." >&2 exit 1 fi diff --git a/lib/resolve-convention-pattern.test.sh b/lib/resolve-convention-pattern.test.sh index df32910d32..ea2cd46bdd 100755 --- a/lib/resolve-convention-pattern.test.sh +++ b/lib/resolve-convention-pattern.test.sh @@ -276,6 +276,89 @@ else echo "SKIP: symlink cases (ln -s unavailable on this filesystem)" fi +# ===================================================================== +# Well-known default neutral path (docs/conventions/source-control/…) +# The resolver probes a repo-dogfooded default path when no explicit +# convention_source pointer is declared, so the common case reads ONE +# tool-agnostic file with no markdown pointer-parse. Precedence: +# 1. explicit convention_source pointer (relocation override) +# 2. well-known docs/conventions/source-control/commit-convention.yml +# 3. markdown-H2 in .claude/source-control.md (legacy) +# ===================================================================== + +WELL_KNOWN="docs/conventions/source-control/commit-convention.yml" + +# The well-known rung activates ONLY for a git-TRACKED file (policy floor: an +# untracked/gitignored artifact at that path must not override team policy), so +# tests exercising the rung must git-init the repo and stage the neutral file. +track() { + local d="$1" path="$2" + git -C "$d" init -q 2>/dev/null || true + git -C "$d" add -- "$path" 2>/dev/null || true +} + +# --- well-known file present + TRACKED, NO pointer, NO markdown -> resolves --- +r="$(newrepo "")" +addneutral "$r" "$WELL_KNOWN" $'subject_pattern: \047^WK-[0-9]+: .+\047' +track "$r" "$WELL_KNOWN" +assert_eq "well-known: resolves with no pointer" '^WK-[0-9]+: .+' "$(run "$r")" + +# --- well-known present + TRACKED AND markdown subject_pattern -> well-known WINS (rung 2 > 3) --- +r="$(newrepo $'## subject_pattern\n^old-md: .+')" +addneutral "$r" "$WELL_KNOWN" $'subject_pattern: \047^wk-wins: .+\047' +track "$r" "$WELL_KNOWN" +assert_eq "well-known: wins over markdown H2" '^wk-wins: .+' "$(run "$r")" + +# --- POLICY FLOOR: well-known present but UNTRACKED -> rung skipped, falls to markdown --- +# The core #163434 hardening: a generated/gitignored file at the default path +# must NOT silently override a tracked markdown policy. +r="$(newrepo $'## subject_pattern\n^tracked-md-wins: .+')" +addneutral "$r" "$WELL_KNOWN" $'subject_pattern: \047^untracked-ignored: .+\047' +git -C "$r" init -q 2>/dev/null || true # repo exists but the well-known file is NOT added +assert_eq "well-known: untracked file ignored -> markdown wins" '^tracked-md-wins: .+' "$(run "$r")" + +# --- POLICY FLOOR: untracked well-known with NO markdown -> no enforcement (not activated) --- +r="$(newrepo "")" +addneutral "$r" "$WELL_KNOWN" $'subject_pattern: \047^untracked: .+\047' +git -C "$r" init -q 2>/dev/null || true +run "$r" >/dev/null 2>&1 +assert_exit "well-known: untracked file + no markdown -> exit 1" 1 $? + +# --- explicit pointer present AND well-known also present -> explicit WINS (rung 1 > 2) --- +r="$(newrepo $'## convention_source\nconventions.yml')" +addneutral "$r" "conventions.yml" $'subject_pattern: \047^explicit-wins: .+\047' +addneutral "$r" "$WELL_KNOWN" $'subject_pattern: \047^wk-loses: .+\047' +track "$r" "$WELL_KNOWN" +assert_eq "well-known: explicit pointer overrides well-known" '^explicit-wins: .+' "$(run "$r")" + +# --- well-known present + TRACKED but broken (non-posix-ere dialect) -> fail closed --- +r="$(newrepo "")" +addneutral "$r" "$WELL_KNOWN" $'dialect: pcre\nsubject_pattern: \047^\\d+: .+\047' +track "$r" "$WELL_KNOWN" +out="$(run "$r")" +rc=$? +assert_exit "well-known: broken dialect -> exit 1" 1 "$rc" +assert_eq "well-known: broken dialect -> empty stdout" "" "$out" + +# --- well-known present + TRACKED but empty key -> fail closed (never markdown) --- +r="$(newrepo $'## subject_pattern\n^stale-md: .+')" +addneutral "$r" "$WELL_KNOWN" $'subject_pattern: \047\047' +track "$r" "$WELL_KNOWN" +out="$(run "$r")" +rc=$? +assert_exit "well-known: empty key -> exit 1 (no stale markdown)" 1 "$rc" +assert_eq "well-known: empty key -> empty stdout" "" "$out" + +# --- NO well-known, NO pointer, markdown present -> markdown (back-compat rung 3) --- +r="$(newrepo $'## subject_pattern\n^md-only: .+')" +assert_eq "well-known: absent -> markdown back-compat" '^md-only: .+' "$(run "$r")" + +# --- well-known TRACKED: a key it omits falls back to markdown H2 (per-key) --- +r="$(newrepo $'## pr_title_pattern\n^MDPR: .+')" +addneutral "$r" "$WELL_KNOWN" $'subject_pattern: \047^WKS-[0-9]+: .+\047' +track "$r" "$WELL_KNOWN" +assert_eq "well-known: omitted key -> markdown fallback" '^MDPR: .+' "$(run "$r" pr_title_pattern)" + # --- usage / invalid key --- bash "$SCRIPT" >/dev/null 2>&1 assert_exit "no args -> exit 2" 2 $? diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 1c756be70b..d5bf1fada3 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", - "version": "0.13.0", + "version": "0.14.0", "description": "Nine safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.", "author": { "name": "Melodic Software", diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index c36c8f578e..1c7e069b7d 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.14.0] + +### Changed + +- **Vendored convention resolver probes the well-known default neutral path (#163434).** The synced + copy of `lib/resolve-convention-pattern.sh` now resolves the neutral convention SSOT by a fixed + 3-rung precedence: an explicit `## convention_source` pointer, else the well-known default path + `docs/conventions/source-control/commit-convention.yml` when present, else the team markdown-H2. + The CC-layer content gate enforces the same pattern the drafting side drafts against, with no + pointer required in the common case. Back-compat: absent both a pointer and the well-known file, + enforcement resolves from the markdown-H2 exactly as before. + ## [0.13.0] ### Added diff --git a/plugins/guardrails/hooks/resolve-convention-pattern.sh b/plugins/guardrails/hooks/resolve-convention-pattern.sh index 43de85ef20..1497674588 100755 --- a/plugins/guardrails/hooks/resolve-convention-pattern.sh +++ b/plugins/guardrails/hooks/resolve-convention-pattern.sh @@ -51,6 +51,13 @@ readonly CC_ERE='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert) # shellcheck disable=SC2016 # backticks are a literal part of the deferral marker, not a substitution readonly PR_DEFERRAL='Same as `subject_pattern`.' +# Well-known default path for the neutral convention SSOT — the marketplace's +# own dogfooded docs/conventions// layout. When the team file declares +# no explicit convention_source pointer, the resolver probes this path so the +# common case reads ONE tool-agnostic file with no markdown pointer-parse. An +# explicit pointer always overrides it; see the pointer-resolution block below. +readonly WELL_KNOWN_NEUTRAL="docs/conventions/source-control/commit-convention.yml" + if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 @@ -134,6 +141,23 @@ yaml_value() { # enforcement (exit 1) with a diagnostic: fail closed, never fall back. NEUTRAL_FILE="" ptr="$(h2_value "$TEAM_FILE" "convention_source")" +# Precedence: an explicit convention_source (rung 1) always wins. Absent one, +# probe the well-known default path (rung 2) so the common case needs no pointer +# at all; absent that too, resolution falls through to the markdown H2 (rung 3) +# with full back-compat. The default is a plain repo-relative path and passes the +# same safety checks below as any pointer. +# +# POLICY FLOOR: the well-known rung activates ONLY when the file is git-TRACKED. +# Unlike an explicit convention_source — whose opt-in is a tracked edit to the +# team file — this rung has no other tracked signal, so honoring an untracked or +# gitignored file at this path would let a generated/local artifact silently +# override team policy (or, if malformed, disable enforcement) on one checkout. +# git unavailable, or the file untracked -> skip this rung and fall through to +# the markdown H2 (fail toward the prior behavior, never toward the hole). +if [[ -z "$ptr" && -f "$repo_root/$WELL_KNOWN_NEUTRAL" ]] && + git -C "$repo_root" ls-files --error-unmatch -- "$WELL_KNOWN_NEUTRAL" >/dev/null 2>&1; then + ptr="$WELL_KNOWN_NEUTRAL" +fi if [[ -n "$ptr" ]]; then case "$ptr" in /* | [A-Za-z]:* | *\\* | *..*) @@ -163,7 +187,13 @@ if [[ -n "$ptr" ]]; then fi canon_root="$(cd "$repo_root" 2>/dev/null && pwd -P)" canon_dir="$(cd "$(dirname "$NEUTRAL_FILE")" 2>/dev/null && pwd -P)" - if [[ -z "$canon_root" || -z "$canon_dir" || "$canon_dir/" != "$canon_root/"* ]]; then + # Pure string-prefix test, not a glob: a `!= "$canon_root/"*` match would let a + # glob metacharacter in the physical repo path (`[`, `*`, `?`) be interpreted + # rather than compared literally. Compare the leading bytes of "$canon_dir/" + # against "$canon_root/" directly. + canon_prefix="$canon_root/" + canon_dir_slash="$canon_dir/" + if [[ -z "$canon_root" || -z "$canon_dir" || "${canon_dir_slash:0:${#canon_prefix}}" != "$canon_prefix" ]]; then printf '%s\n' "resolve-convention-pattern: convention_source '$ptr' resolves outside the repository root (symlinked path segment); enforcement disabled." >&2 exit 1 fi diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 2539216f49..ae51ad59ea 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.24.0", + "version": "0.25.0", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop — safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and merge-rung raises binding from the team-tracked layer only), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply — interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index b5525fc0b4..865bb89368 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,33 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.25.0] + +### Added + +- **Well-known default path for the neutral convention SSOT (#163434).** The commit-convention + resolver now probes a repo-dogfooded default path, + `docs/conventions/source-control/commit-convention.yml`, when the team file declares no explicit + `## convention_source` pointer. The common case reads ONE tool-agnostic file with no markdown + pointer-parse and nothing in agent-rewritable prose to sever. Fixed 3-rung precedence, identical on + the drafting and enforcement surfaces: explicit `convention_source` pointer (relocation override) > + well-known default path > markdown-H2 (legacy). Full back-compat — absent both a pointer and the + well-known file, resolution is unchanged. + +### Changed + +- **Setup recommends the neutral SSOT as the default when a second enforcement consumer exists (F1).** + When inference detects a commit-msg hook, a CI title check, or a user-stated second consumer, + `/source-control:setup apply` now recommends the tool-agnostic neutral file (at the well-known + path, pointerless) rather than steering to markdown-primary; it falls back to markdown-only only + when this plugin is demonstrably the sole consumer. +- **`setup check` surfaces neutral-SSOT drift (F3).** Two probes: a broken pointer / neutral file + (FAIL — was silent fail-closed), and a resolved neutral file shadowing a stale markdown-H2 + duplicate (WARN). +- **Neutral-YAML preamble trimmed to a 1–2 line header (F4).** The self-describing multi-line + preamble template is reduced to what the file is and who reads it; the human document proper lives + in CONTRIBUTING/AGENTS.md. + ## [0.24.0] ### Added diff --git a/plugins/source-control/reference/config-resolution.md b/plugins/source-control/reference/config-resolution.md index 1a2e179cc3..98266d32b8 100644 --- a/plugins/source-control/reference/config-resolution.md +++ b/plugins/source-control/reference/config-resolution.md @@ -59,7 +59,14 @@ Absent sections are absent, never empty. file's own H2 sections, and plugin-only keys (`trailer_policy`, `pr_body_attribution`) stay in the markdown surface. The `Conventional Commits` keyword and the pr-title deferral marker work identically in the neutral file. User-global and local-overlay layers are unchanged and still - merge per key on top. Value grammar, pointer safety rules, and the fail-closed + merge per key on top. **The neutral file is resolved by a three-rung precedence, identical on the + drafting and enforcement surfaces:** (1) an explicit `convention_source` pointer (the relocation + override — path stays repo-owned); absent one, (2) the **well-known default path** + `docs/conventions/source-control/commit-convention.yml` when that file exists (the common case — + read ONE tool-agnostic file, no pointer needed); absent both, (3) the team markdown H2 sections + (legacy / back-compat). Once a neutral file is resolved via rung 1 or 2 it is authoritative and + the fail-closed broken-file contract applies; a key it omits still falls back per key to the + markdown H2. Value grammar, pointer safety rules, and the fail-closed broken-pointer contract are owned by the [commit-convention seam](https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/commit-convention/README.md) — drafting honors the same contract (a declared-but-broken pointer is surfaced as a config error, diff --git a/plugins/source-control/skills/setup/SKILL.md b/plugins/source-control/skills/setup/SKILL.md index 64f5f11710..2eeffc12d5 100644 --- a/plugins/source-control/skills/setup/SKILL.md +++ b/plugins/source-control/skills/setup/SKILL.md @@ -87,6 +87,23 @@ With **all three layers absent**: INFO — no declared convention; `/commit` and from the repo's own `CLAUDE.md`/rules/commit-msg hook, then fall back to the bundled Conventional Commits default. The remediation is `apply` to persist a convention. +**Neutral-SSOT drift probes.** When a `convention_source` pointer is declared or a neutral file is +resolved (explicit pointer, or the well-known default `docs/conventions/source-control/commit-convention.yml`), +`check` surfaces two drift conditions the resolver otherwise handles silently — round-trip the +enforcement resolver (`lib/resolve-convention-pattern.sh subject_pattern`) and read its +diagnostics: + +- **Broken pointer / neutral file → FAIL.** A declared `convention_source` whose target is missing, + or a resolved neutral file that fails the seam's safety/dialect/empty-key contract, disables + enforcement fail-closed. This is easy to miss because nothing signals it until a commit is + unexpectedly blocked or allowed — so surface it here, naming the resolver's diagnostic and the + remediation (restore the file, fix the pointer, or `apply` to rewrite it). +- **Shadowed markdown → WARN.** A neutral file resolves (via pointer or the well-known default) **and** + `.claude/source-control.md` still carries a markdown-H2 `subject_pattern`/`pr_title_pattern` for the + same key: the neutral value wins (rungs 1–2 over rung 3) and the stale markdown is inert but + misleading. Recommend `apply` to retire the duplicate (migration removes it), per + [reference/apply-convention.md](reference/apply-convention.md) "Migration retires duplicates". + ### Babysit config 1. **Effective configuration.** Report every babysit `userConfig` key with its resolved value or its @@ -141,10 +158,12 @@ In brief: layer (team = tracked and staged; local = ignored and untracked, two independent probes; user = no git command at all), and report the new **effective merge**, not just what was written. -- **Neutral SSOT (optional):** a `team` write may declare `## convention_source` pointing at a - repo-relative flat-scalar YAML file other tools consume too — offered when another consumer - exists, with migration retiring keys the neutral file takes over (spoke section "Neutral - convention SSOT"). +- **Neutral SSOT:** a `team` write may materialize a tool-agnostic flat-scalar YAML file other tools + consume too. It defaults to the well-known path `docs/conventions/source-control/commit-convention.yml` + (resolved with no pointer); `## convention_source` is written only to relocate it. **Recommended as + the default when a second enforcement consumer exists** (commit-msg hook, CI title check), markdown-only + when this plugin is the sole consumer; migration retires markdown keys the neutral file takes over + (spoke section "Neutral convention SSOT"). Every step's exact contract — the interview steps, the written-file template, the per-layer verification scripts, and the failure remediations — lives in the spoke; this summary never @@ -202,9 +221,12 @@ Skill-behavior failure patterns hit in real runs. Add to this section when new o the window silently — probe `git rev-parse --is-shallow-repository` and report the actual span. - **Same-session `userConfig` reads are stale.** Reconfigured babysit values become visible only in a fresh session — re-running `check` in the same session reports a false failure. -- **A broken `convention_source` pointer fails closed.** Enforcement and drafting surface it as a - config error rather than silently falling back to markdown values a migration may have retired — - verify the pointer round-trips through the resolver at write time. +- **A broken `convention_source` pointer or well-known file fails closed.** Enforcement and drafting + surface it as a config error rather than silently falling back to markdown values a migration may + have retired — verify the neutral file round-trips through the resolver at write time. The neutral + file resolves by a fixed 3-rung precedence (explicit pointer > well-known + `docs/conventions/source-control/commit-convention.yml` > markdown-H2); `check` warns when a + resolved neutral file shadows a stale markdown-H2 duplicate. ## What this skill does NOT do diff --git a/plugins/source-control/skills/setup/evals/evals.json b/plugins/source-control/skills/setup/evals/evals.json index 7f1c1384a1..5568ee290c 100644 --- a/plugins/source-control/skills/setup/evals/evals.json +++ b/plugins/source-control/skills/setup/evals/evals.json @@ -222,15 +222,15 @@ }, { "id": 18, - "name": "apply-neutral-ssot-migration-retires-duplicates", + "name": "apply-neutral-ssot-well-known-default-retires-duplicates", "prompt": "/source-control:setup apply\n\nThe team .claude/source-control.md already declares subject_pattern as a ticket-prefix regex. The user says: our commit-msg hook and CI also need to read this convention — move it to a tool-agnostic single source of truth.", - "expected_output": "The skill offers the neutral convention SSOT: it asks the repo's own path preference (never hardcoding a doc root), writes a flat-scalar YAML file (subject_pattern, pr_title_pattern, dialect: posix-ere, self-describing # comments), declares `## convention_source` with that repo-relative path in .claude/source-control.md, and — critically — RETIRES the now-duplicated subject_pattern from the markdown file in the same apply rather than leaving both authoritative. Verification round-trips the pointer through the enforcement resolver and reports the effective merge. Plugin-only keys (trailer_policy, pr_body_attribution) stay in the markdown file.", + "expected_output": "The skill recommends the neutral convention SSOT (a second consumer exists) and materializes it at the WELL-KNOWN DEFAULT path docs/conventions/source-control/commit-convention.yml WITHOUT declaring a `## convention_source` pointer — the resolver probes the well-known path, so nothing needs to live in agent-rewritable markdown. It writes a flat-scalar YAML file (subject_pattern, pr_title_pattern, dialect: posix-ere) with a 1-2 line self-describing header, and — critically — RETIRES the now-duplicated subject_pattern from the markdown file in the same apply rather than leaving both authoritative. Verification round-trips the neutral file through the enforcement resolver, confirms it is staged/tracked, and reports the effective merge. Plugin-only keys (trailer_policy, pr_body_attribution) stay in the markdown file. A `## convention_source` pointer is written ONLY if the user asks to place the file at a non-default location.", "files": [], "expectations": [ - "Asks the repo's path preference for the neutral file instead of hardcoding a doc-root convention; the pointer written is repo-relative with forward slashes", - "Writes flat-scalar YAML with the machine keys and declares convention_source in the team .claude/source-control.md", + "Writes the neutral file at the well-known default path docs/conventions/source-control/commit-convention.yml and does NOT declare a convention_source pointer for the default location (the pointer is written only for a non-default relocation, repo-relative with forward slashes)", + "Writes flat-scalar YAML with the machine keys and dialect, plus a 1-2 line header rather than a multi-line preamble", "Retires the duplicated subject_pattern from the markdown file in the same apply — both surfaces are never left authoritative for the same key", - "Verifies the pointer round-trips through the enforcement resolver (broken pointer fails closed) and keeps plugin-only keys markdown-side" + "Verifies the neutral file round-trips through the enforcement resolver (broken file fails closed), is staged/tracked so a fresh checkout resolves it, and keeps plugin-only keys markdown-side" ] } ] diff --git a/plugins/source-control/skills/setup/reference/apply-convention.md b/plugins/source-control/skills/setup/reference/apply-convention.md index 19f3f6a202..cc09a7e84b 100644 --- a/plugins/source-control/skills/setup/reference/apply-convention.md +++ b/plugins/source-control/skills/setup/reference/apply-convention.md @@ -339,26 +339,38 @@ and value grammar are owned by the [commit-convention seam](https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/commit-convention/README.md); this skill's part: -- **Offer it when it earns its keep** — the user says another tool consumes the convention (a hook, - CI, another agent), or asks for a tool-agnostic/SSOT shape. A repo where only this plugin reads - the convention loses nothing by staying markdown-only; say so rather than upselling the split. -- **Writing it:** ask the repo's own path preference (the plugin ships no doc-root convention — - `docs/conventions/…`, `.github/…`, a root dotfile are all the repo's call; repo-relative, - forward slashes, no `..`), write the YAML with machine keys (`subject_pattern`, - `pr_title_pattern`, optionally `pr_body_required_sections`, `dialect: posix-ere`) plus `#` - comments carrying the self-describing preamble, and declare `## convention_source` with that path - in `.claude/source-control.md`. +- **Recommend it as the default when a second enforcement consumer exists (F1).** Inference (step 2) + already resolves the commit-msg hooks dir via `git rev-parse --git-path hooks`; when a second + consumer of the convention is present — a commit-msg hook, a CI title check, or a user-stated one — + **recommend the neutral SSOT as the default**, because the tool-agnostic file is what that second + consumer reads without reimplementing this plugin's markdown-H2 grammar. Fall back to markdown-only + only when this plugin is demonstrably the sole consumer — a repo where nothing else reads the + convention loses nothing by staying markdown-only; say so rather than forcing the split. +- **Default the path to the well-known location.** The neutral file's well-known default path is + `docs/conventions/source-control/commit-convention.yml` (the marketplace's own dogfooded + `docs/conventions//` layout). Writing it **there** means the resolver finds it with **no + `## convention_source` pointer at all** (the resolver probes the well-known path at rung 2), so the + common case leaves nothing in agent-rewritable markdown to sever. Only when the repo insists on a + different location do you declare `## convention_source` with that repo-relative path (forward + slashes, no `..`) in `.claude/source-control.md` — the rung-1 relocation override. Write the YAML + with machine keys (`subject_pattern`, `pr_title_pattern`, optionally `pr_body_required_sections`, + `dialect: posix-ere`) plus a **1–2 line** `#` header (what the file is, who reads it) — not a + multi-line preamble; the flat YAML is self-describing and the human document proper lives in + CONTRIBUTING/AGENTS.md, not in ceremony comments (F4). - **Migration retires duplicates.** When the team markdown file already carries a key the neutral file now declares, REMOVE it from the markdown in the same apply — the resolver would prefer the neutral value anyway, but leaving both invites hand-edit drift, which is the disease this shape cures. Plugin-only keys (`trailer_policy`, `pr_body_attribution`) stay in the markdown file. -- **Verification adds two probes.** (1) The pointer's target exists, is repo-relative, and - round-trips through the enforcement resolver (`lib/resolve-convention-pattern.sh - subject_pattern` emits the expected pattern) — a broken pointer fails closed to no-enforcement by - contract, so surface it at write time, not at the team's first blocked commit. (2) The neutral - file is **staged together with the pointer**, by explicit path, in the same team-write - verification (step 6's team guard covers only `.claude/source-control.md`) — a commit carrying - `convention_source` without its tracked YAML target would hand every fresh checkout the - missing-file fail-closed path and silently disable enforcement repo-wide. Run the same - ignore-check + stage + `git diff --quiet` sequence against the neutral file's path, and treat an - ignore-rule match on it as the same hard STOP as an ignored team file. +- **Verification adds two probes.** (1) The neutral file round-trips through the enforcement + resolver (`lib/resolve-convention-pattern.sh subject_pattern` emits the expected + pattern) — this exercises the whole precedence chain, so it confirms resolution whether the file + sits at the well-known default path (no pointer) or at a relocated `convention_source` target; a + broken file or pointer fails closed to no-enforcement by contract, so surface it at write time, not + at the team's first blocked commit. (2) The neutral file is **staged**, by explicit path, in the + same team-write verification (step 6's team guard covers only `.claude/source-control.md`) — a + commit that resolves to a neutral file whose tracked target is absent (an unstaged well-known file, + or a `convention_source` pointer without its YAML) would hand every fresh checkout the missing-file + fail-closed path and silently disable enforcement repo-wide. Run the same ignore-check + stage + + `git diff --quiet` sequence against the neutral file's path (the well-known default path, or the + pointer target when relocated), and treat an ignore-rule match on it as the same hard STOP as an + ignored team file.