Commit Graph

2706 Commits

Author SHA1 Message Date
Teknium
c8ac525cbf fix(update): Windows marker decisions re-read creation times instead of a pid's cached one
Get-LiveProcessCt caches a pid's creation time for the script's lifetime and
forgets it only when a probe SEES the pid dead. A delegate (the `hermes update`
child) that exits and whose pid is reused between two probes keeps the old
incarnation's time, so the release judged the unrelated new process live and
handed the marker to it (A7 rule 5), parking every later update behind it.

Every judgement made through New-MarkerContext (claim, delegate, heartbeat,
release) and the delegate line Add-MarkerDelegate writes now pass -Fresh,
which re-reads the time. Pollers that only ask "still alive?" (Wait-DesktopExit)
keep the cache.

Test (Windows live cell): a release with the delegate's pid cached at the
recorded ct but really owned by another incarnation deletes the marker instead
of handing it over. marker.sh reads /proc or ps on every call: no cache.

review S6
2026-10-06 05:44:21 -07:00
Teknium
a041e18255 fix(update): the Windows hand-off judges the checkout busy while a leased child holds it
marker.ps1::Test-CheckoutLockHeld probed only the owner byte at 1 MiB. Since R5b
every joiner the kill-on-close job refused also locks one of the 16 lease bytes
after it, and hermes_cli/update_lock.py::_try_lock treats any held lease as a
held lock. With the owner dead and a leased completion child still running, the
hand-off read the checkout as free, so the R6 release wait ended and the
Desktop's reclaim deleted the marker next to a live mutator.

Lock/Unlock now cover 1+16 bytes in one LockFile (it fails if any byte is held).
marker.sh needs no change: POSIX takes the checkout lock with flock on the whole
file and the lease bytes exist only on win32 (_take_lease).

Tests: a live Windows cell holds only a lease byte (1 and 16) and expects
`held`; a static cell, runnable on Linux, pins the probed range (RED at start:
(1048576, 1)).

review S1
2026-10-06 05:44:21 -07:00
Teknium
d549d5fb5c fix(update): a timed-out probe on a host without perl is killed with its whole tree (review desk-noperl-timeout-leak)
What: run_bounded's no-perl branch starts the probe under bash job control (set -m), so it gets its own process group and the timeout's kill -KILL -- -pid reaches its children.
Why: without perl the probe stayed in the caller's group, the group kill failed, and a hung hermes --version / update --help left its python child running.
Red: pre-fix posix.sh fails test_timed_out_probe_is_killed_with_its_whole_process_tree[absent] (grandchild alive after rc=124). Green: both [present] and [absent] pass.
2026-10-06 05:44:21 -07:00
Teknium
3bb4e87775 test(update): runtime unicode docstring matches the ProcessStartInfo UTF-8 capture
Review R8 m7: after the merge with main's 732b63d61a the probe decodes launcher
stdout as UTF-8 through ProcessStartInfo.StandardOutputEncoding and never touches
[Console]::OutputEncoding, so the test docstrings no longer claim the call sites
scope and restore it. runtime.ps1 keeps a one-line #124526 pointer at the
encoding line. Behaviour and assertions are unchanged.
2026-10-06 05:44:21 -07:00
Teknium
450b53fab1 fix(update): require delegate publication before releasing handoff child
(cherry picked from commit c41f84ddffcc36a3a59a975d7f85bf8c5b2f19b3)
(cherry picked from commit c85844ceaca472d1260e9883f3268c01c0c6010f)
2026-10-06 05:44:21 -07:00
Teknium
b5852b3187 fix(desktop): correlate handoff receipts with stable run identity
(cherry picked from commit 87c67de3778735837b73de8d2955f83624b2b449)
(cherry picked from commit bf3ca9f63cead72e51f22d4376abe5b3504d4bf2)
2026-10-06 05:44:21 -07:00
Teknium
b17bcfc704 fix: bash and PowerShell marker readers take the oversized-number corpus cases
The corpus now pins line 2 to digits that fit u64 (any count) and a pid to
digits that fit u32 after leading zeros. bash took a line 2 past u64 as a
far-future start (live); PowerShell rejected any line 2 over 18 digits
(so u64::MAX was malformed) and any pid over 10 characters (so a
zero-padded pid was malformed), and Windows PowerShell's [double]::Parse
throws on a creation time past double's range.

