diff --git a/CLAUDE.md b/CLAUDE.md index e2d6567..8e2a6c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Slash commands users invoke (e.g., `/discover`, `/review`, `/deploy`). Defined i ### Hooks -Automation hooks in `hooks/hooks.json`. `PostToolUse` runs validation after Write/Edit operations. `PreToolUse` runs `pr-op-gate`, which measures the body of a `gh pr|issue create|edit` call against the `pr-conventions` bar and denies the call when it misses. +Automation hooks in `hooks/hooks.json`. `PostToolUse` runs validation after Write/Edit operations. `PreToolUse` runs `pr-op-gate`, which measures the body of a `gh pr|issue create|edit` call against the `pr-conventions` bar. A create must meet the bar; an edit is scored against the body already posted and denied only where it comes out worse. ### Pipeline diff --git a/plugins/datum-platform/.claude-plugin/plugin.json b/plugins/datum-platform/.claude-plugin/plugin.json index db5ba88..f2b5a81 100644 --- a/plugins/datum-platform/.claude-plugin/plugin.json +++ b/plugins/datum-platform/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "datum-platform", "description": "Kubernetes platform engineering automation with aggregated API servers, controller patterns, GitOps deployment, and platform capabilities", - "version": "1.10.0", + "version": "1.11.0", "author": { "name": "Datum Cloud", "url": "https://github.com/datum-cloud" diff --git a/plugins/datum-platform/hooks/pr-op-gate b/plugins/datum-platform/hooks/pr-op-gate index 8b4c0c4..f5e43f0 100755 --- a/plugins/datum-platform/hooks/pr-op-gate +++ b/plugins/datum-platform/hooks/pr-op-gate @@ -2,8 +2,11 @@ # PreToolUse gate for `gh pr|issue create|edit`. # # Measures the body being posted against the countable rules in the -# pr-conventions skill and denies the call when it misses them. Style guidance -# that cannot be counted rides along as advisory context. +# pr-conventions skill. A create is measured against an empty baseline, so it +# has to meet the bar outright. An edit is measured against the body already +# posted and is refused only on dimensions where it scores worse, so a state +# update to a post written before the convention, or by someone else, passes. +# Style guidance that cannot be counted rides along as advisory context. set -uo pipefail @@ -11,6 +14,10 @@ INPUT=$(cat) COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty') [[ "$COMMAND" =~ gh[[:space:]]+(pr|issue)[[:space:]]+(create|edit) ]] || exit 0 +KIND="${BASH_REMATCH[1]}" +VERB="${BASH_REMATCH[2]}" + +DIMENSIONS=9 ADVICE='Writing rules for this body (datum-platform:pr-conventions): cut every word carrying no fact; short word over long; active over passive; one idea per paragraph; many short paragraphs over one dense block; no "simply"/"just"/"easily"; no em dashes, no "load-bearing", no "gotchas"; an ordered list rather than arrows for a sequence; say it once, so anything already in the linked issue gets linked, not restated. Depth belongs in a comment or the commit message.' @@ -50,16 +57,10 @@ extract_body() { printf '%s' "$COMMAND" | sed -n -E "s/.*(--body|-b)[= ]+'([^']*)'.*/\2/p" | head -1 } -BODY=$(extract_body) - -if [ -z "${BODY//[[:space:]]/}" ]; then - emit "$ADVICE" - exit 0 -fi - -VIOLATIONS=$(printf '%s\n' "$BODY" | LC_ALL=C awk \ - -v wide="$(printf '\xf0\x9f')" -v narrow="$(printf '\xe2')" \ - -v emdash="$(printf '\xe2\x80\x94')" ' +measure() { + LC_ALL=C awk \ + -v wide="$(printf '\xf0\x9f')" -v narrow="$(printf '\xe2')" \ + -v emdash="$(printf '\xe2\x80\x94')" ' function flush_para( i, n, wrapped) { n = para_n para_n = 0 @@ -128,24 +129,210 @@ END { gsub(/e\.g\.|i\.e\.|etc\.|vs\./, "", summary) sentences = gsub(/[.!?]+([ \t]|$)/, "&", summary) + bullets if (boxes == 0) boxes = other_boxes + if (!has_heading) sentences = 0 - if (has_heading && sentences > 4) printf "- Summary runs %d sentences against a cap of 4. Cut it to the problem, then what changes for users or operators.\n", sentences - if (boxes > 4) printf "- Test plan has %d checkboxes against a cap of 4. Collapse build/lint/test rows into one and keep behavioral outcomes only.\n", boxes - if (code) print "- Body names file paths, identifiers, or tool invocations. Those belong in a comment or the commit message, not the opening post." - if (closes) print "- Body uses a Closes keyword. Use Fixes or Resolves when the merge should close the issue, Related to when it should not." - if (emoji) print "- Body uses an emoji heading. Use GitHub callout syntax for emphasis instead." - if (hardwrap) print "- Body hard-wraps prose at a fixed column. GitHub reflows Markdown, so wrap only where syntax needs it." - if (emdashes) printf "- Body uses %d em dash(es). Replace each with a period, a comma, or nothing.\n", emdashes - if (loadbearing) print "- Body says load-bearing. Name the dependency instead: what relies on the thing, and what breaks without it." - if (gotchas) print "- Body says gotchas. Use caveats, watch-outs, constraints, or limitations, in headings too." + printf "sentences\t%d\n", sentences + printf "boxes\t%d\n", boxes + printf "code\t%d\n", (code ? 1 : 0) + printf "closes\t%d\n", (closes ? 1 : 0) + printf "emoji\t%d\n", (emoji ? 1 : 0) + printf "hardwrap\t%d\n", (hardwrap ? 1 : 0) + printf "emdashes\t%d\n", emdashes + printf "loadbearing\t%d\n", (loadbearing ? 1 : 0) + printf "gotchas\t%d\n", (gotchas ? 1 : 0) +} +' } -') + +compare() { + awk -F'\t' -v mode="$1" -v want="$DIMENSIONS" ' +function over(v, cap) { return (v > cap) ? v - cap : 0 } +function worse(d, cap) { return over(cur[d], cap) > over(base[d], cap) } + +FNR == NR { base[$1] = $2; basen++; next } +{ cur[$1] = $2; curn++ } + +END { + if (basen != want || curn != want) { + print "gate could not measure both bodies on all " want " dimensions" > "/dev/stderr" + exit 3 + } + + if (worse("sentences", 4)) { + if (mode == "edit") + printf "- Edit takes the summary from %d to %d sentences, against a cap of 4. Bring it back to the count you found.\n", base["sentences"], cur["sentences"] + else + printf "- Summary runs %d sentences against a cap of 4. Cut it to the problem, then what changes for users or operators.\n", cur["sentences"] + } + if (worse("boxes", 4)) { + if (mode == "edit") + printf "- Edit takes the test plan from %d to %d checkboxes, against a cap of 4.\n", base["boxes"], cur["boxes"] + else + printf "- Test plan has %d checkboxes against a cap of 4. Collapse build/lint/test rows into one and keep behavioral outcomes only.\n", cur["boxes"] + } + if (worse("code", 0)) { + if (mode == "edit") + print "- Edit adds file paths, identifiers, or tool invocations the posted body does not carry. Those belong in a comment or the commit message." + else + print "- Body names file paths, identifiers, or tool invocations. Those belong in a comment or the commit message, not the opening post." + } + if (worse("closes", 0)) { + if (mode == "edit") + print "- Edit adds a Closes keyword. Use Fixes or Resolves when the merge should close the issue, Related to when it should not." + else + print "- Body uses a Closes keyword. Use Fixes or Resolves when the merge should close the issue, Related to when it should not." + } + if (worse("emoji", 0)) { + if (mode == "edit") + print "- Edit adds an emoji heading. Use GitHub callout syntax for emphasis instead." + else + print "- Body uses an emoji heading. Use GitHub callout syntax for emphasis instead." + } + if (worse("hardwrap", 0)) { + if (mode == "edit") + print "- Edit adds hard-wrapped prose. GitHub reflows Markdown, so wrap only where syntax needs it." + else + print "- Body hard-wraps prose at a fixed column. GitHub reflows Markdown, so wrap only where syntax needs it." + } + if (worse("emdashes", 0)) { + if (mode == "edit") + printf "- Edit takes em dashes from %d to %d. Replace each new one with a period, a comma, or nothing.\n", base["emdashes"], cur["emdashes"] + else + printf "- Body uses %d em dash(es). Replace each with a period, a comma, or nothing.\n", cur["emdashes"] + } + if (worse("loadbearing", 0)) { + if (mode == "edit") + print "- Edit adds load-bearing. Name the dependency instead: what relies on the thing, and what breaks without it." + else + print "- Body says load-bearing. Name the dependency instead: what relies on the thing, and what breaks without it." + } + if (worse("gotchas", 0)) { + if (mode == "edit") + print "- Edit adds gotchas. Use caveats, watch-outs, constraints, or limitations, in headings too." + else + print "- Body says gotchas. Use caveats, watch-outs, constraints, or limitations, in headings too." + } +} +' "$2" "$3" +} + +scrubbed_command() { + printf '%s' "$COMMAND" | sed -E "s/'[^']*'/''/g; s/\"[^\"]*\"/\"\"/g" +} + +target_selector() { + scrubbed_command | awk -v kind="$KIND" -v verb="$VERB" ' +{ + n = split($0, t, /[ \t]+/) + for (i = 1; i <= n; i++) + if (t[i] == "gh" && t[i + 1] == kind && t[i + 2] == verb) { start = i + 3; break } + if (!start) exit 0 + for (i = start; i <= n; i++) { + tok = t[i] + if (tok == "") continue + if (tok ~ /^(&&|\|\||;|\||>|>>|2>)$/) exit 0 + if (tok ~ /^-/) { + if (tok ~ /=/) continue + if (tok ~ /^(--remove-milestone|--web|--draft|--help|-h)$/) continue + i++ + continue + } + print tok + exit 0 + } +} +' +} + +run_bounded() { + if command -v timeout >/dev/null 2>&1; then + timeout 10 "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout 10 "$@" + else + "$@" + fi +} + +fetch_live_body() { + local selector repo workdir out + selector=$(target_selector) + repo=$(unquote "$(printf '%s' "$COMMAND" | sed -n -E 's/.*(--repo|-R)[= ]+([^ ]+).*/\2/p' | head -1)") + workdir=$(unquote "$(printf '%s' "$COMMAND" | sed -n -E 's/^[[:space:]]*cd[[:space:]]+([^ ]+)[[:space:]]*&&.*/\1/p' | head -1)") + + [ "$KIND" = "issue" ] && [ -z "$selector" ] && return 1 + + local -a view + view=(gh "$KIND" view) + [ -n "$selector" ] && view+=("$selector") + [ -n "$repo" ] && view+=(--repo "$repo") + view+=(--json body) + + out=$( + if [ -n "$workdir" ] && [ -d "$workdir" ]; then cd "$workdir" || exit 1; fi + run_bounded "${view[@]}" 2>/dev/null + ) || return 1 + + printf '%s' "$out" | jq -e -r '.body // ""' 2>/dev/null +} + +BODY=$(extract_body) + +if [ -z "${BODY//[[:space:]]/}" ]; then + emit "$ADVICE" + exit 0 +fi + +WORK=$(mktemp -d) || { emit "$ADVICE"; exit 0; } +trap 'rm -rf "$WORK"' EXIT + +printf '%s\n' "$BODY" | measure > "$WORK/cur" + +MODE="create" +printf '' | measure > "$WORK/base" + +UNVERIFIED="" +if [ "$VERB" = "edit" ]; then + if LIVE=$(fetch_live_body); then + MODE="edit" + printf '%s\n' "$LIVE" | measure > "$WORK/base" + else + UNVERIFIED="yes" + fi +fi + +VIOLATIONS=$(compare "$MODE" "$WORK/base" "$WORK/cur" 2>/dev/null) +RC=$? + +if [ "$RC" -ne 0 ]; then + deny "The datum-platform:pr-conventions gate could not measure this body, so it refused rather than pass it unchecked. Report the gate failure instead of retrying the same call." + exit 0 +fi + +if [ -n "$UNVERIFIED" ]; then + if [ -n "$VIOLATIONS" ]; then + emit "The posted body could not be read, so this edit was allowed through without comparing it. The body you are about to post misses the datum-platform:pr-conventions bar here: + +$VIOLATIONS +Fix whichever of those your edit introduced. Leave alone anything that was already in the post. $ADVICE" + else + emit "$ADVICE" + fi + exit 0 +fi if [ -n "$VIOLATIONS" ]; then - deny "This body misses the datum-platform:pr-conventions bar: + if [ "$MODE" = "edit" ]; then + deny "This edit moves the posted body further from the datum-platform:pr-conventions bar: + +$VIOLATIONS +An edit only has to leave the body no worse than it found it, so revise what the edit adds. Anything already in the post is not yours to fix. $ADVICE" + else + deny "This body misses the datum-platform:pr-conventions bar: $VIOLATIONS Revise it and post again. $ADVICE" + fi exit 0 fi diff --git a/plugins/datum-platform/skills/pr-conventions/SKILL.md b/plugins/datum-platform/skills/pr-conventions/SKILL.md index 56e5053..76e8800 100644 --- a/plugins/datum-platform/skills/pr-conventions/SKILL.md +++ b/plugins/datum-platform/skills/pr-conventions/SKILL.md @@ -7,7 +7,8 @@ description: Covers GitHub conventions for pull requests, issues, and comments i Rules for PR, issue, and comment bodies in every `datum-cloud`, `milo-os`, and `datum-labs` repository. The `pr-op-gate` hook enforces the countable ones on -`gh pr|issue create|edit` and blocks the call when a body misses them. +`gh pr|issue create|edit`. A new post has to meet the bar outright. An edit has +to leave the post no further from the bar than it found it. ## The bar @@ -208,25 +209,25 @@ title outlives every comment. `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`. Never an emoji header (`## ⚠️ ...`). If everything is highlighted, nothing is. -## When the gate blocks a body you did not write +## Editing a post you did not write -`pr-op-gate` measures the whole body on `gh pr|issue edit`, so ticking a -checkbox on an opening post written before the gate existed gets refused for -violations already in that body. Rewriting the post to satisfy the gate would -destroy the substance it exists to carry. +On an edit, `pr-op-gate` reads the body already posted and scores it on the same +rules as the body you are about to post. It refuses only where your version +scores worse. Ticking a checkbox on a post written before the convention passes. +Adding an em dash to that same post does not. -Never route around the gate on a body you are authoring; fix those. For a state -update to a body whose violations predate your change, diff the edit against the -live body to show it is mechanical, apply it through the API, and say in your -reply that the gate was bypassed and why. The silence is the thing to avoid, not -the routing. +So the misses that predate your change are not yours to fix, and the answer to a +colleague's unformatted issue is never to rewrite their words. -``` -gh api -X PATCH /repos/{owner}/{repo}/issues/{n} -F body=@file -``` +An edit that touches only labels, a title, or a milestone is never measured +against body rules at all. + +When the gate cannot read the posted body, from a failed fetch or a target it +cannot resolve, it allows the edit and says so, listing what the body it is +about to post misses. Fix whatever your edit introduced and leave the rest. -Whole-body prose rewrites do not need this. Reach for the API route only when -the gate actually refuses. +There is no route around the gate, and no need for one. A body you are authoring +gets fixed, not bypassed. ## Example