Prose Oneline Universal
The problem¶
AI agents (and humans) soft-wrap markdown prose at ~80 cols by habit. The house rule is one paragraph per line, enforced by the markdown-oneline CI gate (tools/unwrap-prose.py --check). Wrapped prose fails the gate β e.g. the quest-fix PR #534. The check is already fanned out to every repo; what was missing is enforcement at the source so agents stop producing it and PRs stop going red.
There is no single switch that covers every markdown-producing path across every repo β it is defense-in-depth. This page maps each layer to the shared place it lives, so "universal" means "one edit in a shared layer," not "N edits Γ M repos."
Distribution mechanisms you already have¶
The bamr87/bamr87 hub distributes standards two ways:
- Reusable workflows β
.github/workflows/ci.ymlis a thin caller:uses: bamr87/bamr87/.github/workflows/standard-ci.yml@main. Edit the hub once and every repo runs the new logic on its next PR. - Fanout/seed β
standardize-fanout.yml+tools/fanout.sh --kit prosecopiesmarkdown-oneline.yml+unwrap-prose.pyinto each repo. Edit the hub kit, re-fanout, and every repo gets the new committed copy.
Plus a shared AI chokepoint: the fleet's claude-run action (bamr87/bamr87/.github/actions/claude-run@main, consumed by reference) β every AI call flows through it, in this repo and the sibling sites alike.
The layers¶
| Layer | Covers | Lives in (one place) | Status |
|---|---|---|---|
| 0. CI check | any wrapped prose reaching a PR, all repos | fanned-out markdown-oneline.yml |
shipped (the backstop) |
| 1. CI auto-fix | same, but self-heals instead of failing | hub prose kit 0.3.0 (markdown-oneline.yml) |
shipped β adopted here |
| 2. AI runner sweep | all AI markdown in CI, all sibling repos | the hub's claude-run runner (the shared ai-runner kit) + .prose-excludes |
shipped in the kit |
| 3. Claude Code hook | all Claude markdown, every repo + session | ~/.claude/settings.json (global) or .claude/settings.json (project) |
config + script below |
| 4. pre-commit hook | all commits, any author | tools/hooks/ + core.hooksPath |
shipped in this repo; kit it |
Layer 0 stays the hard gate. Layers 1 and 3 are the recommended pair: Claude's output is clean at the source (3), and everything else self-heals in CI (1).
Layer 1 β hub CI auto-fix (draft to paste into bamr87/bamr87)¶
Replace the check-only step in the hub's prose-kit markdown-oneline.yml with an unwrap-and-commit step. On a same-repo PR it pushes the fix back to the branch; on a fork PR or push event it falls back to failing with instructions (it cannot push there). It converges in one extra run β after the fix the tree is clean, so the follow-up run finds nothing to commit.
name: markdown-oneline
on:
pull_request:
paths: ["**/*.md", "**/*.markdown"]
push:
branches: [main]
paths: ["**/*.md", "**/*.markdown"]
permissions:
contents: write # needed to push the auto-fix back to the PR branch
concurrency:
group: markdown-oneline-${{ github.ref }}
cancel-in-progress: true
jobs:
oneline:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }} # the PR branch, not the merge ref
# A PAT re-triggers CI on the fix push; GITHUB_TOKEN pushes do NOT
# re-run checks (which also prevents loops). Pick per your needs.
token: ${{ secrets.AUTO_PR_GITHUB_TOKEN || secrets.PAT_TOKEN || github.token }}
- uses: actions/setup-python@v5
with: { python-version: "3.x" }
- name: Unwrap prose β auto-commit on same-repo PRs, else check-only
env:
EVENT: ${{ github.event_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
THIS_REPO: ${{ github.repository }}
run: |
set -euo pipefail
python3 tools/unwrap-prose.py --write \
--exclude '(^|/)SCHEMA\.md$' --exclude '(^|/)CHANGELOG\.md$' \
--exclude '(^|/)pages/_quest-reports/' \
--exclude '(^|/)test/quest-validator/walkthroughs/'
if git diff --quiet; then
echo "prose already one paragraph per line."; exit 0
fi
if [ "$EVENT" = "pull_request" ] && [ "$HEAD_REPO" = "$THIS_REPO" ]; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "style(prose): unwrap soft-wrapped markdown (one paragraph per line)"
git push
echo "::notice::Auto-unwrapped soft-wrapped prose and pushed the fix to this PR."
else
git --no-pager diff --stat
echo "::error::Wrapped markdown prose found. Run: python3 tools/unwrap-prose.py --write"
exit 1
fi
Tradeoff: auto-committing edits the contributor's PR and can race other automation (auto-merge). If you would rather keep the current fail-fast behavior, leave markdown-oneline.yml as-is and rely on Layers 3 + 4 to prevent wrapped prose from ever being committed.
Layer 2 β the shared AI runner sweep (shipped here)¶
The kit's runner (run.sh beside bamr87/bamr87/.github/actions/claude-run@main) normalizes any markdown the agent changed after a successful Claude Code run (normalize_changed_markdown). It is best-effort, skips the gate-excluded files, and is idempotent with quest-fix.yml's M8 step. The sweep was upstreamed into the shared ai-runner kit (the hub is the source of truth; this repo consumes it by reference β see scripts/ai/README.md), so the kit's run.sh no longer hard-codes this repo's excludes: it always skips SCHEMA.md/CHANGELOG.md and reads the rest, one extended regex per line, from the repo-root .prose-excludes.
This sweep catches uncommitted edits (the workflow-opens-PR flows). Agents that commit inside their own run β content-factory, quest-forge, the issue resolver β are covered by Layer 3, which fires per-edit during the run, before the agent commits.
Layer 3 β Claude Code hook (all Claude markdown, every repo)¶
A PostToolUse hook on Write|Edit|MultiEdit runs the unwrapper on any *.md Claude writes. Committed project hooks (.claude/settings.json) also fire in the headless claude -p CI agents β there is no trust gate in CI β so this one config covers local contributors and the fleet.
The hook script is in this repo at tools/hooks/unwrap-md-hook.sh (jq-free, parses the event JSON with python3, acts only on markdown, always exits 0).
Global install (all repos, including non-bamr87 β your machine)¶
The remote/web container is ephemeral, so install this on the machine that owns your ~/.claude. Make the unwrapper available at a stable path, then add the hook:
mkdir -p ~/.claude/bin
curl -fsSL https://raw.githubusercontent.com/bamr87/it-journey/main/tools/unwrap-prose.py \
-o ~/.claude/bin/unwrap-prose.py
Add to ~/.claude/settings.json (merge with any existing hooks block):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "python3 -c 'import json,sys,os,subprocess as s; d=json.load(sys.stdin); f=(d.get(\"tool_input\") or {}).get(\"file_path\") or \"\"; f.endswith((\".md\",\".markdown\")) or sys.exit(0); u=os.path.join(os.environ.get(\"CLAUDE_PROJECT_DIR\",\"\"),\"tools\",\"unwrap-prose.py\"); u=u if os.path.isfile(u) else os.path.expanduser(\"~/.claude/bin/unwrap-prose.py\"); os.path.isfile(u) and os.path.isfile(f) and s.run([\"python3\",u,\"--write\",f])'"
}
]
}
]
}
}
The command prefers the repo's own tools/unwrap-prose.py (so a repo pins its version) and falls back to the global copy, so it is a clean no-op in repos that have neither.
Project install (this repo / kit β team + CI, opt-in)¶
If you want it committed so the whole team and the CI agents get it, add .claude/settings.json (this is intentionally NOT committed by default β a committed hook auto-runs a shell command for every contributor, so it is your call):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/tools/hooks/unwrap-md-hook.sh\"" }
]
}
]
}
}
To make it universal across bamr87 repos, add both .claude/settings.json and tools/hooks/unwrap-md-hook.sh to the hub's --kit prose fanout.
Layer 4 β pre-commit hook (all commits, any author β shipped here)¶
tools/hooks/pre-commit runs the unwrapper on staged markdown and re-stages the result, so wrapped prose is fixed before it is ever committed β regardless of who or what created it. Install once per clone:
Bypass a single commit with git commit --no-verify. To distribute it, add tools/hooks/pre-commit to the hub's --kit prose fanout; contributors still run make hooks-install once (git will not auto-enable a repo's hooks).
Rollout checklist¶
- Layer 0 β CI check fanned out (already in every repo).
- Layer 1 β the hub's
--kit prose0.3.0 ships the self-healingmarkdown-oneline.yml; adopted here (with this repo's two extra excludes). - Layer 2 β the runner sweep, now in the shared
ai-runnerkit consumed by reference; this repo's excludes live in.prose-excludes. - Layer 3 β add the global
~/.claude/settings.jsonhook on your machine; optionally kit the project variant. - Layer 4 β
tools/hooks/pre-commit+make hooks-install(this repo); kit it for the rest.
Keep in lockstep¶
The exclude list appears in markdown-oneline.yml, the Makefile (PROSE_ONELINE_EXCLUDES), .prose-excludes (read by the kit's run.sh in the hub β there is no local copy to edit), and both hook scripts. When it changes, update all of them. tools/unwrap-prose.py is the single engine everywhere.