bash compares a 20-digit line 2 as two 10-digit halves (never in signed
shell arithmetic); PowerShell strips leading zeros before the width checks,
compares 20 digits as a decimal, and reads an out-of-range ct as +Infinity,
which never matches a live process. Rewrites carry line 2's exact digits;
only the age arithmetic clamps.

(cherry picked from commit 7208aa4af3c9e355dadee970dddb730fcfeb0301)
2026-10-06 05:44:21 -07:00
Teknium
33cea9a733 fix: bash and PowerShell adopt an old Desktop's launcher bridge by one lineage rule
The two scripts disagreed on an old packaged Desktop's v1 bridge (round 5
D11): bash adopted a live launcher that is our parent but no longer the
Desktop's child when line 2 equals HERMES_UPDATE_STARTED_AT (the Desktop
quit first and the launcher was re-parented); PowerShell refused it, also
accepted a v2/delegate claim naming its parent, required a gone launcher to
be its recorded parent, and Trim()/TryParse'd the env value.

One rule now, as a pure function in both (marker_launcher_rule /
Test-MarkerLauncherRule) over the same facts, plus one env comparison
(plain ASCII digits of line 2's value). tests/scripts/desktop_update/
lineage_rule_cases.py is the shared table; the posix and Windows suites run
it, and a real-process cell per platform covers the divergent row.

(cherry picked from commit aa4b8ce704bc474d539690235c191edee3b637f2)
2026-10-06 05:44:21 -07:00
Teknium
b493b91a33 fix: hand-off probes the checkout lock for two syscalls and refreshes line 2 through the R6 wait
checkout_lock_held took the real checkout lock through flock(1) on a shell
fd and released it only when the shell closed that fd: the hold spanned
flock(1)'s exit, the wait and the close, so a concurrent `hermes update`
acquiring non-blocking was refused 223 of 3157 times in the reviewer's
tight-loop probe (round 5 D17). Now one process (perl, else python3) opens
the file, tries LOCK_EX|LOCK_NB and drops it in the next syscall: 0 of
3387. flock(1) stays the last-resort fallback; no tool still means held.

The old-Electron line-2 refresher ran only while `hermes update` ran, so
an old packaged Desktop could age-delete the marker 20 minutes into the R6
release wait (round 5 D18). The refresher now lives from the claim until
the release is decided (posix.sh), and the Windows heartbeat ticks inside
Invoke-MarkerRelease's wait. Tests shorten the cadence with the test-only
--self-test-refresh-every argument.

(cherry picked from commit 8339b25e9cfbd13f9f3e8f606e703d994b8b5410)
2026-10-06 05:44:21 -07:00
Teknium
b802f677b5 fix: windows hand-off keeps the marker while the checkout lock is held (R6)
Release waits (bounded) while any process still holds the checkout lock's
byte (hermes_cli/update_lock.py takes it with msvcrt.locking at 1 MiB), and
-MarkerOp reclaim answers `held` instead of freeing a dead marker in that
state, so a Desktop gate never opens over a surviving completion. posix.sh
withdraw now keeps a dead adopter's claim (`foreign`), like marker.ps1.

(cherry picked from commit 8a5525d26f036a3c4b5176f11ee4a590c746e2db)
2026-10-06 05:44:21 -07:00
Teknium
a9f8800511 fix(desktop-update): probe .cmd launchers through cmd.exe /d /s /c
Invoke-HermesProbe handed a .cmd launcher to Process.Start with
UseShellExecute=false, letting CreateProcess pick the interpreter and
re-parse the arguments. Run it through ComSpec explicitly like the update
step does, and refuse arguments cmd.exe cannot be made to take literally.

(cherry picked from commit 0b24aab96962535268d71711c0ef564e01ac6e11)
2026-10-06 05:44:21 -07:00
Teknium
8fb5c3b536 fix(desktop-update): A7 marker lock and hand-off protocol 2 on Windows
Every marker mutation (reclaim, adopt, delegate, release/hand-over,
withdraw, line-2 refresh) now happens inside one hold of
[IO.File]::Open("<marker>.lock", OpenOrCreate, ReadWrite, None), retried
for 10 s then BUSY (claim refused). Read->judge->mutate all inside the
hold; the sidecar is never deleted. Replaces the compare-then-replace /
compare-then-delete helpers.

- parser/judge per tests/fixtures/update_marker_corpus.json (trimming,
  BOM/CR, u32 pids, pid 0 dead, delegate/run on any line >= 4, own-pid
  exact incarnation, 2 s tolerance, v1 1200 s ceiling)
- release per corpus: owner deletes or hands the claim to a live
  delegate; a delegate releaser drops its line
- -HandoffRun <id>: adopt only the Desktop bridge carrying run:<id>
- -DesktopPid alone (old packaged Desktop): accept its cmd.exe wrapper's
  v1 overwrite by lineage, never by dropping the check
- -MarkerOp reclaim|withdraw: one verdict line, exit 0 (64 usage)
- `# hermes-handoff-protocol: 2`
- the update child is created suspended in a kill-on-close job, named as
  the delegate under the lock, then resumed: no update instruction runs
  before (or without) the delegate line
- line 2 refreshed every 5 min while the child runs (old readers)
- the job helper moves to update-job.ps1 (windows.ps1 < 2000 lines)

(cherry picked from commit 8bdf467c768da314ce7817f114a6b1b154e11154)
2026-10-06 05:44:21 -07:00
Teknium
a0f2dc903e fix: posix hand-off decides every marker mutation under the A7 lock (protocol 2)
marker.sh (sourced by posix.sh) owns the update marker: corpus parser and
judge, claim, delegate, release and the line-2 refresh, each read -> judge ->
mutate inside one flock(1) / perl Fcntl hold of <marker>.lock (busy 10 s
fails closed). Two live claimants of one dead marker no longer both win (R3).

Hand-off protocol 2 (`# hermes-handoff-protocol: 2`): --handoff-run R adopts
only the Desktop's live bridge carrying run:R; an older Desktop's bridge (its
launcher's pid, v1) is adopted by lineage -- the named pid is our parent and
the Desktop's child, or carries HERMES_UPDATE_STARTED_AT -- never by dropping
the check (R4). --marker-op reclaim|withdraw gives the Desktop one verdict
line; it never mutates the marker itself.

Release waits while any survivor still holds the checkout lock and the
reclaim helper answers `held` for a dead marker in that state (R6). The
update child is gated on a go-file created only after the delegate line is
published (pre-publication crash window), run_bounded kills the probe's
whole process group, and line 2 is refreshed every 5 min while ours so an
older Desktop's 20-minute age ceiling never deletes a live claim.

(cherry picked from commit f16dcc5583df8294cea96e6941cfdf5d4a7c8078)
2026-10-06 05:44:21 -07:00
Teknium
2245190411 fix: posix hand-off names its hermes update child as the marker delegate at spawn
A daemon killed in the second between spawning `hermes update` and that
child taking the update lock left a marker whose only identity was the dead
script: every reader judged it DEAD and a second update could start over the
first. The child now runs asynchronously just long enough for its pid to be
written as line 4 (`delegate:<pid> ct:<ct>`, compare-and-swap on our own
lines 1-3), then is waited on; HUP/INT/QUIT that land meanwhile are handled
after it exits, exactly as with the former foreground child.

(cherry picked from commit a09d2fb7cac0d730b491560ea0f970e5d227421e)
2026-10-06 05:44:21 -07:00
Teknium
4729c737e5 fix: windows hand-off reads other users' creation times and never fails a committed update
- marker helpers move to scripts/desktop-update/marker.ps1, dot-sourced
  first (windows.ps1 2113 -> 1930 lines); a hand-off that cannot load it
  exits 3 having changed nothing. It ships in the checkout next to
  windows.ps1 and runtime.ps1, which is where the Desktop runs them from.
- creation time comes from Win32_Process.CreationDate (limited query
  rights), cached per pid while it lives: Get-Process .StartTime is access
  denied for SYSTEM/elevated/other-user pids under PowerShell 5.1, so a
  pid reused by a service after a reboot read as a live owner forever.
  Alive with an unreadable time is live only inside the 20-minute ceiling.
- -DesktopPid runs adopt the Desktop's live bridge claim only; a refused
  run exits 2 without a result file and only reopens a Desktop that is gone.
- positional parse identical to the other readers; claims publish the
  complete body with an exclusive hard link (rename fallback); a fresh
  empty marker is a claim in flight; the delegate line always carries ct.
- a follow-up step whose process tree cannot be quiesced after the commit
  point is ok:true + a manual warning (no relaunch, marker kept live),
  not "still on the previous version".
- the interrupted post-update tail is always a manual warning: re-running
  hermes update is the only remedy, so the copy says exactly that.
- the launcher probe bound is a -ProbeTimeoutSeconds parameter (default
  60), not the HERMES_UPDATE_PROBE_TIMEOUT_SECONDS environment variable.

(cherry picked from commit 65663dc6a64f942c6385b7de3ba13cdecd393387)
2026-10-06 05:44:21 -07:00
Teknium
073eb00f1d fix: posix hand-off adopts only under a Desktop pid and parses markers like every reader
Contract amendments A1-A4, posix side:
- --desktop-pid runs only adopt that Desktop's live bridge claim; with no
  bridge (the Desktop gave up after its 20 s wait) or anyone else's marker
  the script exits 2 without claiming, running, or writing a result
- a refused hand-off (exit 2) never writes .hermes-update-result.json; the
  other update owns the outcome, and a stale "still on the previous
  version" dialog could otherwise follow a successful CLI update
- positional parse: BOM/CRLF accepted, non-integer line 2 = malformed
  (dead), bad ct line = v1, line 4 only as "delegate:<pid> ct:<ct>"
- a live pid whose creation time cannot be read is live only inside the
  20-minute ceiling (unknown never means forever)
- an empty marker younger than 5 s is a claim in flight
- macOS ps lstart is rendered and parsed with TZ=UTC0 (DST-safe)

(cherry picked from commit 9d8b58063f0c2d8de6bc37b615e6e49f1abb7549)
2026-10-06 05:44:21 -07:00
Teknium
cb8451baa5 fix: windows hand-off claims the update marker first and never fails a committed update
windows.ps1 implements update-marker contract C1 v2 and the C3 outcome
vocabulary on the Windows Desktop hand-off:

- The marker claim is the script's first action (before any Add-Type, UI
  or probe): exclusive create (tmp + File.Move, never overwrites) with a
  v2 body "<pid>\n<started_at>\nct:<creation time>\n". A live marker owned
  by exactly -DesktopPid is adopted by compare-and-swap keeping its
  started_at; any other live owner refuses the run (exit 2, marker
  untouched, "Nothing was changed"); a dead owner (pid gone, creation time
  mismatch = pid reuse, v1 older than 20 min) is compare-and-deleted and
  the claim retried.
- Release is compare-and-delete on lines 1-3 and keeps the marker while a
  line-4 delegate is alive. When a stalled tree cannot be quiesced (exit 7)
  the marker gains a delegate line naming a surviving job member
  (JobObjectBasicProcessIdList), so it stays live exactly as long as the
  tree does instead of naming the dying script.
- After `hermes update` exits 0 (or the 124 banner remap) the result is
  ok:true, exit_code 0: verify failure (was exit 8), legacy rebuild
  failure (was exit 6), a gateway restart miss, an interrupted post-banner
  phase and any unexpected throw become `warnings` + manual, with a
  "Hermes was updated, but ..." message; gateway start --all still runs
  after a failed verify/rebuild.
- The idle watchdog counts job CPU/IO accounting deltas as progress, so a
  pipe-silent but busy step (download, extraction) is not killed.
- Launcher probes (--print-runtime-command, --version, update --help) run
  with a 60 s timeout and kill their tree on expiry.
- Desktop exit wait checks pid + creation time pinned at start, and a
  still-open Desktop is never relaunched over (exit 4).
- The result file is written atomically, carries started_at (marker line
  2) and warnings, and is no longer deleted at hand-off start.
- Stop-UiServer only removes its own profile dir and dirs whose pid is not
  running (TEMP is shared across HERMES_HOMEs).

(cherry picked from commit af178fd695d7f94bf584bfabe9748cbb752fea13)
2026-10-06 05:44:21 -07:00
Teknium
b54d069815 fix: posix desktop hand-off claims the update marker first and never fails a committed update
posix.sh now implements the update-marker contract (C1/C2) as a lock:
- the daemon claims `.hermes-update-in-progress` as its first action with a v2
  body (pid, started_at, `ct:` creation time) via an atomic no-replace create
  (link(2)); a LIVE foreign owner refuses with exit 2 regardless of age, a
  marker owned by --desktop-pid is adopted keeping its started_at, a dead one
  is compare-and-deleted; liveness honours a line-4 `delegate:` (the running
  `hermes update`), so an orphaned update is never reclaimed;
- the launcher waits for the daemon's claim and exits non-zero when it never
  lands, so the Desktop does not quit into nothing;
- release is compare-and-delete and keeps a marker whose delegate is alive.

Outcomes follow C3: a legacy rebuild failure, a mac bundle stage/swap failure
or a relaunch gate after `hermes update` exited 0 is ok:true + warnings (no
more exit 6 / exit 7 "still on the previous version"). The result carries
started_at and warnings and is written via a per-pid tmp + rename.

Crash safety: start-of-run recovery restores an interrupted .app swap from its
aside copy (both swappers' suffixes) and a killed TCC heal backup, and sweeps
UI/probe litter of dead pids; the two swap renames are a signal-deferred
critical section. `hermes desktop`'s Python swappers recover the same state
instead of deleting the only copy first.

Also: probes are bounded (60 s), `hermes update` streams into the log, the
update child gets default TERM and the daemon default HUP (no SIG_IGN leak into
gateways or the relaunched app), the relaunched Linux app drops the update env,
the Desktop wait is zombie/pid-reuse aware and never relaunches over a live
app, serve-ui.py exits with its orchestrator, and the job section is parsed as
one brace group so `hermes update` rewriting posix.sh cannot shift the rest.

(cherry picked from commit c7dd9fab00b8af09321ec982d07a62f03351355d)
2026-10-06 05:44:21 -07:00
Teknium
3f524a2459 ci: version the required check so CI greens from before a new required job stop counting
The protect-main ruleset requires a check by NAME, and a green check run on a
PR head never expires. After the code-health ratchet joined the required set,
32 PRs whose last CI run predated it merged on their old "All required checks
pass" green without the ratchet ever running; 20 of them would have been
blocked (62 findings, incl. 15 new blind `except Exception`).

Renaming the gate to "All required checks pass (v2)" and pointing the ruleset
at the new name voids every older green: each open PR needs one fresh CI run,
which now includes the ratchet, before it can merge, whoever merges it.
The ci.yaml comment documents the bump as the standard step whenever the
required set gains a job every PR must pass.

live_comment.py keeps both names in its infra list (old runs still render),
and a test pins that the gate's current display name never shows as a job.
2026-10-06 03:57:34 -07:00
Teknium
260cef995a fix(update): every owed follow-up reaches the Desktop hand-off result
A committed update exits 0 and prints one whole "Update follow-up '<step>'
did not finish:" line per owed step (update_receipt.record_followup). The
hand-offs only reacted to the Desktop build line, so a gateway proven to
run old code (gateway_restart), a refused Windows resume, a lost
completion or a failed channel adoption ended as a plain
"Update complete.".

posix.sh collects the distinct owed steps from that line
(owed_followup_steps) and turns them into the manual result message
(DONE_NOTE, the only channel the main-based script has); a gateway_restart
adds the `hermes gateway restart` hint. windows.ps1 does the same through
its manual outcome. With #132354's add_warning protocol the same
owed_followup_steps output feeds one add_warning per step.

record_followup's docstring names the line as a protocol; a contract test
runs the real posix.sh parser and the windows.ps1 regex against it.

Review: kshitijk4poor 5411222454 regression 1 / open thread F6.
2026-10-06 03:44:49 -07:00
Teknium
e28c05e4b0 fix(update): an unbuilt Desktop app is reported by one whole line the hand-offs match (review F6-desktop-build-debt-grep)
What: build_update_products acquires npm inside the "Node dependencies" attempt and, whenever the
desktop product was requested but not built, prints `Desktop app build owed: <step> failed`.
posix.sh matches that line instead of the truncated follow-up text; windows.ps1 now projects it as
a manual outcome (it had no projection of the current follow-up at all).
Why: the 500-char follow-up leads with whichever product failed first, and an npm acquisition
failure escaped un-named, so a committed update whose Desktop was not rebuilt said "Update complete."
Red: pre-fix source_build.py -> test_unbuilt_desktop_is_named_on_one_whole_line[both] fail; pre-fix posix.sh -> truncated-followup hand-off test reports manual=false.
Green: test_source_build.py 15 passed; test_desktop_update_gateway_flag.py 3 passed. windows.ps1 needs a native run.
2026-10-06 03:44:49 -07:00
Teknium
630b1bce80 fix(update): a conflicted autostash is owed to the user, never a plain success
Contract C3 keeps post-commit step failures at exit 0, but the parked stash is not a
step anything retries: the receipt records a user_action, finalizes partial and
hermes update exits 1 (#122557). The Desktop handoff reports an owed app rebuild as
an action ("could not be rebuilt ... hermes desktop --force-build"), and the pm e2e
follows A6 (dependency sync after the tree moved is an owed follow-up).
2026-10-06 03:44:49 -07:00
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