Repository navigation
v0.65.0
Added
-
A slim
releasebranch ships to the Anthropic plugin directory instead of the full tree (#2705), ported from claude-remember#851. The directory had scanned the default branch and stopped withVALIDATION_INCOMPLETE/VALIDATION_NOT_EVALUATED-- both policy holds -- on this repository's 1,726-file, 24.6 MB tree.A tag push now runs
.github/workflows/release-branch.yml: it builds a tree withtests/,docs/,.github/,.claude/,.oss/and the other dev-only paths removed (.github/scripts/build_release_tree.py, driven by.github/release-branch.json), checks it against the directory's pre-submission checklist (.github/scripts/check_release_tree.py), smoke-tests every hook in isolation (.github/scripts/smoke_release_tree.py), and only then pushes one commit on top ofrelease. Built fromv0.64.0: 336 files, 6.5 MB, down from 1,726 files and 24.6 MB._supertool.pycannot be denied -- it is the tool itself -- so it is a named, issue-referenced exception inrelease-branch.json(tracked separately in #2706):check_release_tree.pyreports it as aREVIEWline rather than failing the build. Any other file over the 256 KiB limit still fails. Seedocs/releasing.mdfor the full sequence.
Changed
-
Moved the vim implementation out of
_supertool.pyinto a new in-process
module,_supertool_vim.py, imported lazily on the firstvimcall rather
than paid by every invocation._supertool.pykeeps a thinop_vimstub
that does the lazy import and delegates; dispatch, validators and rollback
are unchanged. Step 1 of the_supertool.pysize split (#2706) that keeps
the plugin directory's 256 KiB per-file rule enforceable without a
standing exception for the tool's own source. -
Moved
introduction/output-format/version/help/ops/ops:full/
ops:roster/ops-compact/registryout of_supertool.pyinto a new
_supertool_catalog.py(~68 KiB), another slice of #2706 (shrinking
_supertool.pyunder the plugin directory's 256 KiB per-file limit). Unlike
_supertool_doctor.py/_supertool_gc.py, this part is loaded the same way
as_supertool_guard.py--_load_part("_supertool_catalog"), an
exec(code, globals())at the exact source position the code used to
occupy, not a realimport-- because the moved code carries 8+ live
monkeypatch.setattr(supertool, ...)sites on_shipped_configand its
_SHIPPED_CONFIG/_SHIPPED_CONFIG_DIR/_SHIPPED_CONFIG_STATEglobals
across five test files; a real module split would give this code its own
namespace and break every one of those patches. The moved code sat in two
spans interleaved withop_doctor/op_init(already split out by #2714);
those two stubs are untouched and still sit exactly where they were, between
the two spans this file now holds concatenated, loaded by one_load_part
call at the first span's former position. -
The config/presets-merge/env-knobs/exclude-paths/gitignore/rtk/display region
of_supertool.py(#2706) is now a_load_part()-loaded part too, split out
verbatim into_supertool_config.pybeside it --.supertool.json
discovery/merge, every env-knob reader, the exclude-paths matcher, gitignore
integration, rtk (Rust Token Killer) detection, and the display region
(plain_mode,mark, the shared line-break helpers). No behavior change:
every function keeps__globals__ is _supertool.__dict__, so the 46+
existingmonkeypatch.setattr(supertool, ...)sites into this region keep
reaching the code they patch. -
Split
dispatch,dispatch_verdict,_dispatch_impl, the per-op
accumulators andlog_callout of_supertool.pyinto_supertool_dispatch.py
(~95 KiB), one slice of #2706 (shrinking_supertool.pyunder the plugin
directory's 256 KiB per-file limit). Loaded with_load_part("_supertool_dispatch")
at the exact source position the code used to occupy, exec'd into
_supertool.py's ownglobals()rather than imported as a real module --
the same mechanism_supertool_guard.pyalready uses -- so every existing
monkeypatch.setattr(supertool, "dispatch", ...)(18 sites across 6 test
files) andmonkeypatch.setattr(supertool, "log_call", ...)(24 sites
across 10 test files) keeps reaching the code it patches, untouched.
_dispatch_impl'sglobal _DEFER_FORMATTERS, _FORMAT_QUEUE, _VALIDATOR_DEFER_QUEUE, _VALIDATOR_DEFER_SEENrebind is a non-issue for
the same reason: every part shares oneglobals()dict with the core. -
Split
doctor/doctor:probe,init/init:writeandgcout of
_supertool.pyinto_supertool_doctor.py(~50 KiB) and_supertool_gc.py
(~8.6 KiB), one slice of #2706 (shrinking_supertool.pyunder the plugin
directory's 256 KiB per-file limit).op_doctor/op_init/op_gcstay in
_supertool.pyas thin stubs that import the new modules lazily, so
dispatch is unchanged; every cross-module call back into_supertool.py
goes through a qualified_supertool.NAMEform resolved at call time, so a
test'smonkeypatch.setattr(supertool, ...)on a helper that stayed behind
is still seen._cache_root,_maybe_auto_gcand itsatexit.register
call stay in_supertool.py-- moving an import-time atexit registration
into a lazily-imported module would change both when it fires and its
position in the process's atexit LIFO order. 11 tests across 8 files
migrated their direct calls/monkeypatches of the moved private helpers
(_doctor_*,_init_*,_gc_sweep_all,_gc_sweep_kind,
_GC_DEFAULT_RETENTION_DAYS) to reference the new modules directly, rather
than re-exporting them back into_supertool.pyto keep an old patch
green while it no longer reaches the code. -
Wave 1 of the
_supertool.pysize split (#2706): the write-op region
(op_replace,op_edit,op_json_set,op_paste,op_append,
op_replace_lines,_atomic_write,_result_line, and the
rollback/near-miss-diagnostic helpers they share) moved verbatim into
_supertool_edit.py, loaded by_load_part()at the position it used to
occupy. Every existingmonkeypatch.setattr(supertool, ...)patch site
(55 over this region, including 44 on_branch_reading) keeps reaching
the code it patches. -
Part of the
_supertool.pysize split (#2706): op names, synonyms,
near-miss suggestions, safety classification,_split_arg, and the
colon/extra-token refusals move out of_supertool.pyinto
_supertool_parse.py, a_load_part()-loaded part (two contiguous spans
of the pre-split file, concatenated into one, loaded with a single call).
monkeypatch.setattr(supertool, ...)patch sites keep reaching the moved
code (0 test monkeypatch sites besides_batch_prevalidation_refusal, all
4 of them pinned).tests/test_grep_around_comment_1842.pyis widened to
search_core_sources.core_source_text()instead of_supertool.pyalone,
since the comment it pins moved with_PATH_ARG_POSITIONS. -
Lane 0 of the
_supertool.pysize split (#2706): a_load_part()loader
thatexecs a_supertool_<x>.pypart file into_supertool.py's own
namespace at the exact position its code used to occupy, keeping the #931
bytecode cache and every existingmonkeypatch.setattr(supertool, ...)
patch site intact. The raw-command guard is the pilot part, moved verbatim
into_supertool_guard.py(0 test patch sites).tests/_core_sources.py
derives the core-plus-parts file list from the_load_part(...)call
sites so a source-scanning test widened to use it tracks the split instead
of rotting with it. -
#2706 wave 1: the TOML mini-parser and
@file/payload routing
(_detect_payload_formatthrough_at_file_to_parts_generic, including
op_payload_lint) moved out of_supertool.pyinto_supertool_payload.py,
a_load_part()-loaded part sharing_supertool.py's own namespace, the
same mechanism lane 0 built for the raw-command guard. No behaviour change. -
Part of the
_supertool.pysize split (#2706): the custom-op / preset
resolution region --_safe_path, containment,_expand_env,
_resolve_custom_op, alias resolution and their direct helpers -- moved
verbatim into_supertool_presets.py, loaded the same way lane 0's
_supertool_guard.pyis, with_load_part("_supertool_presets")at the
exact position the code used to occupy. No behaviour change; every existing
monkeypatch.setattr(supertool, "_safe_path", ...)site keeps reaching the
same code, since the part still runs with_supertool.py's ownglobals(). -
Part of the
_supertool.pysize split (#2706):render_file,op_read,
op_glob,op_ls,op_head,op_tail,op_wc,op_stat,op_treeand
op_map(the tree-sitter/ctags symbol map) moved verbatim into a new
_load_part-loaded part,_supertool_read.py;op_grep,op_around,
op_between_symbol,op_between_patternand the pattern gate moved into
_supertool_grep.py. The two parts' functions interleaved in the original
source across five read spans and two grep spans rather than one contiguous
chunk each (unlike lane 0's guard);op_checkandop_diff, physically
sitting inside that span but unrelated to either op family, were relocated a
few hundred lines within_supertool.pyitself rather than smuggled into
either part. No behaviour changed and no test needed updating — both parts
share_supertool.py's own globals() exactly as lane 0's loader already
established. -
Split validators, formatters, advice generation,
_run_with_validators,
op_validate*andop_format*out of_supertool.pyinto
_supertool_validate.py(~144 KiB of moved code, ~148 KiB on disk with its
header), one slice of #2706 (shrinking_supertool.pyunder the plugin
directory's 256 KiB per-file limit). Loaded via a single
_load_part("_supertool_validate")call -- a plainexec(code, globals())at the exact source position the moved code used to occupy,
not a realimport-- so every function still has__globals__ is _supertool.__dict__and every existingmonkeypatch.setattr(supertool, ...)on a validator/formatter name keeps reaching the code it patches.
_FORMAT_QUEUE,_DEFER_FORMATTERS,_VALIDATOR_DEFER_QUEUEand
_VALIDATOR_DEFER_SEENmove with it; theglobalrebindings in
_dispatch_impl/mainkeep working under the shared namespace. Five
physical spans, not three or one:_flat_cell, and separately
_flat_field/_flat_keys(plus the_UNTRUSTED_FLATstate the latter
rebinds), sit inside the byte range this part occupied but are generic
string-flattening helpers used all over core's own dispatch and payload
code -- moving them would have cost ~55# noqa: F821call sites in
unrelated core code instead of the ~20 genuinely tied to this part's own
dispatch points, so they stay in_supertool.pyand the part is loaded
from five source regions through the one_load_partcall at the first
region's position. The notifier subsystem (_applicable_notifiers,
_run_notifiers,_first_changed_line,_sweep_old_notifier_temp_files
and its import-time call/atexitregistration) stays in_supertool.py
for the same kind of reason -- a third caller, dispatch-level read-op
notification, has no validator/formatter involvement of its own. One
test,test_format_staged_skips_symlinks, grepped
Path(supertool.__file__).read_text()for an implementation detail that
moved with this split; it now reads_core_sources.core_source_text()
(core plus every loaded part) instead of assuming everything still lives
in one file. Three deadimport fnmatchlines surfaced inside the moved
code once it became a standalone file subject to whole-file lint (the
tree-wide F401 ignore covers only files with pre-existing history) and
were removed.
Fixed
-
git-commit:@-'smessage = @restheader now refuses a message whose last
line looks like a swallowedpaths = [...]payload field, when no paths
were given any other way. Previously the tail of a@restmessage was
taken verbatim with no scanning for a later sibling field: apaths = [...]
line written after the message text became the last line of the commit
body, PATHS stayed empty, and -- with foreign changes already staged --
the op committed the index exactly as it stood, under a mangled message,
and reported PASS (#2667). -
FIELD = @restpayload tails no longer carry their heredoc's own closing
newline intoold/new(edit/replace) orpattern(grepand its
siblings) -- aneditused to write a stray blank line and agrep
pattern used to match every line in the file.content-shaped fields
(paste/append) still get the tail byte for byte, since a full-file
write may legitimately end on a blank line (#2668). -
git-commit's leaked-key guard (#2656) now matches any TOML-legal spacing
and leading indentation around a leakedpaths =/message =key, not
only the canonicalpaths = [/message =byte-for-byte prefix.
Previouslypaths=[...],paths = [...]and an indentedpaths = [...]
all slipped past the guard and committed under the mangled subject (#2669). -
gitlab-mr's poller re-asks the approvals endpoint while a stored
approvedis stillFalse, not only on the one tick that exits
not_approved(#2670).
Previously, once a higher-priority blocker (a draft, an open thread, a
failing pipeline) took thedetailed_merge_statusfield on the same tick
the MR leftnot_approved, the one confirm request answeredFalseand
was never asked again -- the approval rule could later be satisfied while
the MR was still draft, or after it wentmergeable, and no tick would
ever re-confirm it. The cost stays bounded to the same "once per red
streak, not once per tick" budget #2645 already pays. -
git-push's open-MR and dead-MR lookups (query_open_mr_result,
query_last_mr_result) now filter out cross-repository matches. Both
probedgh pr list --head BRANCH, which matches the head branch name in
ANY repository -- a stranger's fork PR with the same branch name could be
rendered as this branch's own open or dead MR. Both gh probes now request
isCrossRepositoryand read past a fork's row to the first same-repo
match, raising--limitso a same-repo match is not dropped when it sits
behind a fork match (#2672). -
git-push's dead-MR line no longer renders a tracker row's missing
statefield as an ordinaryclosedMR._gh_last_fieldsand
_glab_last_fieldspass a row'sstatethrough untouched, so a row with
nostatekey gavestate: None, and_dead_mr_linesfolded that into
(mr.get("state") or "closed"), silently equating "unanswered" with
"closed". It now rendersstate unknown (the tracker row carried none)
instead (#2673). -
git-commit's post-commit "Next:" hint no longer silently recommends a
plain push when the branch's own MR/PR was already merged or closed.
_existing_mr_for_branchusedquery_open_mr, which discards WHY nothing
is open, so a dead request read exactly like a branch that never had one.
When the open lookup finds nothing, the hint now asksquery_last_mr_result
(the same #2657 lookupgit-pushuses) and, when the branch's most recent
request was merged or closed, names it instead of pointing at a plain push
(#2674). -
channel:health's own description now names all six states, including
BOUND, UNPROVEN(exit 8), added by #2658
(#2675).
It previously still said "five states" and its first-line branching sentence
omitted the sixth, so a caller who branched the way the description told
them to had no key for it. -
gh-branch's poller no longer re-emitswent_greenfor an unchanged
sha after an intermediateunknownreading
(#2676).
UNKNOWNin any form -- an unreconciled tally, or the #2436/#2537 grace
window reaching its threshold -- is not an answer this poller settled on:
the stored state now keeps the last real answer (answer_state/
answer_sha) separately from the last raw read, and a recovery back to
that same answer on the same sha no longer fires. A genuine transition
(GREEN -> FAILED -> GREEN, a re-run) still fires every time. -
The guard-wrapper test spawns now scale their outer timeout on Windows instead of sitting at a flat 120s
(#2687).
test_the_wrapper_denies_a_replaced_commandtimed out at exactly 120s on a
windows-latestrunner in PR #2683's CI, while that same job's own
duration report already named it the slowest test in the whole suite --
the margin was thin even on a run that did not hit the wall. Same shape as
#658/#702: the bash wrapper (hooks/pre-bash-guard.shunderbash.EXE)
has no internal timeout of its own fortests/_adapter_budget.py's
inner_budget()to read, so the newwrapper_budget()applies the same
Windows multiplier directly to a flat 120s base instead of deriving one.
Seven spawns across six files moved off the hardcoded literal:
test_guard_interpreter_ladder_1390.py(both sites, including the
module-level gate that decides whether the rest of the file runs at all),
test_guard_hook_cost_1377.py,test_guard_partial_envelope_1377.py,
test_guard_envelope_serialisation_1613.py,
test_guard_reserialised_envelope_1625.pyand
test_hook_interpreter_windows_1401_1402.py. -
git-push's open-MR and dead-MR lookups (query_open_mr_result,
query_last_mr_result) no longer report "no MR" when agh pr listfetch
came back full of forks exactly at its own--limit. A same-repo match
could be sitting behind the last fork row, unseen -- the fetch is now
reported as unanswered/unknown in that case, same as any other lookup that
did not fully run, instead of the stated absence a branch with zero rows
also produces (#2691, found during #2672's own self-review). -
watchesno longer keeps a pollerSTALEforever after a confirmed
:reload, nor recommends:reloadagain as the fix once it already ran.
_reload_pollernow recordsreloaded_atand a fresh fingerprint on the
state file when the reload succeeds;version_state_ofgets a fourth
state,RELOADED, for a process whosepoller.pyis current as of that
reload but whosedispatcher.py/transport.pyare still the fork-time
copies. The footer for aRELOADEDrow now namesunwatch+watchas
the only way to a fully current process, instead of the:reloadthat
cannot reach it -- and that remedy now actually works: a fresh fork clears
any earlier lifetime'sreloaded_at, so a genuinely current process no
longer inherits a stale reload marker from the poller it replaced (#2694). -
watchesno longer claims thatunwatchthenwatchforks a fresh poller
with an empty state that re-announces everything as new on its first tick.
cmd_unwatchnever cleared the state file, so the fresh poller actually
resumes the priorsource_statefrom disk and stays quiet unless the world
changed while it was down -- the text was wrong, not the code. Reworded the
cmd_reloaddocstring and note, thecmd_listSTALE and RELOADED footers,
adispatcher.pymodule comment, anddocs/presets/watch.mdto say so
(#2697).
Security
-
gh-issue-comment's:edit=COMMENT_IDnow verifies the comment belongs to
the issue or PR named before PATCHing it. Previously the PATCH was built
fromCOMMENT_IDalone -- a mistyped or stale id could silently overwrite
a comment on an unrelated issue or PR, while the[result]line still
reported success against the issue number the caller asked for (#2665). -
The bluesky/devto/hashnode/youtube injection-scan warning (
⚠ POSSIBLE INJECTION — ...) only flattened\rand\nout of a detected hit before
printing it, so U+2028, U+2029, VT, FF, FS, GS, RS and NEL -- every other
separatorstr.splitlines()treats as a line boundary -- still let attacker
text land at column 0 of what looks like the tool's own line. Routed through
_untrusted.flat(), which handles the full set, in all four presets'
_sanitize.py(kept byte-identical) and in each preset's ownread.py,
which builds the same warning line independently ofwrap(). The
free-text fields rendered right next to that warning -- a comment's
author/text(youtube'sread/status_since), a reply's own text
(bluesky), a comment body (devto/hashnode), and youtube's own video
title/channelTitle-- carried the same\n-only gap and are now
routed throughflat()too (#2671). -
Same #2671 gap, at the sites that fix deliberately left out (#2680 said so
in its own commit message):list.py/search.py/status_since.pyacross
bluesky, devto, hashnode and youtube, plus the bluesky-engagement watch
poller, each reimplemented the pre-#2671 bare\n-only replace instead of
calling_untrusted.flat(), so a post/comment/notification text carrying
U+2028, U+2029, VT, FF, FS, GS, RS or NEL could still put attacker text at
column 0 of a forged output row. Routed throughflat()at all eight
sites (#2681). -
claude-log's session-transcript renderer (tail.py's[result]/[result/ERR]
lines,list.py's excerpt) now flattens every line-boundary separator
str.splitlines()recognises, not just bare\r/\n. A tool_result part
can carry text a stranger chose -- a WebFetch body, a bash stdout capture --
even though the.jsonltranscript file itself is local on disk, and one of
the eight separators the old bare.replace()idiom left untouched
(U+2028, U+2029, VT, FF, FS, GS, RS, NEL) could still forge a line at
column 0 of a rendered transcript row. Same defect class, and same fix
(_untrusted.flat()), as #2671/#2680/#2681 -- #2681 itself deferred this
file alongsidepresets/xml/_common.pyas rendering "local files", but the
rationale did not hold here (#2685). -
No test caught the bare
.replace("\r", ...)/.replace("\n", ...)idiom
recurring at a new untrusted-render call site -- it was fixed three separate
times by hand (#2671, #2680, #2681), each time in a self-review, because
that idiom only strips CR/LF and leaves U+2028, U+2029, VT, FF, FS, GS, RS
and NEL -- every other separatorstr.splitlines()treats as a line
boundary -- able to put attacker-chosen text at column 0 of a forged output
row.tests/test_bare_separator_replace_2686.pyis a repo-wide AST scan
for the literal idiom itself across every preset that renders remote or
tracker text (bluesky, devto, hashnode, youtube, watch, github, gitlab,
git, notifiers), so a new instance is caught in CI instead of by hand
(#2686).