Commit Graph

2684 Commits

Author SHA1 Message Date
Teknium
519a13aaa6 ci: Desktop session open/resume changes run the core E2E
Every core spec drives open, resume and switch, yet changes under
apps/desktop/src/app/session/ skipped the suite on the PR; #132017
changed resume that way and main went red on remote-secondary.
2026-10-05 23:36:55 -07:00
liuhao1024
1d6b2786e2 fix(build): refuse a completed-install receipt whose packages lost their manifests
The reuse receipt check in node-deps.mjs accepted a tree where every locked
directory existed but package contents had been stripped (partial cleanup or
snapshot restore). npm trusts the hidden lockfile and never repairs such
entries, so every 'hermes update' skipped npm ci, the TUI build died on the
missing esbuild manifest, and the stale receipt kept the loop closed (#128935).
Probe each locked entry's package.json — the same file product builders
resolve tools through — so a gutted tree falls back to a real npm ci.
2026-10-05 22:09:20 -07:00
Teknium
c99299d746 feat(telemetry): memory and compression rows say why they failed or were skipped
Fleet telemetry shows built-in memory writes failing 34-47% and automatic
compression failing ~15%, and several merged fixes each target one cause
(#133038 for memory, #133054/#133040/#133051/#133032 for compression). Both
metrics only carried outcome, so the fleet could not tell which cause a fix
moved.

- hermes.memory.op.count and hermes.compression.count gain one closed-enum
  dimension, failure_class; `none` on success.
- Memory: every refusal/failure return in tools/memory_tool.py and
  tools/memory_tool_store.py names its class (no_match, ambiguous,
  over_budget, would_empty, drift, read_failed, retry_cap, scan_blocked,
  missing_content, missing_old_text, invalid_args, stale_entry, disabled,
  staged, gate_refused) through a context variable; the result the model
  sees is unchanged. Plugin providers report provider_error / exception.
- Compression: the attempt log's own failure_class, collapsed onto the
  compressor's closed names (exception:<Type> / rollback:<Type> keep only the
  prefix, anything else reads other, a classless failure unknown). Skipped
  rows carry the skip reason, and the skip set is now defined once in the
  contract.
- Package schema v4 / event schema v4; v3 field sets of both metrics stay
  valid so pending rows drain. Doc table rows and the consent copy (CLI
  setup and every Desktop locale's collectedActivity) name the new reason.
2026-10-05 22:07:41 -07:00
Teknium
1c97b57a93 fix(update): convert a treeless checkout before the dependency work, and on installer re-runs
A Desktop built before the blobless fix keeps walking history during
`hermes update`. On a treeless (tree:0) checkout every walk downloads trees
again, and #129514 filled a 434 GB disk during the minutes of dependency work.
The one-time conversion (#133016) ran at the very end of the update, after that
window. Both update hand-offs (update_completion._prepare and an older
updater's _update_takeover.prepare) now run it first, in the bootstrap
interpreter, and the late call is gone.

Re-running install.sh / install.ps1 over a treeless checkout now converts it
too. The filter is recorded only after the refetch succeeds, so a failure is
retried by the next update instead of leaving a blob:none filter over a
history that has no trees.

The pack cleanup also sweeps tmp_* transfer files that killed fetches left
behind. They are more than an hour old, a storm leaves gigabytes of them,
and git itself only prunes them after two weeks. The docs gain the recovery
steps for a disk that the storm already filled.

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: Yuan Li <dskwelmcy@163.com>
2026-10-05 17:32:54 -07:00
Teknium
d2c05341fc fix(skills): install skills.sh skills from a lone generic dir under their slug; resolve index paths before the token expires
skills.sh rows in the hermes-index carry the skills.sh slug as `path` and,
since mid-September, never a `resolved_github_id`: the scheduled index build
resolves paths AFTER the ClawHub walk, ~80 min into the job, when the 1 h
GitHub App token has expired. Every tree fetch 401s and the log says
"Resolved 0/20000 paths" (last good run 2026-09-15: 17917/20000). Installs
then work only where the repo dir happens to equal the slug, and everything
else is reported as a "stale index entry".

- build_skills_index: resolve skills.sh paths straight after the skills.sh
  crawl, while the token is fresh.
- HermesIndexSource.fetch: when the indexed candidates miss, locate the skill
  in the repo tree (slug-named dir, a lone SKILL.md in a generic dir such as
  `skills/`, or the repo root) and install it under the slug, not as
  `skills/`.
- _find_skill_in_repo_tree: a tree with exactly one non-root SKILL.md
  resolves to it when no dir matches the slug (fails closed on 2+).
- GitHubAuth: once the credential chain came up empty, stay anonymous for the
  instance instead of re-running `gh auth token` before every API call; with
  gh installed but not logged in that cost up to 5 s per request and pushed an
  anonymous install past four minutes.

Fixes #130129
Based on #130135 by @dskwe.
2026-10-05 15:56:28 -07:00
Brooklyn Nicholson
f0b9649032 fix(build): wait out Windows scanner holds when publishing products
publishDirectory moved a finished product into place with one renameSync.
On Windows an antivirus scanner opens every freshly written file, so the
rename onto build/native-deps (right after hud-modifier-monitor.exe is
compiled) or hermes_cli/web_dist failed EPERM and threw away a build that
took minutes.

retryHeld retries EPERM/EACCES/EBUSY for about 3s and raises every other
code at once. All three publication renames use it, as does the HUD
helper's staging rename. The previous product's backup now sits beside
the output instead of inside withProduct's scratch tree, so a rollback
that also fails no longer has its only copy deleted by scratch cleanup;
the error names where it is.

Co-authored-by: dskwe <6286305+dskwe@users.noreply.github.com>
Co-authored-by: juliorobertoalvarez <39749815+juliorobertoalvarez@users.noreply.github.com>
Co-authored-by: bighamx <10262931+bighamx@users.noreply.github.com>
Co-authored-by: lzmuhioin <1814826+lzmuhioin@users.noreply.github.com>
2026-10-05 17:08:32 -05:00
Teknium
1d9bb51f6b fix(install): clone blobless, convert treeless checkouts once
Installers have cloned with --filter=tree:0 since c3e9ac87a7. A treeless
clone keeps commits only, and git requests a missing tree without telling
the server which trees it already has, so every checkout and every
path-filtered history walk re-downloads complete directory snapshots
(~0.58 MB per commit for this repo). GitHub's own guidance is that
treeless clones are for throwaway CI builds, not long-lived checkouts;
the blobless layout keeps every commit and tree local and fetches only
file contents on demand.

- install.sh / install.ps1 clone --filter=blob:none (same release tags,
  --commit pins and ancestor checks the treeless switch was made for).
- hermes update (post-update step) converts an existing tree:0 checkout once with
  `git fetch --refetch --filter=blob:none origin` (~120 MB, ~8 s
  from GitHub) and records the new filter only after it succeeds, so a
  failed refetch retries next update.
- The shallow-heal conversion unshallows as blob:none instead of tree:0.
- checkout_contains asks git with GIT_NO_LAZY_FETCH: a recorded SHA the
  checkout lacks is "not contained", never a download of that commit's
  whole history.
2026-10-05 01:30:17 -07:00
Teknium
e1ec584b57 feat(ci): cap every AGENTS.md chain below the context budget it is truncated at
scripts/ci/check_agents_md_size.py (in scripts/check and lint.yml): root <= 12k, every
AGENTS.md plus its ancestors <= 30k (the joined root->cwd block on a 128k window, minus
the per-file labels), every area file <= _MAX_HINT_CHARS read from the source so the two
cannot drift. On origin/main it reports the root and all 11 root-to-area chains over.
2026-10-04 23:05:42 -07:00
Teknium
a4140eebb1 docs: root AGENTS.md becomes a hub; area rules move where they load
The root file is project context and was 38.7k chars, past the context-file budget
(20k floor, 30,720 on a 128k window), so every session lost its middle: routing table,
testing rules, code-shape rules. It now keeps the two invariants, a condensed rubric
(every rule kept), environment, structure, one-line rules that apply everywhere and the
routing table (11.8k).

Moved verbatim to where they load: Testing -> new tests/AGENTS.md; dependency pinning and
PM ownership -> new pm/AGENTS.md; machine facts -> new hermes_platform/AGENTS.md;
session-scoped tool availability -> tools/AGENTS.md; the full rubric with its real-close
examples -> developer-guide/contributing.md. Pointers in lint, checker messages, docs
and test comments name the new homes.
2026-10-04 23:05:42 -07:00
Teknium
bdef06de3b fix(code-health): a staged case collision is reported, not a crash
On a case-insensitive filesystem (Windows, macOS) `scripts/check --staged`/`--commit`
and the hooks' judge() aborted with "checkout-index -a failed: pkg/Notes.md already
exists" when the judged tree held two paths that differ only by case, so the
case-collision guard never got to report them (exit 2 instead of FAILED:
case-collisions). Both snapshots now check out with -f into their empty temp dir; the
guards read the private index, which keeps both paths. Found on the Windows runner by
the case-collision test once that test staged its premise as blobs.
2026-10-04 22:35:16 -07:00
Teknium
e8392f5b20 docs: code-health pre-push judges branch tips; moved-and-edited limits
The pre-push hook judges each pushed branch tip, as CI judges the PR as a whole, not
every intermediate commit; CONTRIBUTING.md and the hook comments said "each commit you
push". CONTRIBUTING.md also said a moved function you lightly edit keeps its cap; that
holds only when no function of that name exists at the destination, and a rename plus
an edit is new code. Both limits are now stated.
2026-10-04 22:35:16 -07:00
Teknium
6cc7d4a570 fix(code-health): TS recursive rename ignores nested-scope bindings
bodyWithoutSelf treated a declaration of the function's name at any depth as a rebind,
so a renamed recursive function containing an unchanged `.map((legacy) => legacy)` kept
its old self-call text, no longer matched its base body, and lost its cap ("CC 22 >
target 20 (new code)"). Only the function's own scope (parameters, a named function
expression's name, declarations outside nested functions and classes) now stops the
normalization, and nested functions that bind the name keep their own reads, as the
Python measurer already does.
2026-10-04 22:35:16 -07:00
Teknium
16fba82ce3 fix(code-health): global/nonlocal no longer hide an import alias
_Binder.visit_Global marked the name ambiguous in the module, so `global execute` in a
function hid `from subprocess import run as execute` and HX006 (missing timeout) never
fired on `execute(cmd)`; nonlocal did the same for an enclosing function's import. A
declaration selects the outer binding, it does not rebind it: declared names now resolve
in the scope they point at, and only an assignment (or import) through the declaration
rebinds that outer name.
2026-10-04 22:35:16 -07:00
Teknium
51483660d3 fix(code-health): git hooks work with MSYS path conversion off
The hooks' judge() built its private index and tree under `mktemp -d`, a POSIX /c/...
path in Git Bash, and passed it to native git.exe and python.exe through GIT_INDEX_FILE,
--prefix and the checker path. Hermes' Windows terminal exports MSYS_NO_PATHCONV=1 and
MSYS2_ARG_CONV_EXCL=*, so those paths arrived unconverted and the hook died
("Unable to create '/c/.../index.lock'") before any check ran. judge() now hands native
tools the `cygpath -m` spelling when cygpath exists; elsewhere nothing changes.
2026-10-04 22:35:16 -07:00
Teknium
791dc63c05 fix(code-health): CRLF blobs measure like LF on Windows
Measurer.measure wrote each blob's decoded text into the temp tree with write_text's
default newline translation. On Windows that turns a CRLF blob into CR CR LF, so ruff
counted lines differently from the AST and comment tables: hits moved to <module>,
their `# health: allow` waivers were missed, and valid code blocked. Writing with
newline="" keeps the blob's own line endings. The test forces the Windows write
translation so it also runs on Linux.
2026-10-04 22:35:16 -07:00
Teknium
0017264c02 fix(code-health): replay stops counting unmeasured PRs as clean
A manifest row whose range could not be built (base "", head "error: ...") or replayed
(an OID the clone does not have) was counted in the "N of M PRs had a blocking finding"
denominator with no findings, and the run exited 0, so a replay that measured nothing
read as evidence that a rule never fires. Such rows now carry an "error" in replay.jsonl,
stay out of the totals and the denominator, are listed by number, and fail the run.
2026-10-04 22:35:16 -07:00
Teknium
df81f543f8 fix(code-health): oversized-file fix text names the cheap way to comply
Files past 2,000 lines stay strictly no-growth (tests included). The message now says how
to offset growth in the same PR: move an existing function into a topical sibling (moved
code keeps its cap) or put new tests in a new test file. Probed on a 2,128-line file: plain
growth blocks; the same growth plus moving a CC-22 function out passes; a new test file
passes; growing the old test file blocks.
2026-10-04 21:29:39 -07:00
Teknium
00361a69ce fix(code-health): read replay manifests BOM-tolerantly 2026-10-04 21:29:39 -07:00
Teknium
baa07694a8 feat(code-health): heuristic rules warn until a frozen replay shows precision
HX001, HX003, HX010, HX012 and PS-P05/P06 now print as advisory findings instead of
blocking. Reviewers' samples put them between ~12% and ~60% precision, and the plan
ships weak rules as warnings first. Promote one by flipping it to blocking with replay
evidence in the PR. The ENFORCEMENT comment now says where the switch is read from.
Tests that used PS-P05 as a stand-in blocking violation now use a blind except.
2026-10-04 21:29:39 -07:00
Teknium
fccb46ac48 fix(code-health): measure every Python unit's real end line
compare.py's cross-file move matching slices the origin function by its end line;
without it the engine estimated the end from the line count. Pass ast's end_lineno through.
2026-10-04 21:29:39 -07:00
Teknium
7ceedd731a style(code-health): wrap the long lines added by the lane-C fixes
No behaviour change: line wrapping in the hook text, parse_switch docstring,
commit_or_none and the new tests; the test helpers lose an unused env parameter.
2026-10-04 21:29:39 -07:00
Teknium
f91534efb9 fix(code-health): each hooked candidate is judged by its own scripts/check
JoaoMarcos44 (inline scripts/check:40): the hooks began with
`[ -f "$repo/scripts/check" ] || exit 0`, before reading the pushed SHAs, so the
checked-out branch decided whether another revision was checked. Pushing an
invalid checker-containing branch while an older branch was checked out succeeded
without judging it.

Eligibility is now decided from the candidate tree: pre-push runs
`git cat-file -e <local_sha>:scripts/check` per pushed ref (a candidate that
predates the checker is skipped, deletions are still skipped), pre-commit does the
same for the index tree. An eligible candidate is checked out alone into a temp
dir (private index + checkout-index; the worktree, index and stash are untouched)
and judged by its own scripts/check with GIT_DIR pointing at the repo - with the
previous commit's snapshot-bound health job, implementation, job table, config and
policy all come from the judged artifact, whatever is checked out. pre-commit
exports an absolute GIT_INDEX_FILE because git may hand hooks a relative one.
2026-10-04 21:29:39 -07:00
Teknium
f472161acb fix(code-health): hooks prefer the repo virtualenv; scripts/check refuses Python < 3.11
unsupportedpastels M5: both hooks ran whatever python3 came first on PATH, but
scripts/check imports ruff_runner, which imports tomllib (3.11+, the project
minimum). With Ubuntu 22.04's python3 3.10 every hooked commit or push died with
"ModuleNotFoundError: No module named 'tomllib'".

The hooks now use the repo's virtualenv interpreter when there is one (.venv/ or
venv/, bin/python or Scripts/python.exe; this worktree first, then the main
checkout) and fall back to python3, then python. scripts/check checks
sys.version_info before importing the engine and exits 2 with a one-line message
naming the virtualenv instead of a traceback. Verified with a real CPython 3.10.21:
exit 2 and the message, where 96bb97a crashed in ruff_runner.
2026-10-04 21:29:39 -07:00
Teknium
f8501a9328 fix(code-health): --staged/--commit run the ratchet from the judged snapshot
F20 (andrexibiza): in --staged/--commit mode the guard jobs ran inside the
snapshot root with the snapshot's git env, but the health job ran in the real
worktree. Its source was pinned to the index tree, yet its engine and policy were
not: Measurer reads scripts/ci/profile_scope_patterns.json from the repo it runs
in, so with an identical `git write-tree`, editing only the unstaged P05
path_regex turned PS-P05 / exit 1 into "ok" / exit 0 and back.

The health job now runs like every other job: cwd = the snapshot root with the
snapshot env, so `python -m scripts.code_health` imports the snapshot's own engine
and reads the snapshot's policy files and ruff pin. The ruff job resolves its pin
from the same root. Unstaged edits to the policy file or the engine no longer move
the verdict, and they stay untouched in the worktree; plain runs are unchanged
(root is the worktree).
2026-10-04 21:29:39 -07:00
Teknium
f98585d5cc fix(code-health): scripts/check also runs CI's case-collision, lazy-deps and profile-archive guards
unsupportedpastels m4: scripts/check says it runs the lint checks CI runs, but CI
also runs scripts/check-case-collisions.py, scripts/ci/check_lazy_deps_imports.py
and scripts/ci/check_profile_archive_boundary.py in workflows of their own
(case-collision-check.yml, lazy-deps-guard.yml, profile-artifact-check.yml). A
case-colliding pair passed scripts/check and failed CI.

All three are deterministic and local (git ls-files / an os.walk of the checkout;
measured here: 0.2 s, ~7 s, 0.1 s on the full tree), so they run as ordinary guard
jobs - in the snapshot root for --staged/--commit like the other guards - and are
selectable with --only case-collisions / lazy-deps / profile-archives.
2026-10-04 21:29:39 -07:00
Teknium
a6274687fd fix(code-health): parse ENFORCEMENT with ast and read it from the main tip locally
unsupportedpastels m3: (a) the switch was found by a regex that accepted only the
exact `ENFORCEMENT = "..."` spelling, so `ENFORCEMENT: str = "off"` silently stayed
blocking; (b) local runs and both hooks read it from the merge-base with
origin/main, so a flip on main reached a branch only after a rebase, contrary to
"CI and local hooks alike".

(a) parse_switch() reads the module-level plain or annotated assignment with ast
(last one wins, as at runtime); missing, unparseable or unknown values are
blocking. (b) Without --base, the base is still the merge-base of the head (a
--head commit, else HEAD) but the switch comes from origin/main when it resolves,
falling back to the base. An explicit --base pins both (CI passes HEAD^1, the
target tip). scripts/check --commit now hands the commit itself to the ratchet so
it derives that commit's merge-base. The head under test never supplies the switch.
(c) A test pins enforcement() on this tree to config.ENFORCEMENT.
2026-10-04 21:29:39 -07:00
Teknium
b76c3facdf fix(code-health): profile regex rules match executable code, not prose
F21 (andrexibiza): PS-P05/PS-P06 ran their regexes over raw source lines, so a
comment such as `# Avoid env = os.environ.copy(); use the scoped builder.`, the same
sentence in a docstring, or a comment warning against `os.getenv("DISCORD_TOKEN")`
produced a blocking hit although nothing builds an env or reads a credential.

Before the regexes run, comment tokens (tokenize) and string statements (ast:
docstrings and bare string examples) are blanked in place, preserving every line
number and line break. Ordinary string literals inside code are kept, so the
env-key argument of `os.getenv("DISCORD_TOKEN")` still matches; the real
`env = os.environ.copy()` / `dict(os.environ)` / platform getenv stay flagged. If
tokenizing fails, the raw lines are used (more hits, never fewer).
2026-10-04 21:29:39 -07:00
Teknium
7ef5f0e1f9 fix(code-health): an allow comment may separate its rules with spaces
banditburai (inline report.py:36): `# health: allow BLE001 S110 -- boundary` waived
neither rule and reported "allow comment ignored: it needs a reason", although the
reason is there. _ALLOW accepts spaces in the rule list, but apply_allows split it
on commas only, so "BLE001 S110" was looked up as one rule name.

The rule list is now split on commas and whitespace, so `BLE001 S110`,
`BLE001, S110` and `BLE001,S110` all waive both rules. A reason is still required.
2026-10-04 21:29:39 -07:00
Teknium
f970c226f3 fix(code-health): a failing measurement tool exits 2, not with a traceback
unsupportedpastels m5: when the pinned TypeScript is not cached, ts_measure runs
`npm install`; a failed or timed-out install raised CalledProcessError /
TimeoutExpired, which cli.main did not catch (it caught RuntimeError only). The run
then died with a traceback and exit 1, which reads like a finding.

cli.main now reports those as a broken run: one `code health: measurement tooling
failed: ...` line on stderr and exit 2, like every other environment error.
2026-10-04 21:29:39 -07:00
Teknium
15274dfb41 fix(code-health): --json prints JSON on every return path
JoaoMarcos44 (inline cli.py:64): the ENFORCEMENT="off" and "no measured files
changed" branches returned before the --json formatter, so
`python -m scripts.code_health --base HEAD --head HEAD --json` succeeded with prose
on stdout and json.loads() raised.

Both early successes now print the empty findings list `[]` on stdout and move
the human explanation to stderr; findings and zero-finding measured diffs were
already JSON. Exit codes are unchanged on all four paths.
2026-10-04 21:29:39 -07:00
Teknium
ac40898e2d fix(code-health): reject unknown or empty --only selectors
F22 (andrexibiza): `scripts/check --only heath`, `--only ,` and `--only heath,rof`
filtered the job table down to nothing and reported "0 checks, ok" / exit 0, and
`--only health,rof` silently dropped the unknown name. A typo in a developer or
automation command could claim success without running the requested check.

The whole selector set is now validated before any job is built or dispatched:
an empty selection or any unknown name is a usage error (exit 2) that lists the
valid names. A valid selector still runs its check and keeps its status.
2026-10-04 21:29:39 -07:00
Teknium
6b4deabadb fix(code-health): bound the cost of same-name pairing
Why (cost, same concern as Adolanium's line-matching note): pairing repeated names by body is
all-pairs within a group, and the per-group lookup of base units scanned the whole base file,
so a codemod touching every function of a big file was quadratic (5,000 edited unique names:
5.4 s -> 0.04 s with a per-file name index). A group whose all-pairs check would exceed
250,000 pairs (e.g. hundreds of edited `it(...)` callbacks in one scope) falls back to the
ordinal pairing, which is bounded; measured 2,000-callback group: 0.05 s.
2026-10-04 21:29:39 -07:00
Teknium
ae59e08534 fix(code-health): hits in a function moved and edited are placed by its origin's lines
Why (M2 follow-up; invariant 4, no trading): the previous commit let a moved+edited unit's
new-looking hits spend any departed occurrence of its origin unit by line text, so a swallow
dropped at the top of `legacy` paid for one added at the bottom of the moved copy (exit 0;
red on the previous commit), while the same relocation in place blocks. A unit matched
across files (pass 5) now compares its lines with its origin unit's (SequenceMatcher, code
only), exactly like an edit in place: hits on unchanged lines are old, the rest are new.
The departed-occurrence pool is now module-level only.
2026-10-04 21:29:39 -07:00
Teknium
fe6b559d11 fix(code-health): a module-level hit moves only with its whole statement
Why (M1 follow-up; invariant 4, no trading): matching a departed module-level occurrence by
its line alone let `except Exception:` stand for any guard, so deleting
`try: import json / except Exception: pass` from one file paid for a new
`try: import yaml / except Exception: pass` in another (exit 0; red on the previous commit).
A module-level occurrence now moves only with its whole top-level statement unchanged
(found by indentation: a whole try/except block, or a one-line constant). Moving an unchanged
HX005 module constant to another file passes; a different constant still blocks.
Hits inside a unit matched across files keep line matching: only that unit's own departed
occurrences can pay.
2026-10-04 21:29:39 -07:00
Teknium
e5a6b1f746 fix(code-health): pair repeated names by body; keep the cap of a function moved and edited
Why:
- banditburai (inline, py_structure.py:97) and unsupportedpastels M3: repeated names (Python
  `@method(...) def _`, `f#2`; TS `<anon>#n`) were paired by source-order ordinal, so a new
  CC-22 `_` inserted above a trimmed one took its cap (exit 0), and inserting a small `_`
  above an unchanged CC-22 one made the old one "new" (exit 1). Same-file name matching now
  groups units by name without the ordinal and pairs the most similar bodies first
  (difflib ratio >= 0.6 over code lines, comments/whitespace dropped); the last unit left
  with a name is that name's unit, exactly as a unique name was before. Exact-hash passes
  still run first. If a body span is unknown, the group falls back to the ordinal.
- unsupportedpastels M2: cross-file matching was body-hash only, so a function moved to
  another file with one constant changed lost its cap (P1 exit 1; same edit in place exit 0).
  New last pass: another file, same name, similar body (>= 0.6), only when the name is gone
  from its origin file and exactly one unit of that name left and exactly one arrived
  unmatched. A copy (origin keeps it), two arrivals, or an unrelated body reusing a deleted
  name stay new code (andrexibiza: never arbitrary same-name credit). Its existing hits
  follow it through the departed-occurrence pool (one-to-one), a second swallow is new.
Unit gains an optional end_line; until the measurers report it (py_structure: lane A,
measure.py: lane C) the span is estimated from the unit's own FUNC_LINES. Tests pass both ways.
2026-10-04 21:29:39 -07:00
Teknium
35951f8fa4 fix(code-health): module-level hits follow code moved to another file; skip idle line matching
Why (round-5 review, unsupportedpastels M1; Adolanium cost note):
- M1: code outside any unit is not a unit, so its hits could survive only through the line
  diff of a file against its own base (or git-detected rename). Moving an unchanged
  module-level `try/except Exception: pass` into pkg/a_compat.py reported S110 + BLE001 as new
  (exit 1). Now a base occurrence whose line LEFT its file may pay, once, for an identical
  occurrence (same rule, same normalized code) arriving in another file in the same diff, at
  module level or inside the unit its own unit was matched to. One-to-one: the spent
  occurrence no longer counts as credit at its origin. A copy (origin line survives) still
  blocks; two arrivals for one departure block; a re-add in the same file stays new; a
  module-level hit never pays for one inside a function.
- Adolanium §3: SequenceMatcher ran for every changed file, TS included (1.66 s on desktop
  main.ts for a one-line change); line matching is now skipped when neither side has hits.
2026-10-04 21:29:39 -07:00
Teknium
142bcd133d fix(code-health): TypeScript install honours the repo .npmrc; npm failures exit 2
Why (round-5 review, unsupportedpastels m5, D4): `npm install --prefix <cache>` makes the
cache dir the npm project, so the repo's .npmrc (min-release-age=14) was ignored: `npm config
get min-release-age` from the prefix was undefined. The repo .npmrc is now copied into the
prefix before installing (verified: `npm config get min-release-age --prefix <cache>` -> 14).
A failed or hung install raised CalledProcessError/TimeoutExpired, which cli.main does not
catch, so the run exited 1 with a traceback that read like a finding; both now raise
RuntimeError with npm's stderr, which cli.main maps to exit 2.
2026-10-04 21:29:39 -07:00
Teknium
cf7dc01a37 fix(code-health): TypeScript identity by object, token hash, fail closed on syntax errors
Why (round-5 review, unsupportedpastels M3/m1/m6 + §3; Adolanium, helix4u):
- M3: methods of object literals were named `f`, `f#2` and paired by position, so swapping
  two objects gave b.f a.f's cap of 31 (V9a). They are now named by the variable/property
  that holds the object (`a.f`, `b.f`). Overload signatures (and `declare`/abstract
  signatures) have no body and are no longer units, so adding an overload no longer shifts
  the implementation to `old#4` and makes it new code (V10a).
- m1: the body hash covered raw text, comments included, so rename + one comment line was new
  code in TS (not in Python). It now hashes the parser's token stream (no comments, no
  whitespace, no JSDoc), collected once per file.
- §3 (Adolanium, helix4u): `export function f(x: number {` exited 0 because the parser
  recovers and sf.parseDiagnostics was never read. A file with parse diagnostics is now an
  error -> MEASURE (fails closed), like unparseable Python. No tracked TS file in the tree
  has parse diagnostics (4191 files checked).
- m6: the header documents exactly where the CC count deviates from ESLint's complexity
  (optional chaining, &&=/||=/??=, default parameters are not counted).
Units also carry their end line (`end`), for body-similarity pairing.
2026-10-04 21:29:39 -07:00
Teknium
8a0e27697c fix(code-health): a parameter annotated as a process is an HX006 handle
Follow-up to the per-scope handle tracking (Adolanium §4): a repo-wide sweep
of the old vs new engine showed one lost hit,
tui_gateway/host_supervisor.py `_wait_for_exit(self, proc:
subprocess.Popen[str])` -> `proc.wait()`. The old module-wide map flagged it
only because another function happened to bind `proc` to a Popen; scoping
correctly treats a parameter as unknown, which also drops the case where the
type says exactly what it is.

A parameter annotated `subprocess.Popen[...]` (sync) or
`asyncio.subprocess.Process` (async) now starts as a handle of that kind;
unannotated parameters still clear the name.
2026-10-04 21:29:39 -07:00
Teknium
66cd3efacc fix(code-health): fix texts for HX010, HX001 and BLE001 say what is accepted
unsupportedpastels M4 (review 5408300628 §2.3) and m7: the printed fixes would
break code or did not match the engine.

- HX010 led with `is_truthy_value`, which returns False for every real key,
  while most hits are "is it set" checks (`bool(os.getenv("..._API_KEY"))`).
  It now offers `is not None` / `!= ""` for presence and `is_truthy_value`
  only for boolean flags.
- HX001 only said `get_hermes_home()`, which would move a path that lives
  under the user's home on purpose (the profiles root); such a path takes an
  allow with a reason.
- BLE001: the ratchet runs ruff with --ignore-noqa, so `# noqa: BLE001`
  waives nothing, and a message-only `logger.warning(..., e)` still fails.
  Verified with pinned ruff 0.15.10 through the real comparison path
  (test_ble001_fix_text_matches_the_engine): logger.exception, any log call
  with exc_info=True, and re-raise pass; `% e` logging and `# noqa` block;
  `# health: allow BLE001 -- why` waives.

Text only: no rule's blocking flag or ENFORCEMENT changed.
2026-10-04 21:29:39 -07:00
Teknium
b1b1f0694c fix(code-health): HX001/HX003/HX012 stop rejecting correct code (M4)
unsupportedpastels M4 (review 5408300628 §2.3), each fixture a new file that
exited 1 at 96bb97a:

- HX001 matched any name starting with ".hermes", so
  `Path.home() / ".hermes-profile-exports"` (hermes_cli/profiles.py) was a
  "hardcoded Hermes home". It now matches only the exact `.hermes` path
  component (`.hermes`, `.hermes/x`, `~/.hermes/...`).
- HX003 matched every string containing `ps aux` / `pgrep -f`, including
  docstrings that explain why not to use them. Expr-statement string
  constants (docstrings, bare string statements) are prose and skipped; real
  command strings, assignments and f-string parts stay flagged.
- HX012 flagged `threading.Thread(target=copy_context().run, ...)`, the
  context-preserving form the plan lists as a fix (agent/auxiliary_client.py
  uses it). A target of `<ctx>.run` is accepted when ctx is
  `contextvars.copy_context()` itself or a name this same scope binds only to
  `copy_context()` (keyword or positional target). A ctx from another scope,
  a rebound name, or any other target stays flagged.
2026-10-04 21:29:39 -07:00
Teknium
6ca1395ee0 fix(code-health): NESTING counts else: + indented if as nesting
JoaoMarcos44 (inline py_structure.py:49, review 5408439923; reproduced by
andrexibiza): nesting_depth() treated any If that sits alone in an orelse as
an `elif`, but `else:\n    if b:` produces the same AST. Seven physical
`else: if` levels measured 1 (adding an inert `pass` made them measure 7), so
the gate depended on an irrelevant statement rather than on indentation.

A real `elif` If node starts at the `elif` keyword, i.e. at its `if`'s
column; an `if` nested under `else:` must be indented deeper (a compound
statement cannot follow `else:` on the same line). Only the former keeps the
depth. Seven physical levels now measure 7 with or without the inert pass
and block; a genuine seven-arm elif chain stays at 1 and passes.
2026-10-04 21:29:39 -07:00
Teknium
ae60f7ccc1 fix(code-health): recursive-rename normalization honours nested scopes (F15)
andrexibiza's F15 (changed form, inline on py_rules.py:250): body_hash()
disabled self-reference normalization whenever the textual dump contained
`arg('<name>'` or `Name('<name>', Store())` anywhere in the subtree, so an
unrelated nested `def identity(legacy)` made a pure recursive rename of
`legacy` lose its cap (FUNC_LINES 302 > target 300 as new code). Even with
the check removed, the string replace would also have blanked the nested
function's own `return legacy`, which refers to its parameter.

References are now resolved lexically: only bindings in the function's own
scope (params, assignments, nested def/class names, imports, global/nonlocal,
except/match captures) disable normalization, and a nested def/lambda/class/
comprehension that binds the name keeps its own reads. The matched Name /
Attribute nodes are blanked in place for the dump and restored. Controls: a
rename that leaves the old self-call is different code, an own-scope rebinding
is not a self-reference, a closure's call to its enclosing function is.
2026-10-04 21:29:39 -07:00
Teknium
eb872c9ebb fix(code-health): HX005 evaluates annotations when the interpreter does (F9)
andrexibiza's F9 (changed form, inline on py_rules.py:250): definition
traversal kept a def's decorators and defaults but dropped its parameter and
return annotations, which run at def time on 3.11-3.13 (the project floor is
3.11) unless the module has `from __future__ import annotations`; measured on
3.11 here, `def f(x: os.getenv("S"))` freezes the launch value in
__annotations__. Conversely, with the future import, a module/class
`x: os.getenv("PATH")` annotation is never evaluated but the whole AnnAssign
was walked, so it was charged as a capture.

Def annotations now count as import-time exprs unless annotations are
postponed; an AnnAssign contributes its target and value always and its
annotation only when not postponed. A nested def's annotations stay out
(they run when the outer function does).
2026-10-04 21:29:39 -07:00
Teknium
9b6b48a881 fix(code-health): HX009 follows annotated results and projects loop positions
Adolanium (comment 5983756525 §4) and JoaoMarcos44/andrexibiza: an annotated
`results: list = await asyncio.gather(..., return_exceptions=True)` was not
recognised as a gather result (only ast.Assign was), so a plain `: list`
annotation hid the exact pattern HX009 exists for. Conversely, unpacking a
zip/enumerate tainted every loop variable, so `isinstance(payload, Exception)`
over `zip(payloads, results)` or the `i` of `enumerate(results)` was flagged.

AnnAssign values now propagate results, and loop/comprehension targets are
projected positionally: each element of a zip follows its own argument, an
enumerate taints only its item. Non-tuple targets keep the old whole-target
behaviour.
2026-10-04 21:29:39 -07:00
Teknium
5b5b4ebf66 fix(code-health): HX006 post-kill exemption covers returned and annotated waits
JoaoMarcos44 (inline py_rules.py:442, review 5408210901; reproduced by
andrexibiza): after `proc.kill()` only an expression or plain-assignment
`proc.wait()` was recognised as reaped, so `return proc.wait()` and
`rc: int = proc.wait()` were flagged while the identical `rc = proc.wait()`
passed, and refactoring an accepted assignment into a return gained a blocker.

Return and AnnAssign values now count as the adjacent wait. The negative
controls stay flagged: no preceding kill, a different killed receiver,
communicate() after kill, and asyncio Process.wait() after kill; an explicit
finite timeout is still accepted. The assignment->return refactor is covered
through the real comparison path.
2026-10-04 21:29:39 -07:00
Teknium
592a02fd13 fix(code-health): track HX006 process handles per lexical scope
Adolanium (comment 5983756525 §4), corroborated by andrexibiza (F13 receiver
class) and helix4u: _process_handles() keyed handles by variable spelling for
the whole module, so an async `proc` in one function made a correctly bounded
sync `proc.communicate(timeout=1)` in another function fail HX006 (and a
`def stop(proc)` parameter inherited whatever `proc` meant elsewhere).

A plain-name handle now belongs to the scope that binds it: the innermost
scope binding the name decides (class bodies are invisible to methods,
closures see their enclosing function), and within it the latest binding
before the call, so reassignment (Popen -> asyncio process -> None)
re-classifies the receiver. Parameters and other bindings clear it. Attribute
handles (`self._proc`) stay shared by the methods of their class, keyed by
that class rather than by spelling across the module.
2026-10-04 21:29:39 -07:00
Teknium
a52a7f829b fix(code-health): report every independent import-time capture (F19)
andrexibiza's F19 (review 5407834086, inline): _capture_lines() returned after
the first eager capture it met. The reverse stack reaches an existing dict
entry first, so inserting `"new": os.getenv("HOME"),` above
`"old": os.getenv("PATH"),` produced no new HX005 occurrence and the
comparison could not charge it.

Every independent capture is now emitted (dict entries, list items, defaults);
a capture nested inside another (`expanduser(getenv(...))`) is the same
occurrence and is not entered, and per-line duplicates still collapse in the
measurer. Deferred bodies stay excluded. Tested through the real comparison
path: added dict/list/default captures block, a lambda entry and unchanged
debt pass, a separate assignment blocks.
2026-10-04 21:29:39 -07:00
Teknium
2cd844f114 fix(code-health): resolve import aliases per lexical scope (F12)
andrexibiza's F12 (changed form, review 5407834086): canonical_tree() pooled
every parameter, assignment and def in the module into one "rebound" set, so
`from subprocess import run as execute` stopped resolving everywhere as soon
as an unrelated function had a parameter or local named `execute`, and
`launch(cmd): return execute(cmd)` lost its HX006.

Bindings are now recorded per scope (module, class, function/lambda,
comprehension) and each read resolves through the scope chain the way Python
does: the innermost scope that binds the name decides, a scope that binds it
other than by one import makes it a plain local there, class bodies are not
visible to nested functions. Genuine shadowing in the consuming function (or
at module level) still stops resolution.
2026-10-04 21:29:39 -07:00
Teknium
41236dd450 fix(code-health): replay samples a closed merge window and writes a manifest
The replay picked PRs with sort:updated-desc, so the sample changed whenever an old PR got a
comment and two runs could not be compared (unsupportedpastels M6). It now takes every PR merged
inside a closed date window in PR-number order, writes manifest.json (number, base, head), and
--manifest replays exactly those ranges. The summary prints one total: PRs with at least one
blocking finding, instead of mixing blocking and warning-only rows.
2026-10-04 21:29:39 -07:00