Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,24 @@ jobs:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-check-retirements.sh --check-bump "origin/$BASE_REF"

legacy-statusline-detect-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Verify legacy-statusline-detect cluster matches canonical
run: scripts/sync-legacy-statusline-detect.sh --check
Comment thread
cursor[bot] marked this conversation as resolved.
- name: Verify carrying plugins bumped when canonical changed
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-legacy-statusline-detect.sh --check-bump "origin/$BASE_REF"

resolve-convention-pattern-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
Expand Down Expand Up @@ -1778,6 +1796,7 @@ jobs:
- state-key-sync
- spawn-noise-sync
- check-retirements-sync
- legacy-statusline-detect-sync
- resolve-convention-pattern-sync
- index-regen-sync
- standards-contract-sync
Expand Down
8 changes: 8 additions & 0 deletions docs/conventions/retired-conventions/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ field, removing a field, changing a kind's detection semantics, an exit code's m
severity map is a major bump; adding an optional field, a `status` value, or a new `kind` with its
own detection rule is a minor bump.

## 1.1 — 2026-09-02

Optional `heading` field on `kind: line`. When set, detection and `--clean` consider only matching
lines in the body of every markdown section whose ATX heading line equals the field (trailing
whitespace ignored). Unset `heading` keeps the 1.0 whole-file line rule. The field is frozen once
published, alongside `match` and `content_match`. Helper, validator, and owner-doc field table
updated together.

## 1.0 — 2026-09-01

