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
2 changes: 1 addition & 1 deletion plugins/source-control/.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": "source-control",
"version": "0.61.2",
"version": "0.61.3",
"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 standing merge-rung raises binding from the team-tracked layer only, with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /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, which interviews the repo and writes 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",
Expand Down
7 changes: 7 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
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.61.3] - 2026-09-27

### Fixed

- The PR-body linkage gates (`pr-body-linkage-gate.sh`, `pr-linkage-mcp-gate.sh`) agree with the `pr-contract` step again (melodic-software/ci-workflows#544). A `Refs: #N` or `Relates to: #N` line of its own (optional `owner/repo`, up to three spaces of indent) counts as linkage, so a PR that references an issue it must not close is no longer told to add a closing keyword. A closing keyword with a disclaimer among the five words before it (`not`, `never`, `no`, `without`, `deliberately`, `intentionally`, or an `n't` contraction, after the last `.!?;,`) is blocked as a negated closing reference, even beside valid linkage, because GitHub's parser still closes the issue on merge. The closing keyword is matched one line at a time, as CI does. The block messages use CI's wording and the remedy names the `Refs:` marker.
- A body line starting with a lowercase `t` is no longer masked as indented code, and a tab-indented line now is. The validator's regex spelled the tab as `\t`, which a bash regex reads as a literal `t`, so a line such as "this PR closes #5", or a `## Fix` section whose text began with "t", was dropped from the scan.

## [0.61.2] - 2026-09-27

### Changed
Expand Down
7 changes: 5 additions & 2 deletions plugins/source-control/hooks/pr-body-linkage-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
# then an unterminated `<!--` swallowing the rest — both, in that order,
# exactly as the validator does), the body must carry
# (a) a native closing keyword (`Closes/Fixes/Resolves #N`, including
# `owner/repo#N`) OR the literal `No linked issue` / `No related issue:`;
# `owner/repo#N`), a non-closing `Refs: #N` / `Relates to: #N` marker on
# its own line, OR the literal `No linked issue` / `No related issue:`;
# and no negated closing reference (`does not close #N`) anywhere, which
# GitHub's negation-blind parser would still close on merge;
# (b) four present AND non-empty contract sections — `## Summary`, `## Fix`,
# `## Verification`, `## Related` — where a DEEPER heading (`### …`) is
# that section's content, not its terminator.
Expand Down Expand Up @@ -290,7 +293,7 @@ block() {
for p in "$@"; do echo " - $p" >&2; done
echo "Gate: ${GATE_FILE#"$REPO_ROOT/"} (its pr-contract step)." >&2
echo "Add to the body:" >&2
echo " Closes #<issue> (or the literal line: No linked issue)" >&2
echo " Closes #<issue> (or on its own line: Refs: #<issue> to link without closing, or No linked issue)" >&2
echo " ## Summary" >&2
echo " <what and why>" >&2
echo " ## Fix" >&2
Expand Down
59 changes: 59 additions & 0 deletions plugins/source-control/hooks/pr-body-linkage-gate.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ assert_block "a ## Fix that exists only inside a fenced sample is not the sectio
assert_block "a ## Fix that exists only as indented code is not the section" "$GATED" "gh pr create -t T --body-file indented-fix.md"
assert_block "a ## Fix that exists only in an inline span is not the section" "$GATED" "gh pr create -t T --body-file inline-fix.md"
assert_allow "a real ## Fix is not hidden by a later fenced sample" "$GATED" "gh pr create -t T --body-file real-plus-fenced-fix.md"
printf '%s\n' $'Closes #5\n\n## Summary\n\nx\n\n## Fix\n\n\tonly tab-indented code\n\n## Verification\n\nx\n\n## Related\n\n- x' >"$GATED/tab-indented-fix.md"
assert_block "a tab-indented line is code, leaving ## Fix empty" "$GATED" "gh pr create -t T --body-file tab-indented-fix.md"
assert_allow "a line starting with a lowercase t is not code" "$GATED" \
"$(gh_body $'this PR closes #5\n\n## Summary\n\nx\n\n## Fix\n\nthe change\n\n## Verification\n\nx\n\n## Related\n\n- x')"
# On a line of unmatched, strictly-increasing backtick-run lengths, the
# pair-after-collect scan must not rescan the remainder from each opener
# (superlinear): it must finish quickly and still fail-closed on a missing
Expand Down Expand Up @@ -218,6 +222,61 @@ assert_allow "No linked issue marker accepted" "$GATED" \
assert_allow "No related issue: marker accepted" "$GATED" \
"$(gh_body $'No related issue: drift sweep'"$SECTIONS")"

# Non-closing marker (ci-workflows#544): a `Refs:` / `Relates to:` line of its
# own is linkage; anything else on that line, or no colon, is not.
assert_allow "Refs: #N on its own line accepted" "$GATED" \
"$(gh_body $'Refs: #1234'"$SECTIONS")"
assert_allow "Relates to: #N on its own line accepted" "$GATED" \
"$(gh_body $'Relates to: #1234'"$SECTIONS")"
assert_allow "Refs: owner/repo#N accepted" "$GATED" \
"$(gh_body $'Refs: melodic-software/standards#5'"$SECTIONS")"
assert_allow "Refs: marker indented three spaces accepted" "$GATED" \
"$(gh_body $' REFS: #5 '"$SECTIONS")"
assert_block "Refs: marker with trailing prose is not linkage" "$GATED" \
"$(gh_body $'Refs: #5 for context'"$SECTIONS")"
assert_block "Refs #N without a colon is not linkage" "$GATED" \
"$(gh_body $'Refs #5'"$SECTIONS")"
assert_block "a closing keyword split across lines is not linkage" "$GATED" \
"$(gh_body $'Closes\n#5'"$SECTIONS")"

# Negated closers: GitHub's parser ignores the negation and closes the issue
# anyway, so CI fails them even beside valid linkage.
assert_block "negated closer blocks" "$GATED" \
"$(gh_body $'This does not close #5.'"$SECTIONS")"
assert_block "negated closer blocks even beside a Refs: marker" "$GATED" \
"$(gh_body $'Refs: #5\n\nThis PR deliberately never fixes #5.'"$SECTIONS")"
assert_block "negated closer blocks even beside a real closing keyword" "$GATED" \
"$(gh_body $'Closes #4\n\nThis is not meant to resolve #5 here.'"$SECTIONS")"
printf '%s\n' "Refs: #5"$'\n\n'"This doesn't close #5.$SECTIONS" >"$GATED/negated-apostrophe.md"
assert_block "an n't contraction negates a closer" "$GATED" \
"gh pr create -t T --body-file negated-apostrophe.md"
printf '%s\n' "Refs: #5"$'\n\n'$'This doesn\xe2\x80\x99t close #5.'"$SECTIONS" >"$GATED/negated-typographic.md"
assert_block "a typographic-apostrophe contraction negates a closer" "$GATED" \
"gh pr create -t T --body-file negated-typographic.md"
assert_allow "\"not only\" is affirmative" "$GATED" \
"$(gh_body $'This not only closes #5 but more.'"$SECTIONS")"
assert_allow "a negation before the last comma does not reach the closer" "$GATED" \
"$(gh_body $'Not a draft, closes #5'"$SECTIONS")"
assert_allow "a negation more than five words back does not reach the closer" "$GATED" \
"$(gh_body $'Not one two three four five closes #5'"$SECTIONS")"
printf '%s\n' $'Closes #4\n\nThe template example `does not close #5` is shown.'"$SECTIONS" >"$GATED/negated-inline.md"
assert_allow "a negated closer inside inline code is masked" "$GATED" \
"gh pr create -t T --body-file negated-inline.md"

run "$GATED" "$(gh_body $'This does not close #5. We never fix #6.\nIt does not close #5 either.'"$SECTIONS")"
if [[ "$ERR" == *'Negated closing reference ("close #5" (trigger "not"), "fix #6" (trigger "never")).'* &&
"$ERR" == *'Missing a native closing keyword (Closes/Fixes/Resolves #N). If this PR references an issue it must not close, put "Refs: #N"'* ]]; then
ok "negated-closer message lists each distinct closer once with its trigger, beside the missing-linkage message"
else
fail "negated-closer or missing-linkage message wrong: $ERR"
fi
run "$GATED" "$(gh_body "$NO_KEYWORD")"
if [[ "$ERR" == *'"Refs: #N" (or "Relates to: #N")'* && "$ERR" == *'Refs: #<issue>'* && "$ERR" != *'Negated'* ]]; then
ok "missing-linkage message and remedy name the Refs: marker"
else
fail "missing-linkage message or remedy lacks the Refs: marker: $ERR"
fi

# Word boundaries: JavaScript's \b makes both of these non-matches, so a body
# carrying only one of them must still block.
assert_block "trailing word char after #N is not a match" "$GATED" \
Expand Down
7 changes: 5 additions & 2 deletions plugins/source-control/hooks/pr-linkage-mcp-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
# stripping HTML comments (terminated spans, then an unterminated `<!--`
# swallowing the rest), the body must carry
# (a) a native closing keyword (`Closes/Fixes/Resolves #N`, including
# `owner/repo#N`) OR the literal `No linked issue` / `No related issue:`;
# `owner/repo#N`), a non-closing `Refs: #N` / `Relates to: #N` marker on
# its own line, OR the literal `No linked issue` / `No related issue:`;
# and no negated closing reference (`does not close #N`) anywhere, which
# GitHub's negation-blind parser would still close on merge;
# (b) four present AND non-empty contract sections — `## Summary`, `## Fix`,
# `## Verification`, `## Related` — where a DEEPER heading (`### ...`)
# is that section's content, not its terminator.
Expand Down Expand Up @@ -293,7 +296,7 @@ echo "BLOCKED: PR body fails this repo's PR-contract check." >&2
for p in "${LINKAGE_PROBLEMS[@]}"; do echo " - $p" >&2; done
echo "Gate: ${GATE_FILE#"$REPO_ROOT/"} (its pr-contract step)." >&2
echo "Add to the body:" >&2
echo " Closes #<issue> (or the literal line: No linked issue)" >&2
echo " Closes #<issue> (or on its own line: Refs: #<issue> to link without closing, or No linked issue)" >&2
echo " ## Summary" >&2
echo " <what and why>" >&2
echo " ## Fix" >&2
Expand Down
2 changes: 2 additions & 0 deletions plugins/source-control/hooks/pr-linkage-mcp-gate.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ run 2 "markers only inside HTML comments block" "$(payload "$GATED" $CREATE $OWN
run 2 "bare 'Closes #' with no number blocks" "$(payload "$GATED" $CREATE $OWNER $REPO "$BARE_HASH")"
run 0 "deeper heading is section content" "$(payload "$GATED" $CREATE $OWNER $REPO "$DEEP_HEADING")"
run 0 "owner/repo#N keyword form passes" "$(payload "$GATED" $CREATE $OWNER $REPO "$CROSS_REPO")"
run 0 "Refs: #N non-closing marker passes" "$(payload "$GATED" $CREATE $OWNER $REPO $'Refs: #12'"$SECTIONS")"
run 2 "negated closer blocks beside a Refs: marker" "$(payload "$GATED" $CREATE $OWNER $REPO $'Refs: #12\n\nThis does not close #12.'"$SECTIONS")"
run 2 "create with no body field blocks (empty body)" "$(payload "$GATED" $CREATE $OWNER $REPO)"
run 0 "update with no body field passes" "$(payload "$GATED" $UPDATE $OWNER $REPO)"
run 2 "update with bad body blocks" "$(payload "$GATED" $UPDATE $OWNER $REPO "$NO_RELATED")"
Expand Down
108 changes: 93 additions & 15 deletions plugins/source-control/hooks/pr-linkage-validator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,88 @@ trim_to() {
printf -v "$__plv_dest" '%s' "$s"
}

# The validator's regexes, transcribed to POSIX ERE. JavaScript's `\b` has no
# ERE equivalent, so the probe is wrapped in newlines and the boundary is spelled
# as an explicit non-word character on each side — `#12abc` and `unclosed #5`
# stay non-matches, exactly as `\b` makes them. Matched against a lower-cased
# probe in place of the `i` flag.
KEYWORD_ERE='[^a-z0-9_](close[sd]?|fix(es|ed)?|resolve[sd]?)[[:space:]]*:?[[:space:]]*([a-z0-9_.-]+/[a-z0-9_.-]+)?#[0-9]+[^a-z0-9_]'
# The pr-contract analyzer's linkage patterns (`scan_line` and the `no-issue`
# test in ci-workflows .github/actions/pr-contract/run.sh), transcribed to POSIX
# ERE and matched against lower-cased text in place of `tolower`. The closing
# keyword and the non-closing marker are matched one line at a time, as CI
# does; the no-issue phrase is matched over the whole body, wrapped in newlines
# so the explicit non-word boundary also holds at either end.
CLOSING_ERE='(close[sd]?|fix(es|ed)?|resolve[sd]?)[[:blank:]]*:?[[:blank:]]*([a-z0-9_.-]+/[a-z0-9_.-]+)?#[0-9]+'
NON_CLOSING_ERE='^ {0,3}(refs|relates[[:blank:]]+to):[[:blank:]]*([a-z0-9_.-]+/[a-z0-9_.-]+)?#[0-9]+[[:blank:]]*$'
NO_ISSUE_ERE='[^a-z0-9_]no (linked|related) issue[^a-z0-9_]'
_PLV_WORD_ERE="[A-Za-z][A-Za-z']*"

has_linkage() {
local probe
probe=$'\n'"${1,,}"$'\n'
[[ "$probe" =~ $KEYWORD_ERE || "$probe" =~ $NO_ISSUE_ERE ]]
# CI's `negation_trigger`: the first disclaimer word among the last five words
# between the previous `.!?;,` and the keyword, into <dest>; empty when there is
# none. A typographic apostrophe (U+2019) counts as a straight one, and "not
# only" is affirmative.
negation_trigger_to() {
local __plv_dest="$1" tail="${2##*[.!?;,]}" lower n first i
local -a words=()
printf -v "$__plv_dest" '%s' ""
tail="${tail//$'\xe2\x80\x99'/\'}"
while [[ "$tail" =~ $_PLV_WORD_ERE ]]; do
words+=("${BASH_REMATCH[0]}")
tail="${tail#*"${BASH_REMATCH[0]}"}"
done
n=${#words[@]}
first=0
((n > 5)) && first=$((n - 5))
for ((i = first; i < n; i++)); do
lower="${words[i],,}"
[[ "$lower" == not ]] && ((i + 1 < n)) && [[ "${words[i + 1],,}" == only ]] && continue
case "$lower" in
not | never | no | without | deliberately | intentionally | *"n't")
printf -v "$__plv_dest" '%s' "${words[i]}"
return 0
;;
*) ;;
esac
done
}

# Scan the masked body for linkage. Returns 0 when it carries an un-negated
# closing keyword, a non-closing `Refs:` / `Relates to:` marker on its own line,
# or a no-issue opt-out. Fills LINKAGE_NEGATED with one `"<text>" (trigger
# "<word>")` entry per distinct negated closing reference, in first-seen order.
# A negated closer never counts as linkage, and CI fails it even when valid
# linkage exists elsewhere, because GitHub's parser still closes the issue.
LINKAGE_NEGATED=()
scan_linkage() {
local line lower chunk m pre start len off before after text _plv_trigger i found=1
local -A negated_seen=()
LINKAGE_NEGATED=()
linkage::split_lines "$1"
for ((i = 0; i < ${#LINKAGE_LINES[@]}; i++)); do
line="${LINKAGE_LINES[i]}"
lower="${line,,}"
[[ "$lower" =~ $NON_CLOSING_ERE ]] && found=0
off=0
while chunk="${lower:off}" && [[ "$chunk" =~ $CLOSING_ERE ]]; do
m="${BASH_REMATCH[0]}"
# The leftmost match is also the first occurrence of its own text.
pre="${chunk%%"$m"*}"
start=$((off + ${#pre}))
len=${#m}
off=$((start + len))
before=""
((start > 0)) && before="${lower:start-1:1}"
after="${lower:off:1}"
[[ "$before" == [a-z0-9_] || "$after" == [a-z0-9_] ]] && continue
text="${line:start:len}"
negation_trigger_to _plv_trigger "${line:0:start}"
if [[ -z "$_plv_trigger" ]]; then
found=0
continue
fi
[[ -n "${negated_seen[$text]:-}" ]] && continue
negated_seen[$text]=1
LINKAGE_NEGATED+=("\"${text}\" (trigger \"${_plv_trigger}\")")
done
done
((found == 0)) && return 0
lower=$'\n'"${1,,}"$'\n'
[[ "$lower" =~ $NO_ISSUE_ERE ]]
}

# The four contract sections the pinned ci-workflows reusable requires
Expand Down Expand Up @@ -246,7 +316,8 @@ mask_markdown_code_to() {
continue
fi
fi
if [[ "$rest" =~ ^(\ {4}|\t) ]]; then
# A glob, not `=~ ^(\ {4}|\t)`: in a bash regex `\t` is a literal `t`.
if [[ "$rest" == " "* || "$rest" == $'\t'* ]]; then
out+=$'\n'
continue
fi
Expand Down Expand Up @@ -318,8 +389,8 @@ mask_markdown_code_to() {
}

# Aggregate verdict: strip comments, mask Markdown code (CI does both before
# any heading or keyword scan), then the closing-keyword half plus every
# required section. Fills the global LINKAGE_PROBLEMS array with one line per
# any heading or keyword scan), then every required section plus the linkage
# half: a negated closing reference, and missing linkage. Fills the global LINKAGE_PROBLEMS array with one line per
# problem so the author sees the full set in one pass; returns 0 when the body
# passes (array empty), 1 otherwise. The consuming hook owns what a failure
# DOES — block message, telemetry, exit code.
Expand All @@ -328,6 +399,7 @@ linkage::problems() {
# `_plv_`-prefixed per the DEST-NAME CONTRACT above: an out-variable named
# `body`, `out`, or `line` would be captured by the callee's own local.
local _plv_stripped="" _plv_body="" _plv_content="" heading
local _plv_linked=0 _plv_negated=""
strip_html_comments_to _plv_stripped "$1"
mask_markdown_code_to _plv_body "$_plv_stripped"
LINKAGE_PROBLEMS=()
Expand All @@ -339,8 +411,14 @@ linkage::problems() {
LINKAGE_PROBLEMS+=("Missing a \"## ${heading}\" section.")
fi
done
has_linkage "$_plv_body" ||
LINKAGE_PROBLEMS+=('Missing a native closing keyword (Closes/Fixes/Resolves #N) and no "No linked issue" marker.')
# shellcheck disable=SC2310 # the exit IS the verdict: linkage present or not
scan_linkage "$_plv_body" || _plv_linked=1
((${#LINKAGE_NEGATED[@]} == 0)) || {
printf -v _plv_negated '%s, ' "${LINKAGE_NEGATED[@]}"
LINKAGE_PROBLEMS+=("Negated closing reference (${_plv_negated%, }). GitHub's linkage parser ignores the surrounding words, so this still registers a closing reference and still auto-closes the issue when this PR merges. Remove the closing keyword and use \"Refs: #N\" (or \"Relates to: #N\") on its own line instead.")
}
((_plv_linked == 0)) ||
LINKAGE_PROBLEMS+=('Missing a native closing keyword (Closes/Fixes/Resolves #N). If this PR references an issue it must not close, put "Refs: #N" (or "Relates to: #N") on its own line. If it relates to no GitHub issue at all, state "No linked issue" (or "No related issue:") in the body instead.')
: >"$_PR_LINKAGE_SPLIT_FILE"
((${#LINKAGE_PROBLEMS[@]} == 0))
}
Loading