diff --git a/.claude/hooks/md-no-hardwrap.py b/.claude/hooks/md-no-hardwrap.py
new file mode 100755
index 000000000..ad8e99819
--- /dev/null
+++ b/.claude/hooks/md-no-hardwrap.py
@@ -0,0 +1,802 @@
+#!/usr/bin/env python3
+"""PreToolUse hook: keep hardwrapped prose out of markdown and out of GitHub.
+
+A markdown renderer collapses a soft line break into a space, so a paragraph
+wrapped at ~80 columns renders identically to the same paragraph on one line.
+The wrap is not free, though: it breaks narrow viewports, it mangles list and
+table rendering, and it turns a one-word edit into a diff that reflows the
+whole block. Prose therefore stays on a single line per paragraph, and the
+renderer decides where it wraps.
+
+The rule is easy to state and easy to forget, because an ~80-column wrap is
+correct in the places a coding agent spends most of its time (Go comments,
+commit message bodies), and the reflex leaks from there into markdown files
+and GitHub bodies. This hook is the mechanical check that the reflex slips
+past. It guards two surfaces:
+
+ Write / Edit / MultiEdit markdown content headed for a file
+ Bash a `gh` command publishing a body to GitHub
+
+Two design rules keep it from becoming a nuisance, which matters more than
+catching every last wrap -- a guard that blocks legitimate work gets switched
+off, and then it guards nothing:
+
+ * Only NEW wraps are refused. The hook compares the file as it stands
+ against the file as it would land, and reports only what the edit adds.
+ Editing one word of a legacy document that is wrapped throughout, or
+ rewriting a vendored chart README verbatim, therefore stays possible.
+
+ * Anything ambiguous is allowed. A body assembled at runtime by the shell,
+ an unsplittable command line, a path that is not a regular file, a
+ document the hook cannot read -- all pass. The hook only ever blocks what
+ it is certain about.
+
+Line breaks are rejected only inside prose: a paragraph, a list item, or a
+blockquote spanning more than one line. Everywhere else a break carries
+meaning and is left alone -- code blocks (fenced and indented), tables,
+headings, front matter, HTML, link reference definitions, footnotes, badge
+stacks, and explicit hard breaks (two trailing spaces, or a trailing
+backslash), which render as
and cannot be reflowed without changing the
+output.
+
+Agent hook protocol: a JSON payload on stdin, exit 0 to allow the tool call,
+exit 2 to block it with an explanation on stderr.
+"""
+
+from __future__ import annotations
+
+import difflib
+import json
+import os
+import re
+import shlex
+import stat
+import sys
+from typing import NamedTuple
+
+MARKDOWN_SUFFIXES = (".md", ".markdown", ".mdx")
+
+# Paths whose prose the repo does not own. Vendored upstream charts are
+# regenerated verbatim by `make update` and AGENTS.md forbids editing them;
+# published changelogs are historical records; *.md.gotmpl is a Go template,
+# not a document. Reflowing any of them is the wrong instruction to give.
+OUT_OF_SCOPE = (
+ re.compile(r"packages/[^/]+/[^/]+/charts/"),
+ re.compile(r"/?docs/changelogs/"),
+ re.compile(r"/vendor/"),
+)
+
+# Refuse to read a body file larger than this. A body is prose; anything of
+# this size is not, and a hook must not stall the agent chewing through it.
+MAX_BODY_FILE_BYTES = 1 << 20 # 1 MiB
+
+# Above this, diffing before against after costs more than the check is worth.
+# The hook then cannot tell an added wrap from an inherited one, and allows.
+MAX_DIFF_CHARS = 400_000
+
+# `gh` subcommands that publish a body. `gh pr view`, `gh pr list` and friends
+# are absent by design: they read, they do not publish.
+GH_PUBLISHING_SUBCOMMANDS = {
+ ("pr", "create"),
+ ("pr", "edit"),
+ ("pr", "comment"),
+ ("pr", "review"),
+ ("issue", "create"),
+ ("issue", "edit"),
+ ("issue", "comment"),
+ ("release", "create"),
+ ("release", "edit"),
+ ("api",),
+}
+GH_NOUNS = {"pr", "issue", "release"}
+GH_ACTIONS = {"create", "edit", "comment", "review"}
+
+# Flag meanings are subcommand-dependent: -F is --body-file for `gh pr`, but
+# --raw-field for `gh api`. Getting this backwards silently drops the body.
+BODY_FLAGS = ("--body", "-b", "--notes", "-n")
+BODY_FILE_FLAGS = ("--body-file", "-F", "--notes-file")
+API_FIELD_FLAGS = ("-f", "--field", "-F", "--raw-field")
+
+
+class Violation(NamedTuple):
+ start_line: int # 1-based, first line of the offending block
+ end_line: int # 1-based, last line of the offending block
+ text: str # the block, joined with " | " for the message
+
+
+_RE_HEADING = re.compile(r"^#{1,6}\s")
+_RE_LIST_MARKER = re.compile(r"^\s*([-*+]|\d+[.)])\s")
+_RE_BLOCKQUOTE = re.compile(r"^\s*>")
+_RE_TABLE_ROW = re.compile(r"^\s*\|")
+# A table delimiter row is dashes, colons, pipes and spaces, with at least one
+# pipe. The pipe is what tells it apart from a `---` horizontal rule or setext
+# underline, and it is what makes a pipe-less GFM table (`a | b`) detectable.
+_RE_TABLE_DELIMITER = re.compile(r"^\s*:?-{1,}:?(\s*\|\s*:?-{1,}:?)+\s*$")
+_RE_SETEXT_UNDERLINE = re.compile(r"^\s*(=+|-+)\s*$")
+_RE_ADMONITION_OPENER = re.compile(r"^\s*(\?\?\?|!!!)")
+# Any line opening with a tag is HTML, whether or not it also carries content:
+# `Test output` and `
all green
` are two lines of
+# a block, not a wrapped paragraph, and joining them would be wrong.
+_RE_HTML_BLOCK = re.compile(r"^\s*?[a-zA-Z!]")
+_RE_HTML_COMMENT_OPEN = re.compile(r"^\s*\s*$")
+_RE_FENCE = re.compile(r"^\s*(```|~~~)")
+_RE_HR = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
+_RE_FRONT_MATTER = re.compile(r"^\s*(---|\+\+\+)\s*$")
+# `[label]: https://…` and `[^note]: text` -- one definition per line is the
+# only way to write these, so a run of them is not a wrapped paragraph.
+_RE_LINK_REF_DEF = re.compile(r"^\s*\[[^\]]+\]:\s")
+# `: definition` under a term.
+_RE_DEFINITION = re.compile(r"^\s*:\s")
+# Indented code: four spaces or a tab, outside a list.
+_RE_INDENTED_CODE = re.compile(r"^( |\t)")
+# A README shield stack is one badge per line by convention; collapsing it
+# would be wrong. A paragraph mixing badges with prose is still wrapped prose.
+_RE_BADGE_ROW = re.compile(r"^\s*\[!\[")
+_RE_IMAGE_ROW = re.compile(r"^\s*!\[")
+# Heredoc opener: `< bool:
+ return bool(_RE_BADGE_ROW.match(line) or _RE_IMAGE_ROW.match(line))
+
+
+def ends_with_hard_break(line: str) -> bool:
+ """True iff the line ends in an explicit
: two spaces, or a backslash.
+
+ Such a break renders as a line break. Reflowing it onto one line would
+ change the output, so it is a deliberate break and not a wrap.
+ """
+ return line.endswith(" ") or line.rstrip(" ").endswith("\\")
+
+
+def is_structure(line: str) -> bool:
+ """True iff the line is markdown structure rather than prose."""
+ return any(
+ rx.match(line)
+ for rx in (
+ _RE_HEADING,
+ _RE_BLOCKQUOTE,
+ _RE_TABLE_ROW,
+ _RE_TABLE_DELIMITER,
+ _RE_SETEXT_UNDERLINE,
+ _RE_ADMONITION_OPENER,
+ _RE_HTML_BLOCK,
+ _RE_HR,
+ _RE_LINK_REF_DEF,
+ _RE_DEFINITION,
+ )
+ )
+
+
+def wrapped_groups(block: list[tuple[int, str]]) -> list[list[tuple[int, str]]]:
+ """Split a block into logical lines, and return those spanning >1 physical line.
+
+ An explicit hard break ends a logical line. Everything up to the next hard
+ break (or the end of the block) is one logical line, and if it occupies
+ more than one physical line, the author wrapped it.
+ """
+ groups: list[list[tuple[int, str]]] = []
+ current: list[tuple[int, str]] = []
+
+ for numbered in block:
+ current.append(numbered)
+ if ends_with_hard_break(numbered[1]):
+ groups.append(current)
+ current = []
+ if current:
+ groups.append(current)
+
+ return [g for g in groups if len(g) > 1]
+
+
+def find_violations(content: str) -> list[Violation]:
+ """Return every prose block in content that spans more than one line."""
+ violations: list[Violation] = []
+ lines = content.split("\n")
+
+ def flush(block: list[tuple[int, str]], strip_quote: bool = False) -> None:
+ if len(block) < 2:
+ return
+ # A stack of badges or images is one per line by convention.
+ if all(is_badge_or_image_row(text) for _, text in block):
+ return
+ for group in wrapped_groups(block):
+ violations.append(
+ Violation(
+ start_line=group[0][0],
+ end_line=group[-1][0],
+ text=" | ".join(
+ (t.lstrip("> ").strip() if strip_quote else t.strip())
+ for _, t in group
+ ),
+ )
+ )
+
+ in_fence = False
+ fence_marker = ""
+ in_html_comment = False
+ # A leading `---` opens front matter only if a matching delimiter closes it.
+ # Otherwise it is a horizontal rule, and treating it as an unterminated
+ # front-matter block would swallow the whole document unchecked.
+ front_matter_marker = (
+ lines[0].strip() if lines and _RE_FRONT_MATTER.match(lines[0]) else ""
+ )
+ in_front_matter = bool(front_matter_marker) and any(
+ line.strip() == front_matter_marker for line in lines[1:]
+ )
+ in_table = False
+
+ block: list[tuple[int, str]] = []
+ block_kind: str | None = None # "para" | "list" | "quote"
+
+ for idx, raw in enumerate(lines, start=1):
+ if in_front_matter:
+ if idx > 1 and raw.strip() == front_matter_marker:
+ in_front_matter = False
+ continue
+
+ if in_html_comment:
+ if _RE_HTML_COMMENT_CLOSE.search(raw):
+ in_html_comment = False
+ continue
+ if _RE_HTML_COMMENT_OPEN.match(raw):
+ flush(block, block_kind == "quote")
+ block, block_kind = [], None
+ if not _RE_HTML_COMMENT_CLOSE.search(raw):
+ in_html_comment = True
+ continue
+
+ fence = _RE_FENCE.match(raw)
+ if fence:
+ flush(block, block_kind == "quote")
+ block, block_kind = [], None
+ if not in_fence:
+ in_fence, fence_marker = True, fence.group(1)
+ elif fence_marker in raw:
+ in_fence, fence_marker = False, ""
+ continue
+ if in_fence:
+ continue
+
+ if not raw.strip():
+ flush(block, block_kind == "quote")
+ block, block_kind, in_table = [], None, False
+ continue
+
+ # A delimiter row proves the line above it was a table header, not the
+ # first line of a paragraph. This is what makes a pipe-less GFM table
+ # (`a | b` over `--- | ---`) readable as a table.
+ if _RE_TABLE_DELIMITER.match(raw):
+ if block_kind == "para" and block:
+ block.pop()
+ flush(block, False)
+ block, block_kind, in_table = [], None, True
+ continue
+ if in_table:
+ if "|" in raw:
+ continue
+ in_table = False
+
+ if _RE_BLOCKQUOTE.match(raw):
+ if block_kind != "quote":
+ flush(block, block_kind == "quote")
+ block, block_kind = [], "quote"
+ # `>` on its own separates paragraphs inside the quote.
+ if not raw.lstrip("> ").strip():
+ flush(block, True)
+ block = []
+ else:
+ block.append((idx, raw))
+ continue
+
+ if _RE_LIST_MARKER.match(raw):
+ flush(block, block_kind == "quote")
+ block, block_kind = [(idx, raw)], "list"
+ continue
+
+ if is_structure(raw):
+ flush(block, block_kind == "quote")
+ block, block_kind = [], None
+ continue
+
+ # An indented line directly under a list item is that item's lazy
+ # continuation -- the wrapped-bullet case. Outside a list, and with no
+ # paragraph open, it is an indented code block.
+ if _RE_INDENTED_CODE.match(raw) and block_kind != "list" and not block:
+ continue
+
+ if block_kind == "list":
+ block.append((idx, raw)) # lazy continuation of the item
+ continue
+
+ if block_kind != "para":
+ flush(block, block_kind == "quote")
+ block, block_kind = [], "para"
+ block.append((idx, raw))
+
+ flush(block, block_kind == "quote")
+ return violations
+
+
+def out_of_scope(path: str) -> bool:
+ """True iff the repo does not own this file's prose."""
+ normalised = path.replace(os.sep, "/")
+ if normalised.endswith(".gotmpl"):
+ return True
+ return any(rx.search(normalised) for rx in OUT_OF_SCOPE)
+
+
+def is_markdown(path: str) -> bool:
+ return path.lower().endswith(MARKDOWN_SUFFIXES)
+
+
+def read_text_file(path: str) -> str | None:
+ """Read a regular file, or None if it is missing, huge, or not a file.
+
+ A fifo or a character device would block forever, and the agent with it.
+ """
+ try:
+ expanded = os.path.expanduser(path)
+ info = os.stat(expanded)
+ if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_BODY_FILE_BYTES:
+ return None
+ with open(expanded, encoding="utf-8") as fh:
+ return fh.read()
+ except (OSError, UnicodeDecodeError):
+ return None
+
+
+def added_violations(before: str | None, after: str) -> list[Violation]:
+ """Return the violations whose line breaks THIS edit introduced.
+
+ Legacy documents are wrapped throughout, and demanding that an agent reflow
+ a file it only meant to touch one word of is how a hook gets switched off.
+ But "is this violation new" survives neither of the obvious tests: comparing
+ the violations' text refuses a typo fix inside a paragraph that was already
+ wrapped, and counting them lets a file be rewritten as entirely fresh
+ hardwrapped prose so long as the total does not rise.
+
+ The question that does hold up is about the break, not the block: a soft
+ break is this edit's doing only when BOTH lines it joins are lines the edit
+ wrote. Touching one line of a pair that was already wrapped leaves the break
+ where it was; writing both lines is what creates it.
+ """
+ found = find_violations(after)
+ if before is None or not found:
+ return found
+
+ before_lines = before.split("\n")
+ after_lines = after.split("\n")
+
+ # Lines of `after` that came through the edit untouched, 1-based.
+ untouched: set[int] = set()
+ matcher = difflib.SequenceMatcher(None, before_lines, after_lines, autojunk=False)
+ for tag, _, _, start_line, end_line in matcher.get_opcodes():
+ if tag == "equal":
+ untouched.update(range(start_line + 1, end_line + 1))
+
+ def edit_wrote_this_break(violation: Violation) -> bool:
+ return any(
+ number not in untouched and number + 1 not in untouched
+ for number in range(violation.start_line, violation.end_line)
+ )
+
+ return [v for v in found if edit_wrote_this_break(v)]
+
+
+def apply_edits(original: str, tool_input: dict) -> str | None:
+ """Apply an Edit/MultiEdit payload to the file's current text."""
+ edits: list[dict] = []
+ if isinstance(tool_input.get("edits"), list):
+ edits = [e for e in tool_input["edits"] if isinstance(e, dict)]
+ elif isinstance(tool_input.get("new_string"), str):
+ edits = [tool_input]
+
+ result = original
+ for edit in edits:
+ old = edit.get("old_string")
+ new = edit.get("new_string")
+ if not isinstance(old, str) or not isinstance(new, str) or not old:
+ return None # an empty old_string would prepend, not replace
+ if old not in result:
+ return None # cannot reproduce the edit; do not guess
+ if edit.get("replace_all"):
+ result = result.replace(old, new)
+ else:
+ result = result.replace(old, new, 1)
+ return result
+
+
+def gh_subcommand(args: list[str]) -> tuple[str, ...] | None:
+ """Return the (noun, action) a `gh` invocation names, or None.
+
+ Position alone cannot find the subcommand: a global flag may precede it,
+ and `gh -R owner/repo pr comment` would be read as the subcommand
+ `owner/repo`, letting the body through. Match on the token's value.
+ """
+ noun: str | None = None
+ for token in args:
+ if noun is None:
+ if token == "api":
+ return ("api",)
+ if token in GH_NOUNS:
+ noun = token
+ elif token in GH_ACTIONS:
+ return (noun, token)
+ return None
+
+
+HEREDOC_MARKER = "__COZY_HEREDOC_{}__"
+_RE_HEREDOC_MARKER = re.compile(r"__COZY_HEREDOC_(\d+)__")
+
+
+def split_off_heredocs(command: str) -> tuple[str, list[str]]:
+ """Split a command line into (command with heredocs marked, their bodies).
+
+ A heredoc body is data, and shlex would tokenise it into nonsense. Lift the
+ bodies out and leave a marker where each opener stood, so that the body can
+ later be traced back to the command that opened it -- a heredoc feeding
+ `kubectl apply -f -` is not a GitHub body just because a `gh` call shares
+ the line.
+
+ Every line is scanned for an opener, not only the first: a long `gh` call is
+ routinely spread over backslash-continued lines, the heredoc opening on the
+ last of them.
+ """
+ kept: list[str] = []
+ bodies: list[str] = []
+ body: list[str] = []
+ pending: list[str] = []
+
+ for line in command.split("\n"):
+ if pending:
+ if line.strip() == pending[0]:
+ bodies.append("\n".join(body))
+ body, pending = [], pending[1:]
+ else:
+ body.append(line)
+ continue
+
+ openers = list(_RE_HEREDOC.finditer(line))
+ if openers:
+ marked = line
+ for offset, opener in enumerate(openers):
+ index = len(bodies) + len(pending) + offset
+ marked = marked.replace(
+ opener.group(0), HEREDOC_MARKER.format(index), 1
+ )
+ kept.append(marked)
+ pending.extend(m.group(2) for m in openers)
+ else:
+ kept.append(line)
+
+ if body: # unterminated heredoc: take what we have
+ bodies.append("\n".join(body))
+
+ return "\n".join(kept), bodies
+
+
+def stdin_redirect(args: list[str]) -> str | None:
+ """Return the file a `< path` redirect feeds this segment's stdin from."""
+ for index, arg in enumerate(args):
+ if arg == "<" and index + 1 < len(args):
+ return args[index + 1]
+ if arg.startswith("<") and len(arg) > 1 and not arg.startswith("<<"):
+ return arg[1:]
+ return None
+
+
+def owned_heredocs(args: list[str], bodies: list[str]) -> list[str]:
+ """Return the heredoc bodies opened by this segment, in order."""
+ owned: list[str] = []
+ for arg in args:
+ for match in _RE_HEREDOC_MARKER.finditer(arg):
+ index = int(match.group(1))
+ if index < len(bodies):
+ owned.append(bodies[index])
+ return owned
+
+
+def newlines_to_separators(command: str) -> str:
+ """Turn command-separating newlines into `;`, leaving quoted ones alone.
+
+ shlex treats a newline as plain whitespace, so a script whose second line
+ is the `gh` call lexes into one segment beginning with the FIRST line's
+ command -- and the `gh` invocation is never recognised as one. A newline
+ inside a quoted string belongs to the body and must survive; a
+ backslash-continued newline joins one command, so it is not a separator.
+ """
+ out: list[str] = []
+ quote: str | None = None
+ index = 0
+
+ while index < len(command):
+ char = command[index]
+
+ if quote:
+ if char == "\\" and quote == '"' and index + 1 < len(command):
+ out.append(char)
+ out.append(command[index + 1])
+ index += 2
+ continue
+ if char == quote:
+ quote = None
+ out.append(char)
+ index += 1
+ continue
+
+ if char in ("'", '"'):
+ quote = char
+ out.append(char)
+ index += 1
+ continue
+
+ if char == "\\" and index + 1 < len(command) and command[index + 1] == "\n":
+ out.append(" ") # line continuation: one command, not two
+ index += 2
+ continue
+
+ if char == "\n":
+ out.append(" ; ")
+ index += 1
+ continue
+
+ out.append(char)
+ index += 1
+
+ return "".join(out)
+
+
+def shell_assembled(value: str) -> bool:
+ """True iff the shell, not the agent, decides what this argument says.
+
+ `--body "$(cat < list[str]:
+ """Return the prose fields of a JSON document (`gh api --input` payload).
+
+ A JSON heredoc is not markdown: its braces and quoted keys are not a
+ wrapped paragraph. Only the human-facing values inside it are prose.
+ """
+ try:
+ data = json.loads(text)
+ except (json.JSONDecodeError, ValueError):
+ return []
+
+ found: list[str] = []
+
+ def walk(node: object) -> None:
+ if isinstance(node, dict):
+ for key, value in node.items():
+ if key in ("body", "text") and isinstance(value, str):
+ found.append(value)
+ else:
+ walk(value)
+ elif isinstance(node, list):
+ for item in node:
+ walk(item)
+
+ walk(data)
+ return found
+
+
+def segment_bodies(args: list[str], is_api: bool) -> tuple[list[str], bool]:
+ """Return (bodies, wants_stdin) for one `gh` segment's arguments."""
+ bodies: list[str] = []
+ wants_stdin = False
+
+ idx = 0
+ while idx < len(args):
+ arg = args[idx]
+ nxt = args[idx + 1] if idx + 1 < len(args) else None
+
+ if is_api:
+ # For `gh api`, -F is --raw-field, not --body-file.
+ if arg in API_FIELD_FLAGS and nxt is not None:
+ if nxt.startswith("body=") or nxt.startswith("text="):
+ value = nxt.split("=", 1)[1]
+ if value.startswith("@"):
+ content = read_text_file(value[1:])
+ if content is not None:
+ bodies.append(content)
+ else:
+ bodies.append(value)
+ idx += 2
+ continue
+ if arg == "--input" and nxt is not None:
+ if nxt == "-":
+ wants_stdin = True
+ else:
+ content = read_text_file(nxt)
+ if content is not None:
+ bodies.extend(json_bodies(content))
+ idx += 2
+ continue
+ idx += 1
+ continue
+
+ if arg in BODY_FLAGS and nxt is not None:
+ if not shell_assembled(nxt):
+ bodies.append(nxt)
+ idx += 2
+ continue
+ if arg.startswith(("--body=", "--notes=")):
+ value = arg.split("=", 1)[1]
+ if not shell_assembled(value):
+ bodies.append(value)
+ idx += 1
+ continue
+ if arg in BODY_FILE_FLAGS and nxt is not None:
+ if nxt == "-":
+ wants_stdin = True
+ elif not out_of_scope(nxt):
+ content = read_text_file(nxt)
+ if content is not None:
+ bodies.append(content)
+ idx += 2
+ continue
+ if arg.startswith(("--body-file=", "--notes-file=")):
+ value = arg.split("=", 1)[1]
+ if value == "-":
+ wants_stdin = True
+ elif not out_of_scope(value):
+ content = read_text_file(value)
+ if content is not None:
+ bodies.append(content)
+ idx += 1
+ continue
+ idx += 1
+
+ return bodies, wants_stdin
+
+
+def gh_bodies(command: str) -> list[str]:
+ """Return every body a `gh` command line would publish to GitHub."""
+ if "gh" not in command:
+ return []
+
+ stripped, heredocs = split_off_heredocs(command)
+ stripped = newlines_to_separators(stripped)
+
+ # punctuation_chars makes the lexer return `;`, `&&`, `|`, `(` and friends
+ # as tokens of their own. Without it `git push; gh pr create …` lexes as
+ # `push;` plus a `gh` that never starts a segment, and the body it
+ # publishes is never seen.
+ try:
+ lexer = shlex.shlex(stripped, posix=True, punctuation_chars=True)
+ lexer.whitespace_split = True
+ tokens = list(lexer)
+ except ValueError: # unbalanced quotes: not ours to judge
+ return []
+
+ # A command line may chain several commands; only `gh` segments matter.
+ separators = (";", "&", "&&", "||", "|", "(", ")", "{", "}", "\n")
+ segments: list[list[str]] = [[]]
+ for token in tokens:
+ if token in separators:
+ segments.append([])
+ else:
+ segments[-1].append(token)
+
+ bodies: list[str] = []
+ for segment in segments:
+ # An environment prefix (`GH_TOKEN=… gh …`) would otherwise hide the
+ # `gh` behind a token that is not a command name.
+ while segment and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", segment[0]):
+ segment = segment[1:]
+ if not segment or os.path.basename(segment[0]) != "gh":
+ continue
+
+ subcommand = gh_subcommand(segment[1:])
+ if subcommand not in GH_PUBLISHING_SUBCOMMANDS:
+ continue
+
+ is_api = subcommand == ("api",)
+ found, wants_stdin = segment_bodies(segment[1:], is_api)
+ bodies.extend(found)
+
+ # A heredoc THIS segment opened is the body it publishes -- as JSON for
+ # `gh api`, as markdown otherwise. A heredoc opened by another command
+ # on the same line (a commit message, a YAML manifest) belongs to that
+ # command, and blocking on it would be a false positive of exactly the
+ # kind that gets a hook switched off.
+ owned = owned_heredocs(segment[1:], heredocs)
+ for heredoc in owned:
+ bodies.extend(json_bodies(heredoc) if is_api else [heredoc])
+
+ # `--body-file -` / `--input -` take the body from stdin; a `< file`
+ # redirect says which file that is. Without following it the body
+ # publishes unread.
+ if wants_stdin and not owned:
+ source = stdin_redirect(segment[1:])
+ if source and not out_of_scope(source):
+ content = read_text_file(source)
+ if content is not None:
+ bodies.extend(json_bodies(content) if is_api else [content])
+
+ return bodies
+
+
+def truncate(text: str, limit: int = 160) -> str:
+ return text if len(text) <= limit else text[: limit - 1] + "…"
+
+
+def report(subject: str, violations: list[Violation]) -> None:
+ lines = [
+ "BLOCKED: hardwrapped prose.",
+ "AGENTS.md rule: one continuous line per paragraph — the renderer wraps to viewer width.",
+ "Reflow each block below onto a single line, then retry.",
+ "",
+ f"Subject: {subject}",
+ ]
+ for v in violations[:5]:
+ lines.append(f" - lines {v.start_line}-{v.end_line}: {truncate(v.text)}")
+ if len(violations) > 5:
+ lines.append(f" - … and {len(violations) - 5} more")
+ print("\n".join(lines), file=sys.stderr)
+
+
+def main() -> int:
+ try:
+ payload = json.load(sys.stdin)
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return 0
+ if not isinstance(payload, dict):
+ return 0
+
+ tool_name = payload.get("tool_name", "")
+ tool_input = payload.get("tool_input")
+ if not isinstance(tool_input, dict):
+ return 0
+
+ violations: list[Violation] = []
+
+ if tool_name in ("Write", "Edit", "MultiEdit"):
+ path = tool_input.get("file_path")
+ if not isinstance(path, str) or not is_markdown(path) or out_of_scope(path):
+ return 0
+
+ subject = path
+ before = read_text_file(path)
+
+ if tool_name == "Write":
+ after = tool_input.get("content")
+ if not isinstance(after, str):
+ return 0
+ else:
+ if before is None:
+ return 0 # cannot see the file, cannot judge the hunk
+ after = apply_edits(before, tool_input)
+ if after is None:
+ return 0
+
+ violations = added_violations(before, after)
+
+ elif tool_name == "Bash":
+ command = tool_input.get("command")
+ if not isinstance(command, str):
+ return 0
+ subject = "gh command body"
+ for body in gh_bodies(command):
+ violations.extend(find_violations(body))
+
+ else:
+ return 0
+
+ if not violations:
+ return 0
+
+ report(str(subject), violations)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 000000000..395314de6
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,16 @@
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Write|Edit|MultiEdit|Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/md-no-hardwrap.py",
+ "timeout": 10
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
index 529052948..4d15bd0bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -83,7 +83,17 @@ tmp/
# build revision marker (generated by make image-packages)
packages/core/platform/.build-revision
-.claude/
+
+# Agent tooling. The md-no-hardwrap hook and its registration are shared with
+# every contributor, so they are tracked by name; personal agent state (local
+# settings overrides, worktrees, session data) stays out of git. Put personal
+# overrides in .claude/settings.local.json -- .claude/settings.json is tracked
+# and git will overwrite it on pull.
+.claude/*
+!.claude/hooks/
+.claude/hooks/*
+!.claude/hooks/md-no-hardwrap.py
+!.claude/settings.json
# Credentials and runtime state minted by the backup/restore demo scripts
# under examples/backups/*/. Files are written by 00-helpers.sh +
diff --git a/hack/md-no-hardwrap.bats b/hack/md-no-hardwrap.bats
new file mode 100644
index 000000000..667b43bf8
--- /dev/null
+++ b/hack/md-no-hardwrap.bats
@@ -0,0 +1,667 @@
+#!/usr/bin/env bats
+# Unit test: the md-no-hardwrap agent hook.
+#
+# The hook is a PreToolUse guard for AI coding agents. It refuses two classes
+# of write:
+#
+# 1. Write/Edit/MultiEdit whose resulting markdown contains a prose
+# paragraph, list item or blockquote spanning more than one line.
+# 2. Bash invocations of `gh` that publish such a body to GitHub.
+#
+# Markdown renderers collapse a soft line break into a space, so a paragraph
+# hardwrapped at ~80 columns gains nothing and loses on narrow viewports and
+# in diffs, where a one-word edit reflows the whole block. Everything that is
+# not prose -- code blocks, tables, headings, front matter, link reference
+# definitions, explicit hard breaks -- keeps its line breaks, because there a
+# break carries meaning.
+#
+# Two properties matter more than coverage of exotic markdown, and most of
+# the cases below defend them:
+#
+# * A false positive is worse than a miss. A hook that blocks legitimate
+# work gets switched off, and then it guards nothing.
+# * The single most common wrap -- a bullet the agent broke across two
+# lines -- must be caught. It is the whole reason the hook exists.
+#
+# The hook speaks the agent hook protocol: a JSON payload on stdin, exit 0 to
+# allow, exit 2 to block with an explanation on stderr.
+
+REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
+HOOK="$REPO_ROOT/.claude/hooks/md-no-hardwrap.py"
+
+# The hook is written in python3, and so is the payload builder below. Other
+# hack/*.bats deliberately avoid a python3 dependency; a test for a python3
+# hook cannot. Fail loudly when the interpreter is missing rather than passing
+# vacuously -- a green suite that ran nothing is worse than a red one.
+have_python3() {
+ command -v python3 >/dev/null 2>&1 && return 0
+ echo "python3 is required to exercise .claude/hooks/md-no-hardwrap.py" >&2
+ return 1
+}
+
+# Feed a JSON payload to the hook and echo its exit code. Never fails the
+# calling test on a non-zero exit -- the exit code IS the assertion subject.
+hook_rc() {
+ local rc=0
+ printf '%s' "$1" | python3 "$HOOK" >/dev/null 2>&1 || rc=$?
+ printf '%s' "$rc"
+}
+
+# Payload builders. python3 does the JSON encoding so that newlines, quotes
+# and backslashes survive the trip intact.
+write_payload() {
+ python3 -c 'import json,sys; print(json.dumps({"tool_name":"Write","tool_input":{"file_path":sys.argv[1],"content":sys.argv[2]}}))' "$1" "$2"
+}
+
+edit_payload() {
+ python3 -c 'import json,sys; print(json.dumps({"tool_name":"Edit","tool_input":{"file_path":sys.argv[1],"old_string":sys.argv[2],"new_string":sys.argv[3]}}))' "$1" "$2" "$3"
+}
+
+bash_payload() {
+ python3 -c 'import json,sys; print(json.dumps({"tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' "$1"
+}
+
+@test "hook is executable" {
+ [ -x "$HOOK" ]
+}
+
+###############################################################################
+# Markdown: what must be blocked #
+###############################################################################
+
+@test "markdown: multi-line prose paragraph is blocked" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md 'A paragraph that the agent
+hardwrapped at eighty columns because of a reflex.')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "markdown: bullet wrapped onto a second line is blocked" {
+ have_python3
+ # The commonest wrap of all, and the one the hook exists for. A lazy
+ # continuation line belongs to the bullet above it, not to a new paragraph.
+ payload="$(write_payload /tmp/doc.md '- a bullet the agent wrapped
+ at eighty columns
+- a bullet that stays on one line')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "markdown: ordered list item wrapped onto a second line is blocked" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md '1. an ordered item the agent wrapped
+ onto a second line')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "markdown: wrapped blockquote is blocked" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md '> a quoted paragraph the agent
+> wrapped across two lines')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+###############################################################################
+# Markdown: what must NOT be blocked #
+###############################################################################
+
+@test "markdown: single-line paragraphs pass" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md '# Title
+
+One continuous line per paragraph, however long it happens to run.
+
+Another paragraph, also a single line.')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: fenced code block keeps its line breaks" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md 'Intro line.
+
+```bash
+echo one
+echo two
+```')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: indented code block keeps its line breaks" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md 'Intro line.
+
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: demo')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: explicit hard break with two trailing spaces is preserved" {
+ have_python3
+ # Two trailing spaces render as
. Reflowing that onto one line would
+ # change the rendered output, so it is a deliberate break, not a wrap.
+ payload="$(printf '%s' '{"tool_name":"Write","tool_input":{"file_path":"/tmp/doc.md","content":"Line one, ending in a hard break. \nLine two, its own rendered line.\n"}}')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: explicit hard break with a trailing backslash is preserved" {
+ have_python3
+ payload="$(printf '%s' '{"tool_name":"Write","tool_input":{"file_path":"/tmp/doc.md","content":"Line one, ending in a backslash break.\\\\\nLine two, its own rendered line.\n"}}')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: adjacent link reference definitions keep one per line" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md '[upstream]: https://example.com/upstream
+[downstream]: https://example.com/downstream')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: footnote definitions keep one per line" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md '[^one]: The first footnote.
+[^two]: The second footnote.')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: lists and tables keep their line breaks" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md '- first bullet
+- second bullet
+
+| a | b |
+| --- | --- |
+| 1 | 2 |')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: pipe-less table keeps its line breaks" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md 'a | b
+--- | ---
+1 | 2')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: badge stack keeps one shield per line" {
+ have_python3
+ payload="$(write_payload /tmp/README.md '[](https://example.com/build)
+[](https://example.com/license)')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: the repo's own README passes the hook" {
+ have_python3
+ # README.md uses a two-space hard break. A hook that cannot write the repo's
+ # front page is a hook that gets switched off.
+ payload="$(python3 -c 'import json,sys; p=sys.argv[1]; print(json.dumps({"tool_name":"Write","tool_input":{"file_path":p,"content":open(p,encoding="utf-8").read()}}))' "$REPO_ROOT/README.md")"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: AGENTS.md passes the hook it documents" {
+ have_python3
+ payload="$(python3 -c 'import json,sys; p=sys.argv[1]; print(json.dumps({"tool_name":"Write","tool_input":{"file_path":p,"content":open(p,encoding="utf-8").read()}}))' "$REPO_ROOT/AGENTS.md")"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+###############################################################################
+# Markdown: Edit hunks are judged against the file, not in isolation #
+###############################################################################
+
+@test "markdown: Edit hunk introducing a wrap is blocked" {
+ have_python3
+ tmp="$(mktemp -d)"
+ printf 'Existing single line.\n\nold\n' > "$tmp/doc.md"
+ payload="$(edit_payload "$tmp/doc.md" 'old' 'A fresh paragraph that got
+wrapped across two lines.')"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "2" ]
+}
+
+@test "markdown: Edit hunk landing inside a code fence is allowed" {
+ have_python3
+ # The hunk alone looks like a wrapped paragraph. Only the surrounding file
+ # shows it is YAML inside a fence -- so the file is what must be judged.
+ tmp="$(mktemp -d)"
+ printf 'Intro line.\n\n```yaml\nPLACEHOLDER\n```\n' > "$tmp/doc.md"
+ payload="$(edit_payload "$tmp/doc.md" 'PLACEHOLDER' 'apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: demo')"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "0" ]
+}
+
+@test "markdown: fixing a typo inside a legacy wrapped paragraph is allowed" {
+ have_python3
+ # The paragraph stays as wrapped as it was -- the edit adds no wrap. Keying
+ # violations by their text would see the changed wording as a brand-new
+ # violation and refuse a one-word fix.
+ tmp="$(mktemp -d)"
+ printf 'A legacy paragraph with teh typo\nwrapped long ago.\n' > "$tmp/doc.md"
+ payload="$(edit_payload "$tmp/doc.md" 'teh' 'the')"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "0" ]
+}
+
+@test "markdown: adding a wrap to an already-wrapped file is still blocked" {
+ have_python3
+ tmp="$(mktemp -d)"
+ printf 'A legacy paragraph that was\nwrapped long ago.\n\nTARGET\n' > "$tmp/doc.md"
+ payload="$(edit_payload "$tmp/doc.md" 'TARGET' 'A brand new paragraph that the
+agent wrapped as well.')"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "2" ]
+}
+
+@test "markdown: Edit preserving a pre-existing wrap elsewhere is allowed" {
+ have_python3
+ # Legacy files are full of wrapped paragraphs. Editing one word in such a
+ # file must not require reflowing the rest of it.
+ tmp="$(mktemp -d)"
+ printf 'A legacy paragraph that was\nwrapped long ago.\n\nTARGET\n' > "$tmp/doc.md"
+ payload="$(edit_payload "$tmp/doc.md" 'TARGET' 'A replacement on one line.')"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "0" ]
+}
+
+###############################################################################
+# Markdown: paths the repo does not own #
+###############################################################################
+
+@test "markdown: vendored upstream chart docs are out of scope" {
+ have_python3
+ # AGENTS.md forbids editing vendored charts; `make update` regenerates them
+ # verbatim from upstream. Flagging their wraps would demand a forbidden fix.
+ payload="$(write_payload "$REPO_ROOT/packages/system/cert-manager/charts/cert-manager/README.md" 'A vendored paragraph
+wrapped by its upstream author.')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: published changelogs are out of scope" {
+ have_python3
+ payload="$(write_payload "$REPO_ROOT/docs/changelogs/v1.5.0.md" 'A released changelog entry
+wrapped when it was published.')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "non-markdown files are none of the hook's business" {
+ have_python3
+ payload="$(write_payload /tmp/main.go 'package main
+
+// A Go comment wrapped at eighty columns, which is correct style
+// for Go and must not be blocked by a markdown rule.
+func main() {}')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+###############################################################################
+# gh: bodies that must be blocked #
+###############################################################################
+
+@test "gh: PR body with a wrapped paragraph is blocked" {
+ have_python3
+ payload="$(bash_payload 'gh pr create --title "fix(x): y" --body "This body was
+hardwrapped by the agent."')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: --body= equals form is blocked" {
+ have_python3
+ payload="$(bash_payload 'gh issue comment 42 --body="A body that was
+wrapped across two lines."')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: global flag before the subcommand does not hide the body" {
+ have_python3
+ # `gh -R owner/repo pr comment` puts the repo between `gh` and the
+ # subcommand. Reading the subcommand as "the first non-flag argument" picks
+ # up the repo instead, and the body sails through unchecked.
+ payload="$(bash_payload 'gh -R cozystack/cozystack pr comment 123 --body "First line of the body
+and its wrapped continuation."')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: environment prefix does not hide the body" {
+ have_python3
+ payload="$(bash_payload 'GH_TOKEN=secret gh issue comment 1 --body "First line of the body
+and its wrapped continuation."')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: subshell does not hide the body" {
+ have_python3
+ payload="$(bash_payload '(gh issue comment 1 --body "First line of the body
+and its wrapped continuation.")')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: chained command after && is still inspected" {
+ have_python3
+ payload="$(bash_payload 'git push && gh pr create --title "t" --body "First line of the body
+and its wrapped continuation."')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: chained command after a semicolon with no space is still inspected" {
+ have_python3
+ # `push;` is one shlex token unless the lexer treats `;` as punctuation, and
+ # then the gh segment that follows is never recognised as one.
+ payload="$(bash_payload 'git push; gh pr create --title "t" --body "First line of the body
+and its wrapped continuation."')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: body survives a redirect on the same command" {
+ have_python3
+ payload="$(bash_payload 'gh pr create --title "t" --body "First line of the body
+and its wrapped continuation." > /dev/null')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: heredoc body with a wrapped paragraph is blocked" {
+ have_python3
+ payload="$(bash_payload 'gh pr create --body-file - < "$tmp/body.md"
+ payload="$(bash_payload "gh pr create --body-file $tmp/body.md")"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "2" ]
+}
+
+@test "gh: api field body with a wrapped paragraph is blocked" {
+ have_python3
+ payload="$(bash_payload 'gh api --method POST /repos/o/r/issues/1/comments -f body="A review reply that was
+wrapped across two lines."')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: api raw field body with a wrapped paragraph is blocked" {
+ have_python3
+ # For `gh api`, -F means --raw-field, not --body-file. Reading it as a file
+ # name drops the body on the floor and the wrap ships.
+ payload="$(bash_payload 'gh api --method POST /repos/o/r/issues/1/comments -F body="A review reply that was
+wrapped across two lines."')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+###############################################################################
+# gh: bodies and commands that must NOT be blocked #
+###############################################################################
+
+@test "gh: single-line PR body passes" {
+ have_python3
+ payload="$(bash_payload 'gh pr create --title "fix(x): y" --body "One continuous line, as the renderer expects."')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "gh: read-only subcommands are never blocked" {
+ have_python3
+ payload="$(bash_payload 'gh pr view 42 --json body')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "gh: release notes from a published changelog are not blocked" {
+ have_python3
+ # hack/upload-releasenotes.sh publishes docs/changelogs/.md, which
+ # is historical and wrapped. Blocking it would break the release process.
+ payload="$(bash_payload "gh release edit v1.5.0 --notes-file $REPO_ROOT/docs/changelogs/v1.5.0.md")"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "gh: api JSON heredoc is not read as markdown" {
+ have_python3
+ # `gh api --input -` takes JSON, not markdown. Its braces and quoted keys
+ # are not a hardwrapped paragraph.
+ #
+ # The JSON is indented so that no line of it starts with `}`: cozytest.sh
+ # converts each @test into a shell function with awk, and a bare `}` in the
+ # first column ends that function early, silently truncating the test.
+ payload="$(bash_payload 'gh api --method POST /repos/o/r/pulls/1/reviews --input - < /tmp/notes.txt')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+###############################################################################
+# Robustness: the hook must never hang, crash, or block what it cannot read #
+###############################################################################
+
+@test "unparseable payload fails open" {
+ have_python3
+ rc=0
+ printf 'not json at all' | python3 "$HOOK" >/dev/null 2>&1 || rc=$?
+ [ "$rc" = "0" ]
+}
+
+@test "gh: --body-file on a fifo does not hang" {
+ have_python3
+ # An unopened fifo blocks forever on read. The hook must refuse to read
+ # anything that is not a regular file rather than stall the agent.
+ tmp="$(mktemp -d)"
+ mkfifo "$tmp/fifo"
+ payload="$(bash_payload "gh pr create --body-file $tmp/fifo")"
+ rc=0
+ printf '%s' "$payload" | python3 "$HOOK" >/dev/null 2>&1 || rc=$?
+ rm -rf "$tmp"
+ [ "$rc" = "0" ]
+}
+
+@test "gh: unbalanced quotes fail open" {
+ have_python3
+ payload="$(bash_payload 'gh pr create --body "unterminated')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "block message names the rule and the offending lines" {
+ have_python3
+ payload="$(write_payload /tmp/doc.md 'A paragraph that the agent
+hardwrapped at eighty columns.')"
+ msg="$(printf '%s' "$payload" | python3 "$HOOK" 2>&1 >/dev/null || true)"
+ printf '%s' "$msg" | grep -q 'one continuous line per paragraph'
+ printf '%s' "$msg" | grep -q '/tmp/doc.md'
+}
+
+###############################################################################
+# The question is always "did this edit ADD the line break", never "is there #
+# a wrap somewhere in the file". These cases pin that distinction down. #
+###############################################################################
+
+@test "markdown: rewriting a file as entirely wrapped prose is blocked" {
+ have_python3
+ # Ties the violation count of the legacy file, so any count-based test waves
+ # it through -- while every wrap in it is brand new.
+ tmp="$(mktemp -d)"
+ printf 'A legacy paragraph that was\nwrapped long ago.\n' > "$tmp/doc.md"
+ payload="$(write_payload "$tmp/doc.md" 'A completely new paragraph that the
+agent has just hardwrapped.')"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "2" ]
+}
+
+@test "markdown: reflowing one paragraph while wrapping another is blocked" {
+ have_python3
+ tmp="$(mktemp -d)"
+ printf 'One wrapped paragraph\nfrom long ago.\n\nSECOND\n' > "$tmp/doc.md"
+ payload="$(edit_payload "$tmp/doc.md" 'SECOND' 'A second paragraph the agent
+has now wrapped as well.')"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "2" ]
+}
+
+@test "markdown: rewriting a legacy file verbatim is allowed" {
+ have_python3
+ tmp="$(mktemp -d)"
+ printf 'A legacy paragraph that was\nwrapped long ago.\n' > "$tmp/doc.md"
+ payload="$(write_payload "$tmp/doc.md" 'A legacy paragraph that was
+wrapped long ago.')"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "0" ]
+}
+
+@test "gh: a publishing command on the second line of a script is inspected" {
+ have_python3
+ # A newline is whitespace to shlex, so without explicit handling the gh call
+ # folds into the previous command's segment and is never seen.
+ payload="$(bash_payload 'git status
+gh pr create --title "t" --body "A body paragraph the agent
+hardwrapped across two lines."')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: backslash-continued command with a heredoc body is inspected" {
+ have_python3
+ payload="$(bash_payload 'gh pr create \
+ --title "t" \
+ --body-file - <
+Test output
+all green
+ "')"
+ [ "$(hook_rc "$payload")" = "0" ]
+}
+
+@test "markdown: a leading horizontal rule does not swallow the document" {
+ have_python3
+ # `---` on line 1 opens front matter only if a closing delimiter follows.
+ payload="$(write_payload /tmp/doc.md '---
+
+A paragraph the agent
+hardwrapped after a horizontal rule.')"
+ [ "$(hook_rc "$payload")" = "2" ]
+}
+
+@test "gh: body redirected onto stdin from a file is inspected" {
+ have_python3
+ # `--body-file -` says the body arrives on stdin, and `< body.md` says where
+ # stdin comes from. Recording the first without following the second lets the
+ # body publish unread.
+ tmp="$(mktemp -d)"
+ printf 'A body paragraph the agent\nhardwrapped across two lines.\n' > "$tmp/body.md"
+ payload="$(bash_payload "gh pr create --title t --body-file - < $tmp/body.md")"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "2" ]
+}
+
+@test "gh: api input redirected onto stdin from a file is inspected" {
+ have_python3
+ tmp="$(mktemp -d)"
+ printf '{"body": "A review body that was\\nwrapped across two lines."}\n' > "$tmp/review.json"
+ payload="$(bash_payload "gh api --method POST /repos/o/r/pulls/1/reviews --input - < $tmp/review.json")"
+ rc="$(hook_rc "$payload")"
+ rm -rf "$tmp"
+ [ "$rc" = "2" ]
+}