Skip to content

Setlist 1.0.8 (edition v1.6)

Choose a tag to compare

@AlexCiortan AlexCiortan released this 30 Jul 13:37
· 6 commits to main since this release

If you are on any earlier version, take this one. It closes fifteen ways past
the gates, and every one of them was present in 1.0.6 and 1.0.7 as well, so none
of this is a regression you might have escaped by waiting.

They were a small number of causes rather than fifteen unrelated bugs, which is
why they could be fixed together.

A ref is identified by the commit it names, not by how it is spelled. The
close gate used to recognise a spec branch by stripping literal prefixes from
the merge argument, so every other spelling of the same commit read as an
ordinary sync merge and skipped every close check: heads/spec/0001-x,
remotes/origin/spec/0001-x, another remote's name, a TAG pointing at the
branch, an alias branch, a raw commit id. Worse, for origin/spec/0001-x the
strip produced the LOCAL branch name, so a compliant local branch could
green-light a non-compliant remote one. The gate now resolves the argument to a
commit and asks git which refs point at it.

Where several refs sit on one commit, a spec ref wins over a chore ref, and two
DIFFERENT specs on one commit are refused outright with [CG-AMBIGUOUS-SPEC]
rather than resolved by sort order: the gate cannot know whose Closing report it
would be judging. A branch, its remote-tracking copy and a tag are one spec
rather than three, so ordinary closes are unaffected.

Shell grammar no longer hides the command, and neither does punctuation.
Wrapping a merge in { ...; }, if ... then, for ... do, or prefixing it
with ! moved the git verb out of the position the gate inspects. So did a
leading redirection (>/dev/null git merge ...), and so did the absolute-path
spelling of a wrapper (/usr/bin/env git merge ..., which is what a shebang
produces). All of them really did land a merge on the trunk. Reserved words,
redirections and path-qualified wrappers are stripped at the head of a segment
now, in both gates.

Relatedly, the & inside 2>&1 was read as a command separator, cutting
>/tmp/out 2>&1 git merge ... into two fragments, neither of which began with
git. File-descriptor duplication is recognised before the line is split.

Quoted text is opaque. The close gate deleted quote characters and kept
their contents, so a commit message that merely mentioned a checkout could
retarget the branch the gate believed it was standing on. The commit gate did
the opposite, deleting whole quoted spans, so quoting the word git deleted the
word it matches on. A quoted span of one shell-safe word is now kept as that
word, and anything longer becomes a single inert token that cannot supply a
command or split a line. Escaped quotes are understood, so the ordinary way of
writing a contraction no longer confuses the scan, and a command whose quotes
never close is REFUSED rather than half-read.

A checkout is conditional. The gate modelled every git checkout as having
succeeded, but a checkout aborts whenever local changes would be overwritten,
and after it aborts you are still on the trunk. So git checkout other; git merge --no-ff spec/0001-x ran the merge on the trunk with every close check skipped.
Only && implies the previous command succeeded, so only && is trusted to
have moved the branch; after any other separator the branch is treated as
unknown and the merge is refused rather than waved through.

Every index-writing verb counts, and every commit in the line is judged. A
command that writes the index and commits in one step is denied, because the
hook decides before the command runs and only already-staged content is
scannable. That set is the full one now (stash pop, restore --staged,
reset, a pathspec checkout, the plumbing verbs), not just add, rm and
mv. And a compound carrying two commits is judged on both: appending a second
plain git commit used to disarm the check on the first.

A branch must write its own Closing report. A branch cut after a spec closed
inherits that spec's artifacts entire, so reusing a closed number carried
unreviewed work onto the trunk against somebody else's evidence. The gate now
requires the branch to have modified its own spec file. Writing the Closing
report into the spec is what closing a spec IS, so an honest close always
satisfies it.

