Files
holocron/docs/adr/0023-rtk-prefix-marks-executable-commands-only.md
Defame1297 ed8c99efbd fix(git): stop prefixing rtk where it rewrites the output skills parse
The #113 sweep rested on CLAUDE.md's premise that rtk either filters or passes through unchanged,
so prefixing is always safe. Measured against rtk 0.42.4, that premise is false for several of the
commands the sweep prefixed, and two skills were left giving wrong answers silently.

Why:
- `rtk git worktree list --porcelain -z` discards both flags and renders its own format. The
  `locked`/`lock_reason` fields git-worktrees Step 2 must emit are absent entirely, and paths under
  $HOME are abbreviated to `~/`.
- `rtk git branch --list <name>` prints a phantom `* ` line even when nothing matches, so
  git-branches' stated ambiguity test — "output from both means the name is ambiguous" — reported
  every name as ambiguous. `tag --list` is a clean passthrough, so only one half broke.
- `rtk git diff --name-only`/`--name-status` append a `Changes:` trailer to output documented as
  "one per line"; `--word-diff` emits none of the `[-removed-] {+added+}` markers its table
  describes; `rtk git log -L` truncates each line at ~72 chars, on the one command whose purpose is
  showing line content.
- `rtk git stash pop` prints only `FAILED: git stash pop`, swallowing the conflict diagnostic and
  retained-entry message the surrounding prose tells the agent to rely on.

Implementation notes:
- Eleven sites reverted to bare `git`, each carrying its reason inline so the next sweep does not
  undo it. `mergetool` and `rebase -i` are reverted on clause 3's interactive limb only: the TTY
  defect does not reproduce — rtk filters exactly twelve subcommands and execs the rest — and
  ADR-0023 records that measurement rather than a convenient one.
- ADR-0023 states the rule repo-wide with a third clause: a command whose output the skill parses,
  or which is interactive, stays bare. `plugins/git/README.md` is reduced to a pointer; its claim
  that gitea skills "contain no git/rtk mentions at all" was false, and its citation of
  `hard-rules.md` pointed at a file containing no occurrence of "rtk".
- Eight gitea sites swept, all verified byte-identical passthroughs first.
- `scripts/check-rtk-prefix.sh` gates clause 1. Run against main's pre-sweep corpus it reports 99
  findings including every gitea site, so it would have caught the drift #113 was filed about.

Impact: the gate covers clause 1 only, in shell-tagged fences and the opening span of Run cells.
Clause 2 is not gateable — "Run `git switch`" and "`git switch` refuses" are the same tokens — and
prose bullets are invisible to it. Both limits are recorded in gates.md rather than left implied.

Refs: #113
ADR: 0023
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeH8SCbcrCAQrtymkNuhKP
2026-09-09 05:14:54 +00:00

12 KiB

The rtk prefix marks executable commands only, and is repo-wide

Status: accepted (2026-09-08).

Context

CLAUDE.md states the org convention as a golden rule: "Always prefix commands with rtk. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use." Issue #113 observed that the rule had never been written down for skill prose, where a git <subcommand> mention can be either an instruction to execute or a reference to the concept, and that the corpus had drifted into carrying both spellings with no stated rule. PR #130 swept the git plugin and recorded a two-way split in plugins/git/README.md.

Review found two defects in that sweep, and both are in the premise rather than the execution.

RTK is not output-transparent. rtk git --help enumerates twelve filtered subcommands — diff, log, status, show, add, commit, push, pull, branch, fetch, stash, worktree. Everything else is a true passthrough. Inside that set the filter is not a formatting preference; it changes what the command reports. Measured against rtk 0.42.4:

Command What rtk does to it
worktree list --porcelain -z discards both flags; no NUL separators, no locked/lock_reason field at all
worktree list -v abbreviates /root/… to ~/…, collapses column alignment
branch --list <name> emits a phantom * line even on no match
diff --name-only / --name-status appends a blank line and a Changes: trailer
diff --word-diff[=color|=porcelain] emits no [-removed-] {+added+} markers; substitutes a diffstat
log -L truncates each diff body line at ~72 characters with an ellipsis
stash pop (on conflict) prints only FAILED: git stash pop, swallowing CONFLICT, Unmerged paths and the retained-entry notice
stash list (empty) prints No stashes where git prints nothing

