Skip to content
Draft
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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion plugins/datum-platform/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
233 changes: 210 additions & 23 deletions plugins/datum-platform/hooks/pr-op-gate
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@
# 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

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.'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
33 changes: 17 additions & 16 deletions plugins/datum-platform/skills/pr-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down