Initial published contract, landing with the mechanism PR that ADR 0018 named (helper, validator,
Expand Down
31 changes: 18 additions & 13 deletions docs/conventions/retired-conventions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ subset the flat-key parser already handles, while CI validates the same file wit
| `kind` | yes | `file` \| `dir` \| `line`. What the leftover is. |
| `path` | yes | Repo-relative path of the leftover. Absolute paths, `..` segments, a leading `~`, backslashes, and `.` are rejected. Emitted verbatim, never joined onto the root ([windows-path-emit](../windows-path-emit/README.md)). |
| `match` | `line` only | POSIX ERE a line must match. Required for `kind: line`; forbidden otherwise. |
| `heading` | optional, `line` only | Exact ATX heading (`#{1,6}` + whitespace + title). When set, `match` is evaluated only against the body of every markdown section whose heading line equals this value (trailing whitespace ignored). The section runs from the line after that heading through the line before the next ATX heading of the same or higher level, or EOF. A standalone matching line outside that section is not a leftover. Forbidden on `file` and `dir`. |
| `content_match` | optional, `file` only | POSIX ERE the file's content must match for the record to fire. Guards against a consumer legitimately reusing the path for something else. |
| `action` | yes | `delete` (file or dir) \| `remove-line` (line) \| `migrate` (any kind). What cleanup does. |
| `successor` | `migrate` only | Prose the model follows to carry content forward: where the convention went and what to move. Required for `migrate`. |
Expand All @@ -65,8 +66,10 @@ subset the flat-key parser already handles, while CI validates the same file wit

Detection semantics per kind: `file` is present when a regular file exists at `path` and (no
`content_match`, or it matches); `dir` when a directory exists; `line` when the file exists and some
line matches `match`. A trailing carriage return is stripped from every line before matching, so a
`$`-anchored pattern matches a CRLF-authored consumer file.
line matches `match` (and, when `heading` is set, that line sits in the named heading's section
body). Unset `heading` preserves the whole-file 1.0 line rule. A trailing carriage return is
stripped from every line before matching, so a `$`-anchored pattern matches a CRLF-authored
consumer file.

### Example — two records, one demoted

Expand Down Expand Up @@ -103,11 +106,11 @@ set at plan approval (ADR 0018), and no record exists for it on `main`.
## Append-only, and the enumerated legal edits

The manifest is the plugin's retirement history, and a history that can be rewritten is not one. A
record is **never deleted**, and its `id`, `kind`, `path`, `match`, and `content_match` are never
changed once published — a consumer who skips ten versions must still have every record evaluated
against them, and a record whose detection changed under them would report a different leftover than
the one they were told about. CI enforces this against the base ref: a PR that removes a record or
alters a frozen field fails.
record is **never deleted**, and its `id`, `kind`, `path`, `match`, `heading`, and `content_match`
are never changed once published — a consumer who skips ten versions must still have every record
evaluated against them, and a record whose detection changed under them would report a different
leftover than the one they were told about. CI enforces this against the base ref: a PR that
removes a record or alters a frozen field fails.

Exactly three edits are legal after publication:

Expand Down Expand Up @@ -160,7 +163,7 @@ id<TAB>kind<TAB>path<TAB>action<TAB>status<TAB>note

| Exit | Meaning |
|---|---|
| 0 | Cleaned. `delete` unlinks the file (only if `content_match`, when declared, still matches) or removes the directory (only after re-resolving that it is inside the root and is not the root itself); `remove-line` rewrites the file keeping every non-matching line byte-for-byte, via a temp file in the same directory and a rename, so a CRLF file stays CRLF. |
| 0 | Cleaned. `delete` unlinks the file (only if `content_match`, when declared, still matches) or removes the directory (only after re-resolving that it is inside the root and is not the root itself); `remove-line` rewrites the file keeping every non-matching line byte-for-byte (and, when `heading` is set, only removing matches inside that heading's section body), via a temp file in the same directory and a rename, so a CRLF file stays CRLF. |
| 1 | Nothing present to clean. |
| 2 | Usage, invalid record, unknown id, a `migrate` record without `--i-migrated`, or a failed remove or rename. **On Windows a failed remove is usually a locked file**: nothing is left half-done; close the file and re-run. |

Expand All @@ -172,9 +175,10 @@ Invariants every caller may rely on:
`..` segment, `~`, or a backslash is exit 2. The consumer repo cannot inject a path; only the
manifest names one.
- **Consumer content is only ever grep-matched, never executed.** `match` and `content_match` are
applied with `grep -E`; nothing read from a consumer file is evaluated, sourced, or interpolated
into a command. The `successor` prose is plugin-authored, but the convention doc the model reads
while following it is consumer prose and is treated as untrusted input.
applied with `grep -E`; `heading` is an exact line comparison after CR and trailing-whitespace
strip. Nothing read from a consumer file is evaluated, sourced, or interpolated into a command.
The `successor` prose is plugin-authored, but the convention doc the model reads while following
it is consumer prose and is treated as untrusted input.
- **A `migrate` record refuses `--clean` without `--i-migrated`.** A cheap deterministic backstop;
the real gate is the operator's confirmation in `apply`.
- **Bash 3.2, no jq, no python.** The helper runs wherever the Bash tool runs, including Git Bash on
Expand Down Expand Up @@ -291,9 +295,10 @@ the row states the manifest as it exists on `main`.

| Plugin | Manifest record count | First record date |
|---|---|---|
| `source-control` | 1 | 2026-07-23 |

Row shape for a plugin adding itself: `` `<plugin>` `` \| count \| `YYYY-MM-DD`. No plugin ships a
manifest yet; the pilot surface is `plugin-quality`'s `.claude/plugin-quality.md` (ADR 0018).
Row shape for a plugin adding itself: `` `<plugin>` `` \| count \| `YYYY-MM-DD`. The pilot surface
for the convention-doc expression form is `plugin-quality`'s `.claude/plugin-quality.md` (ADR 0018).

## Deferred

Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-config/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "claude-config",
"version": "0.40.28",
"version": "0.40.29",
"description": "Nine configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-permission-state (the permission rules actually in effect \u2014 every settings scope merged with per-rule provenance, what auto mode drops on entry, config written where nothing reads it, and which managed intents are enforced versus loosenable), draft-auto-mode-rules (interview and draft a paste-ready autoMode classifier block; prints only, never writes), audit-instructions (locally-owned instruction surfaces vs current model capability \u2014 proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane \u2014 posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target \u2014 three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate \u2014 delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns).",
"author": {
"name": "Melodic Software",
Expand Down
6 changes: 6 additions & 0 deletions plugins/claude-config/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
All notable changes to the `claude-config` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.40.29]

### Changed

- **`lib/check-retirements.sh`:** optional `heading` field on `kind: line` restricts detection and `--clean` to the body of matching ATX sections (retired-conventions contract 1.1). A standalone line that matches `match` outside that heading is not a leftover.

## [0.40.28]

### Changed
Expand Down
102 changes: 88 additions & 14 deletions plugins/claude-config/lib/check-retirements.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
# path repo-relative; absolute, `..` segments, a leading `~`,
# backslashes, `.` and tabs are rejected
# match POSIX ERE — REQUIRED for kind line, forbidden otherwise
# heading optional ATX heading (1-6 hashes, whitespace, title), kind
# line only: the record only fires when a matching line sits
# in that heading's section body, so a standalone occurrence
# elsewhere in a markdown file is not a leftover
# content_match optional POSIX ERE, kind file only: the record only fires
# when the file's content matches, so a path the successor
# reuses is not reported as a leftover
Expand All @@ -35,9 +39,13 @@
#
# DETECTION. Per kind: file = a regular file exists at path AND (no
# content_match OR it matches); dir = a directory exists; line = the file
# exists AND some line matches `match`. A trailing carriage return is stripped
# from every line before matching, so a `$`-anchored pattern matches a
# CRLF-authored file. One TSV row per leftover on stdout:
# exists AND some line matches `match` (and, when `heading` is set, that line
# sits in the body of a markdown section whose heading line equals `heading`).
# A section runs from the line after that heading through the line before the
# next ATX heading of the same or higher level, or EOF; every such section is
# searched. A trailing carriage return is stripped from every line before
# matching, so a `$`-anchored pattern matches a CRLF-authored file. One TSV
# row per leftover on stdout:
#
# id<TAB>kind<TAB>path<TAB>action<TAB>status<TAB>note
#
Expand All @@ -49,9 +57,10 @@
# directory (only after re-resolving that it is inside the root and is not the
# root itself). remove-line rewrites the file keeping every non-matching line
# byte-for-byte — each line's own ending survives, so a CRLF file stays CRLF —
# via a temp file in the same directory and a rename. A migrate record refuses
# to clean until `--i-migrated` states that the successor prose was followed;
# it then removes the artifact the way its kind implies.
# via a temp file in the same directory and a rename. When `heading` is set,
# only matching lines inside that heading's section body are removed. A migrate
# record refuses to clean until `--i-migrated` states that the successor prose
# was followed; it then removes the artifact the way its kind implies.
#
# VALIDATION FAILS THE WHOLE RUN. An invalid record — bad kind, missing match,
# absolute path, duplicate id, migrate without successor, an unknown key — is
Expand Down Expand Up @@ -178,6 +187,7 @@ REC_ID=()
REC_KIND=()
REC_PATH=()
REC_MATCH=()
REC_HEADING=()
REC_CONTENT_MATCH=()
REC_ACTION=()
REC_SUCCESSOR=()
Expand All @@ -203,14 +213,14 @@ ere_valid() {
# Current record's fields; reset at each `---`.
r_start=0
r_id="" r_retired="" r_plugin_version="" r_kind="" r_path="" r_match=""
r_content_match="" r_action="" r_successor="" r_note="" r_status=""
r_heading="" r_content_match="" r_action="" r_successor="" r_note="" r_status=""
r_keys=" "
r_nonempty=0

reset_record() {
r_start=$1
r_id="" r_retired="" r_plugin_version="" r_kind="" r_path="" r_match=""
r_content_match="" r_action="" r_successor="" r_note="" r_status=""
r_heading="" r_content_match="" r_action="" r_successor="" r_note="" r_status=""
r_keys=" "
r_nonempty=0
}
Expand Down Expand Up @@ -290,6 +300,12 @@ $r_id
invalid match "is only allowed for kind line (kind is $r_kind)"
fi

if [[ -n "$r_heading" ]]; then
[[ "$r_kind" == "line" ]] || invalid heading "is only allowed for kind line (kind is $r_kind)"
printf '%s' "$r_heading" | grep -Eq '^#{1,6}[[:space:]]+[^[:space:]]' ||
invalid heading "must be an ATX heading (1-6 hashes, whitespace, title): '$r_heading'"
fi

if [[ -n "$r_content_match" ]]; then
[[ "$r_kind" == "file" ]] || invalid content_match "is only allowed for kind file (kind is $r_kind)"
ere_valid "$r_content_match" || invalid content_match "is not a valid POSIX ERE: '$r_content_match'"
Expand Down Expand Up @@ -325,6 +341,7 @@ $r_id
REC_KIND[REC_COUNT]="$r_kind"
REC_PATH[REC_COUNT]="$r_path"
REC_MATCH[REC_COUNT]="$r_match"
REC_HEADING[REC_COUNT]="$r_heading"
REC_CONTENT_MATCH[REC_COUNT]="$r_content_match"
REC_ACTION[REC_COUNT]="$r_action"
REC_SUCCESSOR[REC_COUNT]="$r_successor"
Expand Down Expand Up @@ -383,6 +400,7 @@ while IFS= read -r line || [[ -n "$line" ]]; do
kind) r_kind="$value" ;;
path) r_path="$value" ;;
match) r_match="$value" ;;
heading) r_heading="$value" ;;
content_match) r_content_match="$value" ;;
action) r_action="$value" ;;
successor) r_successor="$value" ;;
Expand All @@ -407,9 +425,63 @@ content_hits() {
awk '{ sub(/\r$/, ""); print }' "$1" | grep -E -e "$2" >/dev/null
}