Every one of those falsified a skill that was written against the bare output. git-worktrees's Step 2 required locked and lock_reason from a command whose rtk rendering has never carried them; git-log-format.md documented [-removed-] {+added+} markers beside a command that no longer produces them. The two-way split could not see any of this, because both halves of it are about what a sentence is doing and none of it is about what the command does.

The rule is not git-plugin-scoped. plugins/git/README.md claimed the gitea-* skills "contain no git/rtk mentions at all". Five gitea-* SKILL.md files run git remote get-url origin in a fenced ```bash Step block — the README's own canonical example of "executable, instructed" — plus git branch --show-current in a reference file and three git remote -v in gitea-orchestrate.agent.md. A convention stated inside one plugin's README is invisible from the plugin next door, which is how those eight sites stayed bare through the sweep that existed to find them.

Decision

One rule, three clauses, repo-wide — every plugins/*/.apm/skills/** and plugins/*/.apm/agents/** file, not the git plugin alone.

  1. Executable and instructed → rtk git. Anything telling the agent to run a command now: an imperative step, a dispatch-table "Run" cell, a fenced code-block procedure. rtk git push -u origin <branch>.
  2. Illustrative or referential → bare git. Naming a flag's behaviour, quoting a doc's own heading, describing a command in the abstract, warning against an anti-pattern. "git switch refuses rather than clobbering conflicting local edits."
  3. Machine-parsed or interactive → bare git, and say why inline. A command whose output the skill parses, where rtk is in the filtered set above; or a command that hands control to an interactive child process.

Clause 3 is the new one and it looks arbitrary without the table in Context, which is why the measurements are recorded here rather than left in a PR thread. It is applied per subcommand and per flag, not per skill: tag --list stays prefixed because rtk passes it through byte-identically, while branch --list two words away goes bare because it does not. git remote get-url origin, git remote -v, git branch --show-current, git log --oneline -1 and git add -u were all re-measured as byte-identical passthroughs and are therefore prefixed, parsing notwithstanding.

Two consequences of that per-subcommand basis are worth stating, because both are load-bearing and neither is comfortable:

  • rtk's filtered set is a moving target. git rebase and git mergetool are passthroughs on 0.42.4 — verified under script(1), both inherit a real TTY, contradicting an earlier report that they did not. They stay bare anyway, on the interactive limb: a token filter has nothing to offer a command that hands control to an editor, and the prefix would only buy exposure to whatever a later rtk version decides to do with those subcommands. The same reasoning makes the inner call in `rtk git remote add origin-push $(git config remote.origin.url)` bare while the outer stays prefixed — config passes through cleanly today, but its stdout becomes a remote URL that is then force-pushed to, and that is not a blast radius to lend to a future filter change.
  • branch --show-current sits on the sharp edge. It is in the filtered set, it is parsed, and it is prefixed — on a measurement, in a subcommand whose sibling --list is exactly the defect clause 3 exists for. If rtk's branch filter is ever extended, that is the first site to break. It is called out rather than hedged, because a rule whose exceptions are unrecorded is the state this ADR is replacing.

A clause-3 site says so inline, in a few words. "bare, not rtk: rtk prints a phantom * line even on no match". Without it the next sweep re-prefixes the command, which is how #113 recurs.

The rule lives here, and docs/spec/gates.md carries the gate. plugins/git/README.md is reduced to a pointer. It had also cited git-workflow/references/hard-rules.md as a place the rule was written down; that file contains no occurrence of "rtk", and the citation is removed rather than repaired.

Clause 1 is enforced by a check-rtk-prefix pre-commit hook; clauses 2 and 3 are not enforceable and are not gated. The hook checks the two places a git mention is unambiguously an instruction — a line in a shell-tagged code fence, and the opening backticked span of a "Run" column cell — and a deliberately-bare command opts out with the literal string ADR-0023 on its own line. Its coverage limits are recorded in docs/spec/gates.md, not smoothed over.

Considered options

Add compatibility: frontmatter to every skill. These six plugins are installable by third parties, and a consumer who installs git from the marketplace has no rtk on their PATH. Every prefixed command in the corpus is a plain git invocation with a word in front of it, so the prefix is droppable: delete rtk and the command is correct. A compatibility: line per skill would state that in a machine-readable field. Rejected on cost. It is 39 lines of frontmatter restating one sentence, it is preloaded into every agent's context every session under ADR-0020's budget — the field is not free the way a line in a doc is — and it has no consumer: nothing reads compatibility:, so the field would be a comment with a colon in it. The consumer situation is documented here and in plugins/git/README.md instead, which is where a human installing a plugin actually looks. The same two-line note is owed to the other five plugin READMEs and is not yet written.

Move rtk to the execution layer entirely. Skills instruct bare git throughout; CLAUDE.md's session rule handles prefixing at the point of execution. This is the strongest rejected option and it deserves the space: it closes the consumer gap and all eight output defects at once, because the executing agent knows what it is about to parse and the skill does not have to predict it. It also removes clause 3 entirely — there is nothing to except. Rejected because the prefix is lost wherever an agent copies a command literally, which is the common case for a fenced procedure block and the whole reason dispatch tables exist. The org convention's value is that the prefix is already there in the text the agent lifts; a rule that relies on the agent remembering to add it is the rule that produced the drift in the first place. Worth revisiting if rtk ever ships a shell shim, which would make the execution layer transparent and this trade different.

Keep the two-way split and fix the eight sites by hand. Rejected: the split has no vocabulary for "this command is executable, instructed, and must still be bare", so the eight sites would be unexplained exceptions and the next sweep re-prefixes them. That is the failure this ADR exists to stop, not a smaller version of it.

Gate clauses 2 and 3 as well. Rejected as undecidable. "Run git switch <branch>" and "git switch refuses rather than clobbering local edits" are the same token sequence; separating them is a judgement about what a sentence is doing. A gate that guessed would fire on correct content, and a gate that fires on correct content gets added to SKIP, which disarms clause 1 along with it.

The boundary the rule does not decide

Two shapes in the corpus resisted the two-way split. The three-clause rule resolves one and does not resolve the other; both are recorded so an author meeting a third one knows which kind it is.

git-worktrees/SKILL.md's tracking row carries both spellings in one Run cell — rtk git worktree add --track -b <branch> <path> <remote>/<branch> — always correct. git worktree add <path> <branch> expands to exactly this. Resolved: the clauses apply per mention, not per row, per cell or per file. The first is the instruction (clause 1), the second names what the first expands to (clause 2), and one table cell can hold one of each. The rule needed no change; the gate did, and it checks only a Run cell's opening span for exactly this reason.

git-submodules/references/setup-and-update.md:80 has a git command inside a quoted argument to another command — rtk git submodule foreach 'git pull origin main || :'. Not resolved: all three clauses describe a command the reading agent executes, and the inner git pull is not one. It is the literal text of an argument that git submodule foreach hands to a subshell running inside each submodule's own working tree, where the local convention does not reach. The file already gets this right and already justifies it in prose two lines below ("the git calls in it are the submodule's own — that is the one place a bare git is correct"). An author meeting this shape should do the same: leave the inner command bare and justify it inline. It is deliberately not promoted to a fourth clause on one instance. The gate does not decide it either — it happens to pass this line, because the segment containing the inner command begins with rtk, and that is an accident of the split rather than an understanding of quoting.

Consequences

Eleven sites in plugins/git/.apm/skills/** revert to bare git under clause 3, each carrying a short inline reason. Eight sites across plugins/gitea/.apm/skills/** and plugins/gitea/.apm/agents/gitea-orchestrate.agent.md gain the prefix under clause 1, and one in pc-run/SKILL.md that the #130 sweep's grep missed because the backtick opens with SKIP= rather than git . plugins/git/README.md's Conventions section becomes a pointer here, minus a paragraph that was false about the gitea-* skills and a citation to a file that does not carry the rule. A check-rtk-prefix pre-commit hook and tests/test-check-rtk-prefix.sh land with it; the test runs the gate against the pre-sweep corpus on main and asserts it fails there, because a gate that only passes on the fixed tree proves nothing about the drift it was written for.