diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 738f422..8a11a1e 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -37,6 +37,10 @@ on: description: Max total changed lines before the auditor's edits are reverted type: string default: '800' + commit-body-line-length: + description: Fallback max characters per commit-body line when the repo has no detectable commit convention (the auditor honors the repo's own body-max-line-length when it finds one) + type: string + default: '100' skip-if-only-regex: description: ERE β€” the gate skips the audit when EVERY changed file matches type: string @@ -266,6 +270,7 @@ jobs: - name: Run docs auditor (Claude Code CLI) env: CLAUDE_VERSION: ${{ inputs.claude-code-version }} + COMMIT_BODY_LINE_LENGTH: ${{ inputs.commit-body-line-length }} run: | set -euo pipefail # Install via npm (not bun) so the postinstall that fetches the native binary runs. @@ -273,7 +278,14 @@ jobs: npm install -g --prefix "$RUNNER_TEMP/claude-cli" "@anthropic-ai/claude-code@$CLAUDE_VERSION" export PATH="$RUNNER_TEMP/claude-cli/bin:$PATH" claude --version + # Fallback commit-body line width injected into the prompt; validate before substituting. + LINE_MAX="${COMMIT_BODY_LINE_LENGTH:-100}" + if ! printf '%s' "$LINE_MAX" | grep -qE '^[1-9][0-9]*$'; then + echo "::error::commit-body-line-length must be a positive integer (got '$LINE_MAX')."; exit 1 + fi cat "$ENGINE_DIR/prompt-skeleton.md" "$POLICY_FILE" > "$RUNNER_TEMP/prompt.md" + sed "s/{{COMMIT_BODY_LINE_LENGTH}}/$LINE_MAX/g" "$RUNNER_TEMP/prompt.md" > "$RUNNER_TEMP/prompt.md.tmp" + mv "$RUNNER_TEMP/prompt.md.tmp" "$RUNNER_TEMP/prompt.md" # --output-format json so we can lift the auditor's final summary into the commit # message + PR body in a later step, instead of boilerplate. set +e @@ -493,12 +505,20 @@ jobs: - name: Run docs auditor (Claude Code CLI) env: CLAUDE_VERSION: ${{ inputs.claude-code-version }} + COMMIT_BODY_LINE_LENGTH: ${{ inputs.commit-body-line-length }} run: | set -euo pipefail npm install -g --prefix "$RUNNER_TEMP/claude-cli" "@anthropic-ai/claude-code@$CLAUDE_VERSION" export PATH="$RUNNER_TEMP/claude-cli/bin:$PATH" claude --version + # Fallback commit-body line width injected into the prompt; validate before substituting. + LINE_MAX="${COMMIT_BODY_LINE_LENGTH:-100}" + if ! printf '%s' "$LINE_MAX" | grep -qE '^[1-9][0-9]*$'; then + echo "::error::commit-body-line-length must be a positive integer (got '$LINE_MAX')."; exit 1 + fi cat "$ENGINE_DIR/prompt-skeleton.md" "$POLICY_FILE" > "$RUNNER_TEMP/prompt.md" + sed "s/{{COMMIT_BODY_LINE_LENGTH}}/$LINE_MAX/g" "$RUNNER_TEMP/prompt.md" > "$RUNNER_TEMP/prompt.md.tmp" + mv "$RUNNER_TEMP/prompt.md.tmp" "$RUNNER_TEMP/prompt.md" set +e claude -p "$(cat "$RUNNER_TEMP/prompt.md")" \ --allowed-tools "Read,Edit,Grep,Glob,Bash(git diff:*)" \ @@ -553,5 +573,5 @@ jobs: base: ${{ github.ref_name }} draft: true commit-message: ${{ steps.compose.outputs.commit_message }} - title: "docs: sync documentation with code changes" + title: ${{ steps.compose.outputs.pr_title }} body-path: ${{ steps.compose.outputs.body_path }} diff --git a/AGENT_SETUP.md b/AGENT_SETUP.md index 4b6b41b..ab56bde 100644 --- a/AGENT_SETUP.md +++ b/AGENT_SETUP.md @@ -69,6 +69,9 @@ Notes: [README β†’ Inputs](https://github.com/slingshot/docs-sentinel#inputs). - If the repo pays for Anthropic directly instead of OpenRouter, use the [Anthropic-native recipe](https://github.com/slingshot/docs-sentinel#using-anthropic-directly-instead-of-openrouter). +- The sync commit auto-matches your repo's commit convention (commitlint, commit templates, + commitizen/cocogitto/gitlint); `commit-body-line-length` (default 100) tunes the fallback body + wrap for repos with no detectable convention. ## Step 3 β€” Write the policy file diff --git a/README.md b/README.md index 4a2c453..0cc0b9c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ any documentation the change made inaccurate, and fixes it β€” surgically. a churn budget (default 15 files / 800 lines), or every edit is reverted and the job fails. - 🧾 **Receipts included** β€” commit messages and PR bodies are built from the auditor's own summary plus the actual doc diff, so you always see exactly what changed and why. +- ✍️ **Convention-aware commits** β€” the auditor detects your commit rules (commitlint config, commit + templates, commitizen/cocogitto/gitlint) and shapes the sync commit's subject and body to match; + `commit-body-line-length` (default 100) is the fallback wrap for repos with no convention. ## Quickstart @@ -99,6 +102,7 @@ All inputs are optional. | `denylist-regex` | `(^\|/)CHANGELOG\.md$` | Forbidden even if allowlisted | | `file-budget` | `15` | Max files the auditor may change | | `line-budget` | `800` | Max total changed lines | +| `commit-body-line-length` | `100` | Fallback max chars per commit-body line when the repo has no detectable commit convention (the auditor matches the repo's own `body-max-line-length` when it finds one) | | `skip-if-only-regex` | docs/tests + common lockfiles | Gate skips (no model call) when EVERY changed file matches | | `diff-exclude` | common lockfiles | File patterns excluded from the diff text shown to the auditor | | `sync-branch` | `docs/sync` | Fixed branch for the rolling docs-sync PR | diff --git a/docs/superpowers/plans/2026-07-12-commit-convention-aware-sync.md b/docs/superpowers/plans/2026-07-12-commit-convention-aware-sync.md new file mode 100644 index 0000000..f2e3f25 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-commit-convention-aware-sync.md @@ -0,0 +1,465 @@ +# Commit-convention-aware docs-sync commits β€” Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the docs-sync commit/PR detect the calling repo's commit convention and shape its subject + body to match, with a tunable fallback body line width (default 100). + +**Architecture:** The shell keeps every safety guarantee (the `[skip docs-sentinel]` loop-breaker β€” relocated from the subject to a footer trailer β€” and subject validation). The auditor proposes a `Subject:` line through the existing `.result` text channel; `compose-message.sh` peels it off, validates it, and emits it as the commit subject and a new `pr_title` output. A new prompt section tells the auditor to detect the convention (commitlint / templates / commitizen-cocogitto-gitlint) and wrap the body to the detected `body-max-line-length` or the injected fallback. + +**Tech Stack:** Bash (engine scripts, bash 3.2 / BSD-compatible for local bats), bats-core (tests), GitHub Actions reusable workflow, shellcheck + actionlint (CI lint). + +## Global Constraints + +- Engine shell must stay **bash 3.2 / BSD-tool compatible** (bats runs locally on macOS): no `mapfile`, no GNU-only `sed -i`, prefer `awk`/`while-read`. +- The `[skip docs-sentinel]` marker MUST always appear somewhere in the composed commit message (the gate greps the whole message with `grep -qF`). Never make its presence depend on the model. +- `shellcheck engine/*.sh` must pass clean (CI gate). +- `actionlint` (v1.7.12) must pass on `.github/workflows/*.yml` (CI gate). +- Conventional-commit values are facts: config-conventional `body-max-line-length` / `header-max-length` = 100; default `type-enum` includes `docs`. +- Commit messages: Conventional Commits, no Claude co-author / "Generated by" trailer. + +--- + +### Task 1: `compose-message.sh` β€” subject extraction, footer marker, `pr_title` + +**Files:** +- Modify: `engine/compose-message.sh` +- Test: `tests/compose-message.bats` + +**Interfaces:** +- Consumes: env `FILES_PATH`, `SUMMARY_PATH`, `SHA`, `GITHUB_OUTPUT`, `RUNNER_TEMP` (unchanged). +- Produces: writes `$RUNNER_TEMP/docs-sentinel-commit.txt` with `` on line 1 and `[skip docs-sentinel]` as the last non-empty line; emits a new GitHub step output `pr_title=` (single line, marker-free). + +- [ ] **Step 1: Update the existing marker test + add new tests (failing).** + +In `tests/compose-message.bats`, **replace** the `@test "commit subject carries the skip marker"` block (lines 32-36) with the footer-placement test, and **append** the three new tests. Also add a `pr_title` assertion to the outputs test. + +Replace lines 32-36 with: + +```bash +@test "skip marker is in the footer, not the subject" { + run env FILES_PATH="$BATS_TEST_TMPDIR/files.txt" SUMMARY_PATH="$BATS_TEST_TMPDIR/summary.md" \ + SHA=0123456789abcdef GITHUB_OUTPUT="$GITHUB_OUTPUT" RUNNER_TEMP="$RUNNER_TEMP" bash "$SCRIPT" + [ "$status" -eq 0 ] + commit="$RUNNER_TEMP/docs-sentinel-commit.txt" + # gate greps the WHOLE message, so the marker just has to be present somewhere + grep -qF '[skip docs-sentinel]' "$commit" + # ...but no longer on the subject line + ! head -1 "$commit" | grep -qF '[skip docs-sentinel]' + # it is the last non-empty line (a footer trailer) + [ "$(grep -v '^[[:space:]]*$' "$commit" | tail -1)" = '[skip docs-sentinel]' ] +} +``` + +Append these tests at the end of the file: + +```bash +@test "extracts a Subject: line as the commit subject and pr_title, keeps it out of the body" { + printf 'Subject: docs(readme): sync dev port\n\n- `README.md` β€” updated the port to 3500.\n' \ + > "$BATS_TEST_TMPDIR/summary.md" + run env FILES_PATH="$BATS_TEST_TMPDIR/files.txt" SUMMARY_PATH="$BATS_TEST_TMPDIR/summary.md" \ + SHA=0123456789abcdef GITHUB_OUTPUT="$GITHUB_OUTPUT" RUNNER_TEMP="$RUNNER_TEMP" bash "$SCRIPT" + [ "$status" -eq 0 ] + commit="$RUNNER_TEMP/docs-sentinel-commit.txt" + [ "$(head -1 "$commit")" = 'docs(readme): sync dev port' ] + ! grep -q '^Subject:' "$commit" # the sentinel line never leaks into the message + grep -q 'updated the port to 3500' "$commit" # body prose still present + grep -qx 'pr_title=docs(readme): sync dev port' "$GITHUB_OUTPUT" +} + +@test "no Subject: line falls back to the default subject" { + # setup()'s summary.md has no Subject: line + run env FILES_PATH="$BATS_TEST_TMPDIR/files.txt" SUMMARY_PATH="$BATS_TEST_TMPDIR/summary.md" \ + SHA=0123456789abcdef GITHUB_OUTPUT="$GITHUB_OUTPUT" RUNNER_TEMP="$RUNNER_TEMP" bash "$SCRIPT" + [ "$(head -1 "$RUNNER_TEMP/docs-sentinel-commit.txt")" = 'docs: sync documentation with code changes' ] + grep -qx 'pr_title=docs: sync documentation with code changes' "$GITHUB_OUTPUT" +} + +@test "an over-long Subject: line falls back to the default subject" { + long=$(printf 'x%.0s' $(seq 1 130)) + printf 'Subject: docs: %s\n\n- `README.md` β€” x.\n' "$long" > "$BATS_TEST_TMPDIR/summary.md" + run env FILES_PATH="$BATS_TEST_TMPDIR/files.txt" SUMMARY_PATH="$BATS_TEST_TMPDIR/summary.md" \ + SHA=0123456789abcdef GITHUB_OUTPUT="$GITHUB_OUTPUT" RUNNER_TEMP="$RUNNER_TEMP" bash "$SCRIPT" + [ "$(head -1 "$RUNNER_TEMP/docs-sentinel-commit.txt")" = 'docs: sync documentation with code changes' ] +} +``` + +In the existing `@test "emits commit_path, body_path, and multiline commit_message outputs"`, add one line before the closing brace: + +```bash + grep -q '^pr_title=' "$GITHUB_OUTPUT" +``` + +- [ ] **Step 2: Run the tests to confirm the new ones fail.** + +Run: `bats tests/compose-message.bats` +Expected: FAIL β€” the four subject/marker tests fail against the current script (marker still on the subject line, no `pr_title` output). + +- [ ] **Step 3: Implement the subject split + validation in `engine/compose-message.sh`.** + +After the SUMMARY read block (the `fi` on line 25), insert: + +```bash +# Split an optional leading "Subject: " off the auditor's summary. The auditor emits it +# only in its "edited docs" output shape; the remainder is the commit/PR body prose. Everything +# structural (the type shape's fallback, the skip marker) stays shell-owned below. +DEFAULT_SUBJECT="docs: sync documentation with code changes" +SUBJECT="" +BODY_SUMMARY="$SUMMARY" +if [ -n "$SUMMARY" ]; then + first="${SUMMARY%%$'\n'*}" # first line (whole string if no newline) + case "$first" in + "Subject: "*) + SUBJECT="${first#Subject: }" + # Drop line 1 and, if present, a single blank separator line after it. + BODY_SUMMARY="$(printf '%s\n' "$SUMMARY" | awk 'NR==1{next} NR==2 && $0==""{next} {print}')" + ;; + esac +fi + +# Validate the subject: single line, non-empty, within the conventional header cap (100). The skip +# marker is no longer part of the subject, so the whole budget is the auditor's. Fall back to the +# static subject on anything unusable, so a malformed line can never become the commit header. +SUBJECT="$(printf '%s' "$SUBJECT" | tr -d '\r\n')" +if [ -z "$SUBJECT" ] || [ "${#SUBJECT}" -gt 100 ]; then + SUBJECT="$DEFAULT_SUBJECT" +fi +``` + +- [ ] **Step 4: Point the PR body at the stripped summary.** + +In the PR-body block, change the summary guard and echo (currently lines 56 and 59) from `$SUMMARY` to `$BODY_SUMMARY`: + +```bash + if [ -n "$BODY_SUMMARY" ]; then + echo "**What changed and why**" + echo + echo "$BODY_SUMMARY" + echo + fi +``` + +- [ ] **Step 5: Rewrite the commit-message block β€” agent subject + footer marker.** + +Replace the commit-message heredoc block (currently lines 82-93, the comment through the `} > "$COMMIT"`) with: + +```bash +# ── git commit message (subject + body) ───────────────────────────────────── +# Subject comes from the auditor (validated above, static fallback). The [skip docs-sentinel] marker +# is a FOOTER trailer, not part of the subject, so it never trips a header-length or subject rule β€” +# the gate greps the whole message, so a footer marker still breaks the audit loop. +{ + echo "$SUBJECT" + echo + if [ -n "$BODY_SUMMARY" ]; then + echo "$BODY_SUMMARY" + echo + fi + echo "Files updated:" + sed 's/^/ - /' "$FILES_PATH" + echo + echo "[skip docs-sentinel]" +} > "$COMMIT" +``` + +- [ ] **Step 6: Emit the `pr_title` step output.** + +In the `GITHUB_OUTPUT` heredoc block (currently lines 98-104), add the `pr_title` line after `body_path`: + +```bash +{ + echo "commit_path=$COMMIT" + echo "body_path=$BODY" + echo "pr_title=$SUBJECT" + echo "commit_message<<$DELIM" + cat "$COMMIT" + echo "$DELIM" +} >> "$GITHUB_OUTPUT" +``` + +- [ ] **Step 7: Run tests + shellcheck.** + +Run: `bats tests/compose-message.bats && shellcheck engine/compose-message.sh` +Expected: all tests PASS; shellcheck prints nothing (exit 0). + +- [ ] **Step 8: Commit.** + +```bash +git add engine/compose-message.sh tests/compose-message.bats +git commit -m "feat(engine): agent-authored commit subject + footer skip marker + +compose-message.sh now peels an optional 'Subject:' line off the auditor +summary, validates it (single line, <=100 chars, static fallback), and +emits it as the commit subject and a new pr_title output. The skip marker +moves from the subject to a footer trailer so it never trips a commit rule." +``` + +--- + +### Task 2: `prompt-skeleton.md` β€” Subject line + convention-detection directive + +**Files:** +- Modify: `engine/prompt-skeleton.md` + +**Interfaces:** +- Consumes: the `{{COMMIT_BODY_LINE_LENGTH}}` placeholder (substituted by the workflow in Task 3). +- Produces: the auditor's "edited docs" output now begins with `Subject: ` + blank line, consumed by `compose-message.sh` (Task 1). + +- [ ] **Step 1: Insert the convention-detection section.** + +Between the end of `## How to edit` (line 32) and `## Final output` (line 34), insert: + +```markdown + +## Matching the repo's commit convention + +Your final message becomes a **git commit** (its body) and, on the default branch, a **PR** β€” so it +must satisfy any commit-message rules this repository enforces. Before writing your final output, +briefly detect the convention. Do this **cheaply**: glob for the files, read only the ones that +exist, and never run build, install, or lint commands. + +Look for: + +- **commitlint** β€” `commitlint.config.{js,cjs,mjs,ts}`, `.commitlintrc`, + `.commitlintrc.{json,yaml,yml,js,cjs,mjs,ts}`, or a `commitlint` key in `package.json`. Note its + `type-enum`, whether a scope is required (`scope-empty`), `subject-case`, and `body-max-line-length`. +- **A commit template or written guide** β€” `.gitmessage*`, `CONTRIBUTING*.md`, + `.github/COMMIT_CONVENTION.md`. +- **Tools that imply Conventional Commits** β€” commitizen (`.czrc`, or `config.commitizen` in + `package.json`), cocogitto (`cog.toml`), gitlint (`.gitlint`). + +Then shape your final message to what you found: + +- **Subject.** Prefer `docs: sync documentation with code changes`. If the repo requires a scope, + add one that fits (e.g. `docs(readme):`). If its allowed types exclude `docs`, use the closest + allowed type (usually `chore`). Respect the repo's subject case and header-length limit. **Do not** + add the `[skip docs-sentinel]` marker yourself β€” the workflow appends it. +- **Body.** Wrap every line to the repo's `body-max-line-length` if it sets one, otherwise to + **{{COMMIT_BODY_LINE_LENGTH}}** characters. Keep each file's bullet on its own line; when a bullet + must wrap, break at a word boundary and indent the continuation two spaces so the markdown list + still renders. + +If you find no convention, use the default subject above and wrap the body at +{{COMMIT_BODY_LINE_LENGTH}} characters. +``` + +- [ ] **Step 2: Update the `## Final output` intro + the "edited docs" shape.** + +Change the intro sentence (line 36-37) from: + +``` +Your final message is captured verbatim and used as the **commit message body and the PR +description**, so write it for a human reviewer skimming the PR β€” concise, specific, no preamble. +``` + +to: + +``` +Your final message is captured and used to build the **commit message and the PR description** (its +first `Subject:` line becomes the commit subject; the rest becomes the body), so write it for a +human reviewer skimming the PR β€” concise, specific, no preamble. +``` + +Change the "If you edited docs" bullet + its example (lines 40-46) to: + +```markdown +- **If you edited docs:** first a single line `Subject: `, then a blank line, then a markdown bullet list, one + bullet per file you changed, each naming the file and the one-line reason the code change required + it: + + ``` + Subject: docs: sync documentation with code changes + + - `README.md` β€” updated the dev port from 3400 to 3500 to match the server config change. + - `docs/setup.md` β€” same port change in the quick-start. + ``` +``` + +- [ ] **Step 3: Verify the placeholder appears and no stray `{{` typos.** + +Run: `grep -c '{{COMMIT_BODY_LINE_LENGTH}}' engine/prompt-skeleton.md` +Expected: `2` + +- [ ] **Step 4: Commit.** + +```bash +git add engine/prompt-skeleton.md +git commit -m "feat(prompt): detect repo commit convention, emit a Subject line + +The auditor now scans for commitlint/commit-template/commitizen configs +and shapes its Subject line and body wrap to match, falling back to the +injected {{COMMIT_BODY_LINE_LENGTH}} width when no convention is found." +``` + +--- + +### Task 3: `audit.yml` β€” new input, validation + prompt substitution, PR title wiring + +**Files:** +- Modify: `.github/workflows/audit.yml` + +**Interfaces:** +- Consumes: `steps.compose.outputs.pr_title` (from Task 1) in the `audit-main` create-pull-request step. +- Produces: the `commit-body-line-length` workflow input; substitutes `{{COMMIT_BODY_LINE_LENGTH}}` in the built prompt (Task 2 placeholder). + +- [ ] **Step 1: Add the input.** + +After the `line-budget` input block (lines 32-39), insert: + +```yaml + commit-body-line-length: + description: Fallback max characters per commit-body line when the repo has no detectable commit convention (the auditor honors the repo's own body-max-line-length when it finds one) + type: string + default: '100' +``` + +- [ ] **Step 2: Inject the value in the `audit-pr` "Run docs auditor" step.** + +Add to that step's `env:` (currently just `CLAUDE_VERSION`): + +```yaml + COMMIT_BODY_LINE_LENGTH: ${{ inputs.commit-body-line-length }} +``` + +Immediately after `claude --version` and before the `cat ... > "$RUNNER_TEMP/prompt.md"` line, insert the validation, and after the `cat`, the substitution. The resulting fragment reads: + +```bash + claude --version + # Fallback commit-body line width injected into the prompt; validate before substituting. + LINE_MAX="${COMMIT_BODY_LINE_LENGTH:-100}" + if ! printf '%s' "$LINE_MAX" | grep -qE '^[1-9][0-9]*$'; then + echo "::error::commit-body-line-length must be a positive integer (got '$LINE_MAX')."; exit 1 + fi + cat "$ENGINE_DIR/prompt-skeleton.md" "$POLICY_FILE" > "$RUNNER_TEMP/prompt.md" + sed "s/{{COMMIT_BODY_LINE_LENGTH}}/$LINE_MAX/g" "$RUNNER_TEMP/prompt.md" > "$RUNNER_TEMP/prompt.md.tmp" + mv "$RUNNER_TEMP/prompt.md.tmp" "$RUNNER_TEMP/prompt.md" +``` + +- [ ] **Step 3: Repeat the same env + validation + substitution in the `audit-main` "Run docs auditor" step.** + +Identical change: add `COMMIT_BODY_LINE_LENGTH: ${{ inputs.commit-body-line-length }}` to that step's `env:`, and insert the same validate/cat/sed/mv fragment (after `claude --version`). + +- [ ] **Step 4: Wire the PR title to the agent subject.** + +In the `audit-main` `Open / update the rolling docs-sync PR` step, change: + +```yaml + title: "docs: sync documentation with code changes" +``` + +to: + +```yaml + title: ${{ steps.compose.outputs.pr_title }} +``` + +- [ ] **Step 5: Lint the workflow.** + +Run: `actionlint .github/workflows/audit.yml` +Expected: no output (exit 0). (Note: `.github/actionlint.yaml` already ignores a known stale job-context schema warning.) + +- [ ] **Step 6: Commit.** + +```bash +git add .github/workflows/audit.yml +git commit -m "feat(workflow): add commit-body-line-length input, wire PR title + +New workflow input (default 100) is validated and substituted into the +auditor prompt in both audit jobs; the rolling docs-sync PR title now +uses the auditor's proposed subject (steps.compose.outputs.pr_title)." +``` + +--- + +### Task 4: Docs β€” README inputs table + feature bullet, AGENT_SETUP note + +**Files:** +- Modify: `README.md` +- Modify: `AGENT_SETUP.md` + +**Interfaces:** +- Consumes: the input name/default/behavior from Task 3. Documentation only β€” no code interface. + +- [ ] **Step 1: Add the inputs-table row in `README.md`.** + +After the `line-budget` row (line 101), insert: + +```markdown +| `commit-body-line-length` | `100` | Fallback max chars per commit-body line when the repo has no detectable commit convention (the auditor matches the repo's own `body-max-line-length` when it finds one) | +``` + +- [ ] **Step 2: Add a feature bullet in `README.md`.** + +After the "Receipts included" bullet (line 31-32), insert: + +```markdown +- ✍️ **Convention-aware commits** β€” the auditor detects your commit rules (commitlint config, commit + templates, commitizen/cocogitto/gitlint) and shapes the sync commit's subject and body to match; + `commit-body-line-length` (default 100) is the fallback wrap for repos with no convention. +``` + +- [ ] **Step 3: Add a note in `AGENT_SETUP.md`.** + +In the `## Step 2` "Notes:" list (after the bullet ending at line 69), add: + +```markdown +- The sync commit auto-matches your repo's commit convention (commitlint, commit templates, + commitizen/cocogitto/gitlint); `commit-body-line-length` (default 100) tunes the fallback body + wrap for repos with no detectable convention. +``` + +- [ ] **Step 4: Verify the docs render sanity + commit.** + +Run: `grep -n 'commit-body-line-length' README.md AGENT_SETUP.md` +Expected: matches in both files (README twice β€” table + bullet; AGENT_SETUP once). + +```bash +git add README.md AGENT_SETUP.md +git commit -m "docs: document commit-body-line-length + convention matching" +``` + +--- + +### Task 5: Full-suite verification + +**Files:** none (verification only). + +- [ ] **Step 1: Run the complete CI-equivalent gate locally.** + +Run: +```bash +shellcheck engine/*.sh && bats tests && actionlint +``` +Expected: shellcheck silent; all bats tests pass (`ok N` lines, no `not ok`); actionlint no output. All exit 0. + +- [ ] **Step 2: Sanity-check the composed message end-to-end** (mirrors the real flow with a Subject line): + +```bash +tmp=$(mktemp -d) +printf 'README.md\n' > "$tmp/files.txt" +printf 'Subject: docs(readme): bump dev port\n\n- `README.md` β€” dev port 3400->3500.\n' > "$tmp/summary.md" +( cd "$tmp" && git init -q && git config user.email t@e.st && git config user.name t \ + && echo a > README.md && git add -A && git commit -qm i && echo b > README.md ) +env FILES_PATH="$tmp/files.txt" SUMMARY_PATH="$tmp/summary.md" SHA=deadbeefcafe \ + GITHUB_OUTPUT="$tmp/out" RUNNER_TEMP="$tmp" bash engine/compose-message.sh >/dev/null +echo '--- commit ---'; cat "$tmp/commit"* 2>/dev/null || cat "$tmp/docs-sentinel-commit.txt" +echo '--- pr_title ---'; grep '^pr_title=' "$tmp/out" +``` +Expected: subject line `docs(readme): bump dev port`, a `Files updated:` block, `[skip docs-sentinel]` as the final line, and `pr_title=docs(readme): bump dev port`. + +--- + +## Self-Review + +**Spec coverage:** +- Ownership model (marker β†’ footer, subject validation) β†’ Task 1. βœ“ +- Agent↔shell channel (`Subject:` line) β†’ Task 1 (parse) + Task 2 (emit). βœ“ +- Detection directive β†’ Task 2. βœ“ +- Workflow input + substitution + `title:` wiring β†’ Task 3. βœ“ +- Tests (subject extraction, footer marker, fallbacks, `pr_title`) β†’ Task 1. βœ“ +- Docs (README input + AGENT_SETUP) β†’ Task 4. βœ“ +- Non-goal (no shell body re-wrap) β†’ honored: no wrap logic in any task. βœ“ + +**Placeholder scan:** No TBD/TODO; every code step shows full code. βœ“ + +**Type/name consistency:** `pr_title` output produced in Task 1, consumed in Task 3. `{{COMMIT_BODY_LINE_LENGTH}}` emitted in Task 2, substituted in Task 3. `BODY_SUMMARY` / `SUBJECT` / `DEFAULT_SUBJECT` names consistent within Task 1. βœ“ diff --git a/docs/superpowers/specs/2026-07-12-commit-convention-aware-commits-design.md b/docs/superpowers/specs/2026-07-12-commit-convention-aware-commits-design.md new file mode 100644 index 0000000..e0ea5e5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-commit-convention-aware-commits-design.md @@ -0,0 +1,190 @@ +# Commit-convention awareness for the docs-sync commit/PR + +**Date:** 2026-07-12 +**Status:** Approved (design), pending implementation plan + +## Problem + +docs-sentinel opens a commit (and, on the default-branch path, a PR) to sync documentation with a +code change. Today the commit **subject** is a hardcoded string +(`docs: sync documentation with code changes [skip docs-sentinel]`, `engine/compose-message.sh`) +and the PR **title** is a hardcoded string (`.github/workflows/audit.yml`). Only the commit/PR +**body** comes from the auditor β€” as free-form markdown prose with no line-length discipline. + +Two consequences in repos that lint their commit history (commitlint, gitlint, cocogitto, …): + +1. **Body line length.** `@commitlint/config-conventional` enforces `body-max-line-length: 100` + at error level. The auditor's prose can exceed that, so the sync commit can fail the calling + repo's own commit lint. +2. **Subject shape.** A repo whose commitlint `type-enum` omits `docs`, or that requires a scope + (`scope-empty: never`), or enforces a non-default `subject-case`, will reject our hardcoded + `docs:` subject. A prompt directive alone cannot fix a subject the model never authors. + +`docs` *is* in config-conventional's default `type-enum` +(`build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test`) and `100` matches its +default body/header line limits β€” so the standard preset is already safe; only customized or +non-conventional repos break. This work makes the commit/PR adapt to those repos. + +## Goals + +- The auditor detects the calling repo's commit-message convention and shapes its commit **subject** + and **body** to match. +- A sensible, tunable default body line width (100) for repos with no detectable convention. +- Zero regression to the load-bearing loop-breaker (`[skip docs-sentinel]`) and the doc-edit + guardrail. + +## Non-goals + +- **No mechanical body re-wrap in the shell.** A bash reflow (`fmt`) mangles markdown bullets, + inline code, and URLs β€” worse than an occasional lint *warning*. Body wrapping is best-effort via + the prompt directive; most doc-sync bullets are short one-liners, and a human reviews every sync + PR. Only the **subject** gets a hard shell backstop (length cap + fallback). +- No support for authoring the *entire* commit message in the model (rejected: makes the structural + guarantees depend on the LLM). See "Ownership model". +- No per-rule emulation of a commitlint config in the shell. The agent reads the config and applies + judgment; the shell only validates the subject it gets back. + +## Ownership model + +The shell keeps every safety-critical guarantee; the agent only shapes prose. + +| Concern | Owner | Guarantee | +|---|---|---| +| `[skip docs-sentinel]` loop-breaker | shell (`compose-message.sh`) | Always appended, **as a footer trailer line** (moved off the subject) so it can never violate a header-length rule or a lint-checked subject. The gate greps the whole message (`grep -qF`), so footer placement is behaviorally identical for the loop guard. | +| Files-updated list | shell | Unchanged. | +| Commit **subject** phrasing | agent proposes, shell validates | Agent emits a `Subject:` line; shell uses it only if non-empty and ≀ 100 chars on one line, else falls back to `docs: sync documentation with code changes`. | +| Body prose + line wrap | agent (best-effort) | Prompt-directed; no mechanical backstop. | + +### Why footer relocation is safe + +The repo has two independent loop-breakers: (a) the `[skip docs-sentinel]` marker grep over +`github.event.head_commit.message`, and (b) the gate's "only docs/tests/lockfiles changed β†’ skip". +(b) is what actually catches a *merged* sync PR (its push touches only docs); (a) guards the +same-run / direct-push case. Because the gate greps the entire message (subject **and** body), +moving the marker from the subject to a footer line changes nothing for either guard. Tests must +lock this in. + +## The agent ↔ shell channel + +The auditor runs with `--allowed-tools "Read,Edit,Grep,Glob,Bash(git diff:*)"` β€” no `Write`, so it +cannot drop a separate subject file. The subject travels through the existing `.result` text +channel (captured to `auditor-summary.md`). + +New contract, applied **only to the "edited docs" output shape**: the final message begins with a +single `Subject: ` line, a blank line, then the existing markdown bullet list. + +``` +Subject: docs(readme): sync dev port in the quick-start + +- `README.md` β€” updated the dev port from 3400 to 3500 to match the server config change. +- `docs/setup.md` β€” same port change in the quick-start. +``` + +The "no edits" and "NEEDS HUMAN" shapes are unchanged (no `Subject:` line). This keeps the sticky +no-drift PR-comment path (which reads the raw summary when `changed=false`) untouched. + +## Detection directive (prompt) + +A new `## Matching the repo's commit convention` section in `engine/prompt-skeleton.md` instructs +the agent to detect the convention **cheaply** (glob first, read only files that exist, never run +build/install), checking: + +- **commitlint** β€” `commitlint.config.{js,cjs,mjs,ts}`, `.commitlintrc`, + `.commitlintrc.{json,yaml,yml,js,cjs,mjs,ts}`, or a `commitlint` key in `package.json`. + Note `type-enum`, scope requirement (`scope-empty`), `subject-case`, `body-max-line-length`. +- **Commit templates** β€” `.gitmessage*`, `CONTRIBUTING*.md`, `.github/COMMIT_CONVENTION.md`. +- **Conventional-Commits-implying tools** β€” commitizen (`.czrc`, `config.commitizen` in + `package.json`), cocogitto (`cog.toml`), gitlint (`.gitlint`). + +Then shape output: + +- **`Subject:`** β€” prefer `docs: sync documentation with code changes`; add a scope if the repo + requires one (e.g. `docs(readme):`); if `type-enum` lacks `docs`, use the closest allowed type + (usually `chore`); respect subject case and header length. **Never** add the + `[skip docs-sentinel]` marker β€” the workflow adds it. +- **Body** β€” wrap every line to the repo's detected `body-max-line-length`, else to + `{{COMMIT_BODY_LINE_LENGTH}}` characters. Keep each file's bullet on its own line; wrap long + bullets at word boundaries with the continuation indented two spaces so the markdown list still + renders. (This one representation is a valid ≀N plain-text commit body **and** a correctly + continued markdown list item β€” satisfying both the git-commit-body and PR-description consumers.) + +**Precedence: detected repo value > workflow default.** + +## The workflow input + +```yaml +commit-body-line-length: + description: Fallback max chars per commit-body line when the repo has no detectable + commit convention (the auditor honors the repo's own body-max-line-length when found) + type: string + default: '100' +``` + +- Validated as a positive integer before use (fail-closed, mirroring `guardrail.sh`'s budget check). +- Injected into the prompt by substituting the `{{COMMIT_BODY_LINE_LENGTH}}` placeholder after the + `cat "$ENGINE_DIR/prompt-skeleton.md" "$POLICY_FILE" > prompt.md` step, in **both** `audit-pr` + and `audit-main`. Substitution uses `sed 'expr' file > tmp && mv tmp file` (portable; the value is + integer-validated so it carries no sed-special characters). + +## `compose-message.sh` changes + +1. **Extract subject.** With awk (BSD-safe), peel a leading `^Subject: ` line off the summary; the + remainder (minus one blank separator line) becomes the body summary. No `Subject:` line β†’ whole + summary is the body, subject stays empty. +2. **Validate subject.** Fall back to `docs: sync documentation with code changes` when the extracted + subject is empty or its byte length exceeds a 100-char cap (matches config-conventional + `header-max-length`; the marker is no longer in the subject, so the whole budget is the agent's). +3. **Commit message shape:** + ``` + + + + + Files updated: + - + ... + + [skip docs-sentinel] + ``` + The marker is its own final paragraph (footer trailer). +4. **PR body** uses the body summary (subject line stripped) for "What changed and why". +5. **New step output `pr_title`** = the validated subject (marker-free) for the create-pull-request + `title:`. + +## `audit.yml` changes + +- Add the `commit-body-line-length` input. +- In `audit-pr` and `audit-main` "Run docs auditor" steps: validate the input is a positive integer, + build `prompt.md`, then substitute `{{COMMIT_BODY_LINE_LENGTH}}`. +- In `audit-main` "Open / update the rolling docs-sync PR": change + `title: "docs: sync documentation with code changes"` to + `title: ${{ steps.compose.outputs.pr_title }}`. + +## Tests (`tests/compose-message.bats`) + +New cases: +- Summary beginning `Subject: docs(readme): …` β†’ commit subject and `pr_title` equal that subject; + commit body excludes the `Subject:` line. +- `[skip docs-sentinel]` appears in the composed commit message (as a footer line) and is **absent** + from the subject line and from `pr_title`. +- No `Subject:` line β†’ subject falls back to the default; marker still in footer. +- Empty or > 100-char subject β†’ falls back to the default. + +Also review `tests/*.bats` for any assertion that the marker sits in the subject and update it. + +## Docs + +- `README.md` β€” add `commit-body-line-length` to the inputs table; one line on the convention-match + behavior. +- `AGENT_SETUP.md` β€” mention the new input where inputs are described. + +Because docs-sentinel audits **itself**, a missed README update to the inputs table would be caught +by its own sentinel β€” a built-in forcing function to keep these in sync. + +## Blast radius + +- `engine/prompt-skeleton.md` +- `engine/compose-message.sh` +- `.github/workflows/audit.yml` +- `tests/compose-message.bats` (and a scan of the other `.bats` files) +- `README.md`, `AGENT_SETUP.md` diff --git a/engine/compose-message.sh b/engine/compose-message.sh index 0738386..3ddab86 100755 --- a/engine/compose-message.sh +++ b/engine/compose-message.sh @@ -24,6 +24,31 @@ if [ -n "$SUMMARY_PATH" ] && [ -s "$SUMMARY_PATH" ]; then SUMMARY=$(cat "$SUMMARY_PATH") fi +# Split an optional leading "Subject: " off the auditor's summary. The auditor emits it +# only in its "edited docs" output shape; the remainder is the commit/PR body prose. Everything +# structural (the type-shape fallback, the skip marker) stays shell-owned below. +DEFAULT_SUBJECT="docs: sync documentation with code changes" +SUBJECT="" +BODY_SUMMARY="$SUMMARY" +if [ -n "$SUMMARY" ]; then + first="${SUMMARY%%$'\n'*}" # first line (whole string if no newline) + case "$first" in + "Subject: "*) + SUBJECT="${first#Subject: }" + # Drop line 1 and, if present, a single blank separator line after it. + BODY_SUMMARY="$(printf '%s\n' "$SUMMARY" | awk 'NR==1{next} NR==2 && $0==""{next} {print}')" + ;; + esac +fi + +# Validate the subject: single line, non-empty, within the conventional header cap (100). The skip +# marker is no longer part of the subject, so the whole budget is the auditor's. Fall back to the +# static subject on anything unusable, so a malformed line can never become the commit header. +SUBJECT="$(printf '%s' "$SUBJECT" | tr -d '\r\n')" +if [ -z "$SUBJECT" ] || [ "${#SUBJECT}" -gt 100 ]; then + SUBJECT="$DEFAULT_SUBJECT" +fi + short="${SHA:0:12}" # The auditor's edits are still uncommitted, so `git diff` over the edited docs gives the ACTUAL @@ -53,10 +78,10 @@ fi # shellcheck disable=SC2016 if [ -n "$SHA" ]; then printf ' in `%s`' "$short"; fi printf '.\n\n' - if [ -n "$SUMMARY" ]; then + if [ -n "$BODY_SUMMARY" ]; then echo "**What changed and why**" echo - echo "$SUMMARY" + echo "$BODY_SUMMARY" echo fi echo "**Files updated**" @@ -80,16 +105,20 @@ fi } > "$BODY" # ── git commit message (subject + body) ───────────────────────────────────── -# The [skip docs-sentinel] marker keeps the gate from auditing our own sync commit. +# Subject comes from the auditor (validated above, static fallback). The [skip docs-sentinel] marker +# is a FOOTER trailer, not part of the subject, so it never trips a header-length or subject rule β€” +# the gate greps the whole message, so a footer marker still keeps it from auditing our sync commit. { - echo "docs: sync documentation with code changes [skip docs-sentinel]" + echo "$SUBJECT" echo - if [ -n "$SUMMARY" ]; then - echo "$SUMMARY" + if [ -n "$BODY_SUMMARY" ]; then + echo "$BODY_SUMMARY" echo fi echo "Files updated:" sed 's/^/ - /' "$FILES_PATH" + echo + echo "[skip docs-sentinel]" } > "$COMMIT" # Random heredoc delimiter: the commit message embeds LLM-generated summary text, and a @@ -98,6 +127,7 @@ DELIM="__DOCS_SENTINEL_MSG_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')__" { echo "commit_path=$COMMIT" echo "body_path=$BODY" + echo "pr_title=$SUBJECT" echo "commit_message<<$DELIM" cat "$COMMIT" echo "$DELIM" diff --git a/engine/prompt-skeleton.md b/engine/prompt-skeleton.md index 061a788..bc778c4 100644 --- a/engine/prompt-skeleton.md +++ b/engine/prompt-skeleton.md @@ -31,16 +31,51 @@ run build, install, or dev commands. files.** If the change clearly needs a brand-new doc, do not create it β€” call it out in your final summary instead. +## Matching the repo's commit convention + +Your final message becomes a **git commit** (its body) and, on the default branch, a **PR** β€” so it +must satisfy any commit-message rules this repository enforces. Before writing your final output, +briefly detect the convention. Do this **cheaply**: glob for the files, read only the ones that +exist, and never run build, install, or lint commands. + +Look for: + +- **commitlint** β€” `commitlint.config.{js,cjs,mjs,ts}`, `.commitlintrc`, + `.commitlintrc.{json,yaml,yml,js,cjs,mjs,ts}`, or a `commitlint` key in `package.json`. Note its + `type-enum`, whether a scope is required (`scope-empty`), `subject-case`, and `body-max-line-length`. +- **A commit template or written guide** β€” `.gitmessage*`, `CONTRIBUTING*.md`, + `.github/COMMIT_CONVENTION.md`. +- **Tools that imply Conventional Commits** β€” commitizen (`.czrc`, or `config.commitizen` in + `package.json`), cocogitto (`cog.toml`), gitlint (`.gitlint`). + +Then shape your final message to what you found: + +- **Subject.** Prefer `docs: sync documentation with code changes`. If the repo requires a scope, + add one that fits (e.g. `docs(readme):`). If its allowed types exclude `docs`, use the closest + allowed type (usually `chore`). Respect the repo's subject case and header-length limit. **Do not** + add the `[skip docs-sentinel]` marker yourself β€” the workflow appends it. +- **Body.** Wrap every line to the repo's `body-max-line-length` if it sets one, otherwise to + **{{COMMIT_BODY_LINE_LENGTH}}** characters. Keep each file's bullet on its own line; when a bullet + must wrap, break at a word boundary and indent the continuation two spaces so the markdown list + still renders. + +If you find no convention, use the default subject above and wrap the body at +{{COMMIT_BODY_LINE_LENGTH}} characters. + ## Final output -Your final message is captured verbatim and used as the **commit message body and the PR -description**, so write it for a human reviewer skimming the PR β€” concise, specific, no preamble. -Use exactly this shape: +Your final message is captured and used to build the **commit message and the PR description** (its +first `Subject:` line becomes the commit subject; the rest becomes the body), so write it for a +human reviewer skimming the PR β€” concise, specific, no preamble. Use exactly this shape: -- **If you edited docs:** a markdown bullet list, one bullet per file you changed, each naming the - file and the one-line reason the code change required it: +- **If you edited docs:** first a single line `Subject: `, then a blank line, then a markdown bullet list, one + bullet per file you changed, each naming the file and the one-line reason the code change required + it: ``` + Subject: docs: sync documentation with code changes + - `README.md` β€” updated the dev port from 3400 to 3500 to match the server config change. - `docs/setup.md` β€” same port change in the quick-start. ``` diff --git a/tests/compose-message.bats b/tests/compose-message.bats index eed80a7..d7dceb4 100644 --- a/tests/compose-message.bats +++ b/tests/compose-message.bats @@ -29,10 +29,17 @@ setup() { grep -q 'docs-sentinel' "$body" } -@test "commit subject carries the skip marker" { +@test "skip marker is in the footer, not the subject" { run env FILES_PATH="$BATS_TEST_TMPDIR/files.txt" SUMMARY_PATH="$BATS_TEST_TMPDIR/summary.md" \ SHA=0123456789abcdef GITHUB_OUTPUT="$GITHUB_OUTPUT" RUNNER_TEMP="$RUNNER_TEMP" bash "$SCRIPT" - head -1 "$RUNNER_TEMP/docs-sentinel-commit.txt" | grep -qF '[skip docs-sentinel]' + [ "$status" -eq 0 ] + commit="$RUNNER_TEMP/docs-sentinel-commit.txt" + # gate greps the WHOLE message, so the marker just has to be present somewhere + grep -qF '[skip docs-sentinel]' "$commit" + # ...but no longer on the subject line + ! head -1 "$commit" | grep -qF '[skip docs-sentinel]' + # it is the last non-empty line (a footer trailer) + [ "$(grep -v '^[[:space:]]*$' "$commit" | tail -1)" = '[skip docs-sentinel]' ] } @test "missing summary degrades gracefully" { @@ -48,6 +55,7 @@ setup() { grep -q '^commit_path=' "$GITHUB_OUTPUT" grep -q '^body_path=' "$GITHUB_OUTPUT" grep -q '^commit_message<<' "$GITHUB_OUTPUT" + grep -q '^pr_title=' "$GITHUB_OUTPUT" } @test "empty FILES_PATH yields no doc diff, not a full-tree diff" { @@ -67,3 +75,32 @@ setup() { [ "$opener" != "__DOCS_SENTINEL_MSG__" ] grep -qx "$opener" "$GITHUB_OUTPUT" } + +@test "extracts a Subject: line as the commit subject and pr_title, keeps it out of the body" { + printf 'Subject: docs(readme): sync dev port\n\n- `README.md` β€” updated the port to 3500.\n' \ + > "$BATS_TEST_TMPDIR/summary.md" + run env FILES_PATH="$BATS_TEST_TMPDIR/files.txt" SUMMARY_PATH="$BATS_TEST_TMPDIR/summary.md" \ + SHA=0123456789abcdef GITHUB_OUTPUT="$GITHUB_OUTPUT" RUNNER_TEMP="$RUNNER_TEMP" bash "$SCRIPT" + [ "$status" -eq 0 ] + commit="$RUNNER_TEMP/docs-sentinel-commit.txt" + [ "$(head -1 "$commit")" = 'docs(readme): sync dev port' ] + ! grep -q '^Subject:' "$commit" # the sentinel line never leaks into the message + grep -q 'updated the port to 3500' "$commit" # body prose still present + grep -qx 'pr_title=docs(readme): sync dev port' "$GITHUB_OUTPUT" +} + +@test "no Subject: line falls back to the default subject" { + # setup()'s summary.md has no Subject: line + run env FILES_PATH="$BATS_TEST_TMPDIR/files.txt" SUMMARY_PATH="$BATS_TEST_TMPDIR/summary.md" \ + SHA=0123456789abcdef GITHUB_OUTPUT="$GITHUB_OUTPUT" RUNNER_TEMP="$RUNNER_TEMP" bash "$SCRIPT" + [ "$(head -1 "$RUNNER_TEMP/docs-sentinel-commit.txt")" = 'docs: sync documentation with code changes' ] + grep -qx 'pr_title=docs: sync documentation with code changes' "$GITHUB_OUTPUT" +} + +@test "an over-long Subject: line falls back to the default subject" { + long=$(printf 'x%.0s' $(seq 1 130)) + printf 'Subject: docs: %s\n\n- `README.md` β€” x.\n' "$long" > "$BATS_TEST_TMPDIR/summary.md" + run env FILES_PATH="$BATS_TEST_TMPDIR/files.txt" SUMMARY_PATH="$BATS_TEST_TMPDIR/summary.md" \ + SHA=0123456789abcdef GITHUB_OUTPUT="$GITHUB_OUTPUT" RUNNER_TEMP="$RUNNER_TEMP" bash "$SCRIPT" + [ "$(head -1 "$RUNNER_TEMP/docs-sentinel-commit.txt")" = 'docs: sync documentation with code changes' ] +}