# section_body_nrs <file> <heading> — one 1-based line number per line that
# sits in the body of every markdown section whose heading line equals
# <heading> (trailing whitespace ignored on both sides). The heading line
# itself is excluded. A section ends at the next ATX heading of the same or
# higher level, or EOF. POSIX awk only: no interval quantifiers.
section_body_nrs() {
awk -v heading="$2" '
function rtrim(s) {
sub(/[ \t]+$/, "", s)
return s
}
function atx_level(s, n) {
n = 0
while (substr(s, n + 1, 1) == "#") n++
if (n >= 1 && n <= 6 && substr(s, n + 1, 1) ~ /[ \t]/) return n
return 0
}
{
sub(/\r$/, "")
trimmed = rtrim($0)
if (in_section) {
lvl = atx_level(trimmed)
if (lvl > 0 && lvl <= start_level) in_section = 0
}
if (in_section == 0 && trimmed == heading) {
in_section = 1
start_level = atx_level(trimmed)
if (start_level == 0) start_level = 6
next
}
if (in_section) print NR
}
' "$1"
}

# matching_line_nrs <file> <ere> [heading] — space-separated 1-based line
# numbers whose text (CR stripped) matches <ere>. When <heading> is non-empty,
# only lines inside that heading's section body.
matching_line_nrs() {
local file="$1" ere="$2" heading="${3:-}" all scoped n
all=$(awk '{ sub(/\r$/, ""); print }' "$file" | grep -E -n -e "$ere" | cut -d: -f1 | tr '\n' ' ')
if [[ -z "$heading" ]]; then
printf '%s' "$all"
return
fi
scoped=$(section_body_nrs "$file" "$heading" | tr '\n' ' ')
for n in $all; do
case " $scoped " in
*" $n "*) printf '%s ' "$n" ;;
*) ;;
esac
done
}