Configuration is read for meaning, not shape. A .claude/sdd.json that is
valid JSON but not a single object (a top-level array, or two documents in one
file, which a half-merged config produces) silently disabled the trunk rule in
both the close gate and the scope hook. So did a "trunk" that is present but
empty, or an array, or an object: jq's // falls back only on null, so an empty
string passed straight through and every branch comparison failed. Both are
refused now. A trunk key that is simply absent still defaults, because not
declaring one is ordinary.

The upgrade path no longer certifies a disarmed instance. The check for "are
the gates wired" matched the hook filename anywhere in your settings, so gates
moved to the wrong hook event, given a matcher that never names the tool they
govern, replaced by a local fork, or merely MENTIONED inside an unrelated
command all certified as a complete refresh. It checks the event, the matcher,
and that the entry actually executes one of the four stamped files.

Every denial carries a code, like [CG-NO-CLOSING-REPORT] or
[CM-INDEX-COMPOUND]. Nothing about the messages changed for you; it means a
reworded explanation can never silently break a test that was matching its
prose.

Known open issues in this release

Four things about this release are disclosed here rather than left for a reader
to discover, because each is something a careful adopter would want to know.

1. This release was not reviewed by an adversarial pass against its exact
bytes.
Five adversarial reviews ran during 1.0.8's development, against five
earlier trees. The fifth found seven ways past the gates, all seven were fixed,
and those fixes changed the bytes the fifth review had read. Re-reviewing would
have changed the bytes again, which is a loop with no exit, so it was ended by an
explicit decision. What stands behind these bytes instead is the full mechanical
evidence set: the hook suite (451 assertions), an outcome oracle that runs
generated commands for real and compares the gate's verdict to what actually
happened in the repository (zero misses across all three gates), mutation testing
that reintroduces each previously fixed defect and confirms the suite still
catches it (8 of 8), a replay of every payload from all five reviews, the hook
smoke tests, and the upgrade-seam check. That proves every KNOWN way past the
gates is closed. It proves nothing about ways nobody has thought of yet.

2. A stale remote-tracking ref can deny an ordinary merge. If you rename a
spec branch (git branch -m spec/0004-thing feature/thing) while a
remote-tracking ref for the old name still exists, the close gate denies
git merge --no-ff feature/thing with CG-SPEC-MISSING. It is a false denial
rather than a way past the gate, so it is disclosed rather than fixed in this
release. The remedy is one command:

git update-ref -d refs/remotes/origin/spec/0004-thing

3. Findings from the last two reviews remain open and are not secret. They
are MAJOR and MINOR rather than blocking: none of them puts unreviewed code on
the trunk. Several are defects in the project's own checking tools rather than in
the gates you install.

4. The gates need a WORKING jq, not merely an installed one. A jq that
exists but fails to run (a broken dynamic link, a killed process, a wrong
architecture) used to make two gates allow silently. That is fixed in this
release: all four hooks now run jq rather than just locating it, and each
distinguishes "missing", "present but not runnable" and "could not parse this
input" so the message names what to fix.

The standing framing has not changed and is worth repeating: these hooks are a
backstop against ordinary mistakes, not a sandbox. Anything that can run a shell
can reach the trunk, and the gates have never claimed otherwise. A merge
performed in a forge's web UI is not reached by them at all. The trunk audit
(scripts/trunk-audit.sh, opt-in) reads history rather than commands and is the
designed catch for that whole family.

One honest caveat about the fixes above. The gates are clean against every
spelling a large generated corpus can produce, which is a statement about the
corpus rather than a proof about the gates. Shell is not a regular language, and
three of the four most recent releases found their bypass in a dimension the
corpus did not enumerate. This release added a new KIND of dimension to it, the
state of the repository rather than the shape of the command, which is where the
worst of this round was hiding.

If you already have a project running Setlist

Run /setlist:upgrade once after updating the plugin. This release changes hook
bytes but not the settings wiring, so the refresh is hook files only.

/plugin marketplace update setlist

If a Claude Code session was open when you ran that update, restart it before
upgrading.