# present <index> — 0 when record <index>'s artifact is present in ROOT.
present() {
local i="$1" target
local i="$1" target nrs
target="$ROOT/${REC_PATH[$i]}"
case "${REC_KIND[$i]}" in
file)
Expand All @@ -422,7 +494,8 @@ present() {
;;
line)
[[ -f "$target" ]] || return 1
content_hits "$target" "${REC_MATCH[$i]}"
nrs=$(matching_line_nrs "$target" "${REC_MATCH[$i]}" "${REC_HEADING[$i]}")
[[ -n "${nrs// /}" ]]
;;
*) return 1 ;;
esac
Expand Down Expand Up @@ -534,10 +607,11 @@ line)
assert_target_inside_root
match="${REC_MATCH[$idx]}"
# Which input lines match, by number, decided once by grep -E (the same ERE
# dialect detection used); awk then copies every other line through with
# its own bytes, CR included. Only the matched lines' text is stripped of the
# CR, and only for the comparison.
matched_lines=$(awk '{ sub(/\r$/, ""); print }' "$target" | grep -E -n -e "$match" | cut -d: -f1 | tr '\n' ' ')
# dialect detection used); when heading is set, only section-body hits
# count. awk then copies every other line through with its own bytes, CR
# included. Only the matched lines' text is stripped of the CR, and only
# for the comparison.
matched_lines=$(matching_line_nrs "$target" "$match" "${REC_HEADING[$idx]}")
[[ -n "$matched_lines" ]] || {
echo "check-retirements: $CLEAN_ID: no line of $path matches; nothing to clean." >&2
exit 1
Expand Down
Loading