Skip to content

Unified coding-agent names: one persistent session name for Claude, Codex, and OpenCode - #819

Merged
danshapiro merged 59 commits into
mainfrom
the-usual/unified-agent-names
Sep 23, 2026
Merged

danshapiro merged 59 commits into
mainfrom
the-usual/unified-agent-names

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Unified coding-agent names

One persistent saved name per Claude, Codex, and OpenCode coding-agent session (CLI and fresh* modes), shared by the pane header, the tab (when it takes its name from the session), the sidebar/history rows, the terminal presentation, and every API projection. Renaming from any surface — UI editors and menus, the session/terminal/pane/tab routes, CLI verbs, MCP actions — writes the same durable record everywhere. Only Rename is exposed (no generate/reset/custom-label controls). Non-agent panes and excluded providers (Gemini, Kimi, Amplifier, and Kilroy despite its Claude runtime) keep their existing naming behavior end-to-end, guarded by a shared server-side kilroy-only seam.

Implements the committed plan docs/plans/2026-09-16-unified-agent-names.md in full: the durable cross-process name authority (session-names.json, strict sidecar-locked transactions with generation fencing and pre/post-replace failure classification), verified-persistence-only pending→durable binding, bounded automatic generation (3 starts, 30s/5min retries) with Freshell short AI titles over provider titles, native title ingestion + writeback for all three providers with the documented sync-status limitations, the stable tab nameSource lifecycle, and the deterministic one-time legacy consolidation with immutable backups.

Verification receipts

  • Coordinated full suite: exit 0 at the final HEAD (client, source-runtime, Rust, Electron — all four phases), coordinator receipt full-suite|661c91f5ba7e5add0846bc4c6eb048dcaa45fb4a|dirty:0 (the tip commit adds two mechanical clippy-lint fixes on top; same tree content otherwise).
  • Cloud e2e: 44/44 unified acceptance cases (six modes, cross-mode, restart/recovery) plus the existing naming specs — on the configured cloud backend, zero-flake.
  • Native sandbox smoke: all three providers PASS — the real Claude, Codex, and OpenCode CLIs' metadata contracts proven end-to-end (persistence, fresh readback, writeback synced, source protection) in a disposable container with honest, failable receipt gates.
  • Per-task reviews: all 8 plan tasks reviewed, fixed, and re-reviewed to approval; whole-branch review PASS.
  • Independent Fresh Eyes review of the complete delta: PASSED (5 rounds; two failed rounds — out-of-scope provider refs breaking the reconnect bootstrap, the O(H²) sweep churn, and the Kilroy blind spot — were repaired in 18 focused commits, each batch independently verified).

Notes for reviewers

  • 56 commits, 233 files, +56.5k/−1.6k. The plan and its full evidence trail (per-task briefs/reports/reviews, gate receipts, review logs) live in the run's ledger.
  • Known pre-existing (receipted at base, unrelated to this change): a wall-clock flake family in the full --bin freshell-server lane under extreme host load (passes solo) — if rust-gate hits one, isolation reruns or the documented bypass path apply.
  • Follow-up issues found during the work are being filed as separate katas (pre-existing exit-suffix rendering, excluded-provider pane-title divergence, and a handful of small OPTIONAL items recorded in the review logs).

The WS create dispatch silently swallowed freshAgent.create frames while
settings.freshAgent.enabled was off (the default): no reply frame, no log,
no sidecar spawn — every programmatic driver hung its pending create with
zero attribution (observed end-to-end in the native session-names smoke as
a created-frame timeout with no answer of any kind). Answer with the
raw-layer envelope precedent instead: freshAgent.create.failed carrying
the requestId, code FRESH_AGENT_DISABLED, retryable true (the legacy
disabled-gate rejection parity, ws-handler.ts:3334).
…eSource projection

The send-activity tests asserted the RecordingSink the instant
respond_log_frames returned, but the feed lands on the spawned
sidecar-notification pump — under full-crate parallelism the pump trails
by seconds and the instant assert sees an empty feed (three consecutive
full-crate failures of a_send_racing under host load; each passing solo).
Poll with a 15s deadline instead: the protection under test is 'the feed
is never silently dropped', which a poll proves honestly. Same family, the
racing and post-adoption tests also hand-modeled the session state while
the fake sidecar's async init adoption could still re-write
cli_session_id — wait for the adoption to complete first, then hand-model
the window. The post-adoption and ui_layout_sync fixtures pin the tab-row
nameSource projection 4bd6e68 added (the ws crate suite was never run
to completion at that commit; the two fixture asserts were stale and are
now updated to pin the intended shape).

The approval_respond_write_failure flake observed alongside is NOT
touched: it is the ledger-receipted pre-existing family member (failed at
base 6ee5cf4 under full parallelism, green solo in 0.01s, documented in
the transcript-minimap baseline).
The codex server leg passes end-to-end (13 operations incl. the resume-
create writeback with a verified location, the pending rename, and the
zero-turn restart survival). Root causes found and fixed in the runner:

- Pre-seed the server's ~/.freshell/config.json with freshAgent.enabled
  (like the e2e helper): the WS create gate silently swallowed creates
  while the flag was off — the root cause of every prior created-frame
  timeout, not sidecar boot slowness (a probe of the exact server-shaped
  env boots the ws-listen CLI in ~250ms in all variants).
- The step-(6) create is the real client's resume shape (sessionRef +
  namingHandle): the codex native writeback is live-connection-only by
  design and needs the resume lane's pending-to-durable bind to establish
  the verified location a plain new-thread create never gets.
- Register the codex CLI extension manifest in the server home (mode:
  codex REST creates) and carry the app-bound FRESHELL_MCP_NODE/ENTRY
  pair the packaged runtime expects.
- Container-local server HOME + codex projectDir + the opencode contract
  aligned to the server's opencodeXdgRoots (step 8's same-database
  writeback), refusal-aware frame waits, coherent JSONL failure tails
  (session_names vocabulary), and the store-document/created-frame/pane-
  content evidence riding the opencode durable-bind timeout for the next
  diagnostic run.
…surface inventory

The end-of-execution gate failed on six deterministic unit tests, all
stale against the last two commits' own intended behavior (their receipts
covered the e2e and Rust lanes but never the unit lane):

- Four api-mock factories predate the with429Retry wrapper the naming
  read now rides: the mocked module made it undefined, the bootstrap/
  conflict-refresh paths swallowed the TypeError, and the refetch-spy and
  rename-capture tests saw zero POSTs. Add the passthrough to each
  failing file's factory.
- UnifiedAgentRename's conflict fixture built the legacy raw-Error-with-
  'data' shape — exactly the masking bug the conflict-lane repair fixed
  (toRenameError reads the ApiError's parsed 'details' body). Rebuild the
  fixture as a real ApiError carrying details like production.
- The runtime-boundary inventory: register the two new executable
  provider fixtures (fake-codex-terminal, fake-opencode-terminal) as
  provider-fixture rows; allowlist the native session-names runner's
  transient port probe and the unified-agent-names e2e helper's fake
  Gemini listener in NON_BACKEND_LISTENER_PATHS with matching manifest
  rows (the same class as coordinator-endpoint and harness-06/fake-ai);
  ignore the untracked .tmp-native-smoke run root in the walker.

perWindowLayoutKeys was triaged first as possible-real-regression and
cleared: it passes solo and in company once the noise above is fixed;
the product files are unchanged since the lane was last green.
The sessions directory-overlay tests resolved their ClaudeSource root
through claude_home(&home), whose contract lets the process-global
CLAUDE_HOME env var win over the explicit home (the production override
parity). Parallel test modules mutate CLAUDE_HOME process-globally while
holding their own lock, so any test that does not take that lock can
resolve a FOREIGN temp home at construction and scan the wrong root
forever — deterministic in full-parallelism binary runs (the seed file
verified present on disk, the sweep succeeding with zero scan failures,
and the session still absent after 15s of explicit refresh polls), green
solo and at --test-threads=4. Pin home.join(".claude") directly in all
three tests: the env override belongs to the production boot wiring, not
to fixtures that own their temp home.
… budget

write_raw_document_bytes did a bare try_lock+expect on the sidecar lock,
so a transient in-flight store transaction turned the fixture into a
WouldBlock panic (observed as a rotating full-parallelism flake in
workspace-wide runs). The strict path's acquire_document_lock retries
contention for a bounded second — mirror that discipline in the helper.
…t fixture

The runtime allowlist requires claude-sidecar/session-names.mjs (the
unified naming writeback's sidecar entry), but the artifact-verification
fixture never wrote it — every probe-based rejection case failed with
'missing required file' instead of exercising the probe contract. The
fixture must construct a complete artifact per the current allowlist;
the composite runner had not reached the electron phase in recent gates
(rust-phase failures stopped it first), so the drift went unnoticed.
…end wire

The opencode (8b) server-writeback proof now follows the product's real
lifecycle: the freshopencode create answers created with the PLACEHOLDER
id by design (opencode_ws.rs: the binding row is written at materialization
— the first send), so the contract sends the first message over the WS
lane (the serve's session creation and the verified bind precede the
model turn, which may fail provider-less in the sandbox), then derives
the durable target from the naming state itself: reading the pane's
PENDING handle resolves through the store's redirect to the bound durable
ses_* record — the pane CONTENT's nameRef only advances when a real
client syncs its layout, which a raw-WS pane never does (observed: the
bind, the redirect, and the verified database location all landed while
the pane content kept the placeholder).
…stly

Task-008 review I-2, M-4, and M-6 on the cross-mode spec.

M-4: the swap request sent targetPaneId, which swap_pane ignores — the
swap was silently REFUSED forever (the endpoint answers HTTP 200 with
data.message only), and the old if(swap.ok) guard kept the sub-assert
green with nothing swapped. The body now sends the real key (target) and
the test pins the success shape (data.tabId, which the refusal arm
omits), polls the client layout until A's session genuinely lives in
B's pane id, then closes exactly that pane — the old ?? a.paneId
fallback would now close the wrong session's pane. Fixing the close
poll exposed resolveSessionIdOf reading a phantom redirect.to field
that never exists in the store (the real shape is {toKey, revision}); it
always returned null, masked while the swap was always refused. RED:
the flipped targetPaneId body failed the shape pin (data.tabId
undefined); GREEN: the case passes 7 consecutive full-file runs.

I-2: the vacuous toBeAttached(...).catch(()=>{}) no-op is gone. The
case now honestly asserts what IS true at the base contract: the shell
observably exits (the server's terminal directory records status
'exited', polled through /api/terminals) and the nonagent tab keeps its
OSC-derived title. The '(exit N)' suffix presentation defect stays
unasserted and is disclosed as registered follow-up T8-F1 (pre-existing
at base 6ee5cf4; the branch left terminal-title-policy.ts untouched).
Mutation RED: the flipped exit-status poll failed at its timeout.

M-6: an excluded coding-provider terminal pane (gemini via the existing
fake-gemini.mjs fixture at GEMINI_CMD) is now exercised: the tab's
nameSource stays legacy, the rendered title keeps a legacy derivation,
and the server-side pane content carries no namingHandle and no nameRef
(a flipped toBeDefined RED proved the fields are genuinely absent). The
rendered-title assertion tolerates both pre-existing legacy derivations
(the client's cwd basename vs the server's seeded provider label, which
a machine-snapshot restore fold imports nondeterministically) — both
predate the branch and neither is a unified session name.
The ready/reconnect collector pushed every terminal/fresh pane's
sessionRef into the batched name read, and parseSessionNameRef accepted
any string provider through an unsound cast — so a gemini/kimi/
amplifier pane made the server's strict scope gate reject the whole
100-ref chunk with 400 on every reconnect. Parse the session provider
through NamedProviderSchema so out-of-scope panes are never collected.
The ~5s auto-title sweep called hydrate_indexed (plus activity for
message-carrying sessions) for every scoped session on every pass — each
a strict cross-process transaction that locked/read/digested/parsed the
whole session-names document even for no-ops, O(H^2) per pass forever.
Adopted-view pre-checks in the needs_generation_input pattern now prove
the no-op from the in-memory view first (the activity pre-check runs the
same plan_activity policy the transaction applies, so they cannot drift),
with a per-pass settled memo for repeated rows. The view-accuracy and
cross-process staleness arguments live on the pre-check methods; a
data-dir-keyed test transaction counter pins zero transactions on a
second pass with a new-session convergence control.
Every Manual offer was accepted unconditionally, so repeating the exact
same explicit rename on an already-manual record allocated a new
revision and re-armed a fresh three-cycle/six-read native writeback
series — the plan forbids a new bounded series for equal unchanged
requests. The equal manual case now answers the unchanged winner (no
revision, no series, original renamedAt/manualRevision retained); a
same-text rename from a lower source still promotes, and a different
text still gets its own bounded series.
select_next ordered due series by the stable reference key (then cycles
consumed) and NativeWorkItem never carried next_due, deviating from the
plan's stated "then earliest nextDue/reference key" policy — a
persistently early-keyed series was served ahead of an earlier-due one.
The item now carries the series' next_due and the selector orders
manual-first, then earliest nextDue (a None due is immediately ready,
matching select_generation), with the reference key as the tiebreak.
Every native title observation — including each OpenCode session.updated
carrying a title — committed a bookkeeping-only document generation (a
full-document replace plus fsync) merely to persist last_observation.at
when nothing user-visible changed. Observation provenance now stages
only when a real change is committing anyway (an accepted offer or a
divergence rearm); an observation-only delta answers the unchanged
record without rewriting the document, and its timestamp rides the next
real write. The T3-M7 provenance assertions are re-contracted to the
same policy: no rename, no rearm, no error, and no durable write.
native_work_snapshot filtered due series with the real wall clock while
its sibling generation_work_snapshot uses the test-offset-aware
effective_now_ms — the due gate ignored the ClockOffsetMs hook, a trap
for any future clock-controlled native test. Aligned to the same
effective clock, with a focused test advancing virtual time through the
snapshot's due gate.
All eight tasks of the committed plan are implemented; the step
checkboxes stayed unchecked and the committed artifact misstated its
own status.
hydrate_indexed_decision aborted the whole hydration for a session
whose provider-authored title failed accepted-name validation (over
the 200-scalar cap or control characters), leaving the session with no
canonical record while the ~5s sweep re-failed and re-logged it every
pass. An unusable title is now an absent rung: the ladder falls to the
first-message extraction, then the directory basename, and a valid title
still wins.
Cross-process adoption published only records whose revision or source
changed, so a cooperating same-home process's status-only nativeSync
transition (a native fold or divergence rearm with no visible name
change) never reached the adopting process's WS subscribers until a
bootstrap or later change. The adoption delta now also carries records
whose nativeSync projection moved, as a changed:false frame — the same
status-only fold discipline clients already apply by documentGeneration.
Delta-review round 4, finding 1 (the seam half): the auto-title sweep's
kilroy discrimination becomes the ONE shared predicate every server
surface consults (crates/freshell-server/src/kilroy_lane.rs) — a session
is kilroy-only iff the SESSION-06 metadata types it kilroy AND the naming
authority holds no canonical record for it AND no live terminal runs it
in a scoped mode. The new record component fixes the sweep itself: a
kilroy-typed row that already holds a canonical record (a landed migration
chunk, an earlier pass's hydration, a create-lane bind) now stays in the
naming-authority lane — its one singular name is the supported-mode
record's (the Global Constraint's never-a-competing-kilroy-record rule),
and the legacy ladder stops writing it a competing settings title.
RED: a_kilroy_typed_row_with_a_canonical_record_stays_in_the_authority_lane
(observed failing: the ladder wrote the competing title). GREEN: the full
kilroy suite + the sweep module (25/25).
Delta-review round 4, finding 1 (surfaces 1-2): PATCH /api/sessions/:key
with titleOverride and POST .../generate-title routed every provider-claude
request into the naming authority, so a kilroy-only session (no naming
record, by the sweep's own design) answered 404 NAME_NOT_FOUND on rename
and 400 NAME_RESET_UNSUPPORTED on its still-offered reset, and
generate-title lost kilroy's retained server-side AI titling. Both routes
now consult the shared kilroy-lane seam first: a kilroy-only session
(metadata-typed kilroy, no canonical record, no live scoped terminal)
keeps the legacy override ladder / the retained Gemini ladder, while a
dual-mode session (canonical record or live scoped terminal) still
renames through the ONE authority — never a competing kilroy record.

RED (provider-only routing restored temporarily): the kilroy rename
answered 404 NAME_NOT_FOUND; kilroy generate-title answered {title:null}.
GREEN: a_kilroy_only_session_rename_keeps_the_legacy_override_path,
kilroy_generate_title_keeps_the_retained_server_side_ai_titling, plus the
dual-mode guard a_dual_mode_kilroy_typed_session_renames_through_the_authority
(green before and after — it pins the never-over-exclude half). The
sessions-router test helper is now metadata-aware (wired like main.rs);
sessions:: 25/25 and session_name_routes 15/15 green.
Delta-review round 4, finding 1 (surface 3): once the legacy-name
consolidation's receipt commits, the directory's settings title lane
closed for ALL provider-claude rows, so kilroy rows stopped displaying the
legacy-ladder titles the sweep deliberately still writes for them. The
page's kilroy-only rows are now answered by the shared kilroy-lane seam
(one hoisted metadata read per request, shared with the sessionType
overlay; registry: None — the record component owns the dual-mode
decision) and keep the lane open; a record-holding kilroy-typed row stays
closed (the supported-mode record owns its one singular name).

RED: kilroy_only_rows_keep_displaying_legacy_titles_after_the_migration_receipt
(observed failing: the row displayed its parsed title instead of the
legacy override). GREEN: it passes, plus the dual-mode guard
a_record_holding_kilroy_typed_row_keeps_the_title_lane_closed (green
before and after). session_directory lane 95/95 green.
Delta-review round 4, finding 1 (surface 4): the boot consolidation's
gather/import/clean pass absorbed every provider-claude override row and
metadata derivedTitle, minting the Global Constraint's forbidden competing
Kilroy record for kilroy-only sessions and re-wiping the sweep's
post-migration kilroy titles at each boot as late legacy evidence. The
gather now consults the shared kilroy-lane seam and excludes kilroy-ONLY
rows from import AND from the cleanup/late-evidence targets — they stay in
the legacy lane, untouched, which makes the module doc's contract true.

The dual-mode subtlety is reasoned in the gather's doc and pinned by test:
the exclusion is per-ROW (metadata-typed kilroy AND no canonical record);
the identity ledger is empty at consolidation time, so the record is the
operative evidence — a session whose canonical record exists through the
claude mode still imports and cleans its legacy rows exactly like any
scoped row (the supported-mode record owns its ONE singular name, no
second record minted).

RED: kilroy_mode_override_rows_stay_legacy_never_imported_or_cleaned
(observed: a canonical record was minted) and
post_migration_kilroy_titles_survive_the_late_evidence_pass (observed:
the fresh kilroy title was wiped). GREEN: both pass, plus the dual-mode
guard a_dual_mode_session_with_a_canonical_record_still_migrates_its_legacy_row
(green before and after). session_name_migration lane 27/27 green.
Delta-review round 4, finding 2 (re-found from the focused round): in
observe_native_decision a diverging observation on a non-settled armed
series stamped its None next_due to now even when nothing else changed,
then returned the early Decision::Read — and when that same transaction
was the first to adopt a newer external generation, adopt_if_newer
installed the locally-mutated copy into the view while the stored digest
described the unmutated disk bytes. The stamp now rides ONLY the rearm
commit (changed || status_changed), restoring the
only-Decision::Write-mutates invariant. Semantically inert by
construction: a None due is immediately ready (the work snapshot's
convention) and an unsupported series never enters the snapshot.

RED: an_observation_read_path_never_mutates_the_adopted_document
(observed: the adopted view carried next_due Some(..) while the freshly
parsed disk document said None, at an adopted newer generation).
GREEN: the byte-identity invariant holds; session_names 29/29 and
session_name_native 35/35 green.
Delta-review round 4, finding 3: parse_rename_intents silently dropped a
non-integer or beyond-JS-safe ifRevision (as_u64 + ceiling filter mapped
every malformed value to no-CAS), degrading a malformed compare-and-set
guard to an unguarded rename. Every malformed shape is now the same loud
400 the helper's own nameIntent handling and the canonical serde route
already apply; omitted/null stays no-CAS and a valid integer within the
JS-safe ceiling is still honored.

RED: a_malformed_if_revision_is_rejected_loudly_not_silently_dropped
(observed: ifRevision "12" answered 200 and renamed unguarded).
GREEN: all malformed shapes 400 with the name untouched; a matching valid
revision renames, a stale one still answers 409. session_name_routes
16/16 green.
Delta-review round 4, finding 4 (Nit): the Task 5 middleware-chain edit
left four stray spaces between the getDefault close brace and the
.concat( call. Typecheck clean; the store client unit lane (79 files,
1375 tests) stays green.
Final-gate flake fix (the recorded gate failure is the RED evidence): 2 of
1128 --bin freshell-server tests failed in the gate's rust phase —
claude_unreadable_projects_root_defers and claude_unreadable_project_subdir_defers,
panicking 'an unreadable projects root must DEFER (false), never delete'.
Both are deterministic solo (0.00s) and the pristine base (6ee5cf4) passed
the full bin lane clean, so this is not the receipted rotating load-timing
family. Diagnosis: session_directory::claude_home lets a process-global
CLAUDE_HOME win over a test's explicit home, so while a concurrent test in
the same binary holds CLAUDE_HOME at its own temp home (whose projects tree
is readable and holds no sess-1.jsonl), the deferral tests' production path
(transcript_definitively_absent) resolved the FOREIGN root and answered
'definitively absent'. The branch's own native route-discovery tests are the
only setters of CLAUDE_HOME (4 set/restore sites), and the ~150 added tests
widened the parallel window until the race fired.

Fix: one crate-wide test-only lock — crates/freshell-server/src/test_env_lock.rs
(a tokio::sync::Mutex: async mutators hold it across awaits, sync readers take
blocking_lock), mirroring the HOME_ENV_TEST_LOCK / test_clock_gate precedent.
Every same-binary CLAUDE_HOME/CLAUDE_CONFIG_DIR mutator now holds it for the
whole set…restore window (the two discovery tests; also the three
claude_exact_id_fallback tests, whose CLAUDE_HOME unsets were outside it —
they take HOME_ENV_TEST_LOCK first, the documented HOME→CLAUDE order), and
every env-first reader that cannot pin its root directly holds it too (the
transcript_definitively_absent family in main.rs — including the two
gate-failing deferral tests — and the list_claude_sessions fixtures in
session_directory). Readers that pin roots directly (sessions_tests,
existence) need nothing; grep-verified no same-target mutator remains
outside the lock. All assertions untouched; guards only.

Verification: claude_unreadable 2/2 and the native lane 35/35 solo; the full
--bin freshell-server lane run repeatedly under ambient host load (load
average ~42, mirroring gate contention): 3 consecutive zero-failure runs
(1128 passed each); the only failures observed in any run were the
previously receipted rotating load-timing family (undelivered_failures…
60ms retry-floor window, worker_contention — pre-existing at base per the
gate-g2 stash-compare attribution, isolation-green, deferred as a follow-up
task), never the deferral pair. cargo fmt --check and clippy clean.
Two unnecessary-get-then-check lints in session_names_tests (contains_key)
and one sync-MutexGuard held across an await in the rename intent test
(block-scoped before the next oneshot). No behavior changes; the three
covered tests stay green.
The Windows electron job failed our staged session-names.mjs execution
acceptance: the FRESHELL_CLAUDE_SDK_SESSION_NAMES_MODULE env carried a raw
absolute path, and Node's ESM loader only accepts file/data/node schemes on
Windows (import('/abs') works on POSIX, import('C:\...') does not). Convert
the env value with pathToFileURL(...).href at both test call sites (the
electron staging acceptance and the sidecar unit harness), matching the
caller-side conversion convention the other sidecar modules already rely on.
Production never sets this env. RED: CI job 107072714289 (windows-2022,
'stages the portable runtime from pnpm deploy exports'); GREEN: 15/15 and 8/8
locally on POSIX, Windows job re-verified by CI.
@danshapiro
danshapiro merged commit 509be13 into main Sep 23, 2026
7 checks passed
@danshapiro
danshapiro deleted the the-usual/unified-agent-names branch September 23, 2026 07:44
pull Bot pushed a commit to HinchK/freshell that referenced this pull request Sep 23, 2026
The T9 dual-manager hook rewrite (merged in danshapiro#820 as fb5d524) lost the
'#!/usr/bin/env bash' interpreter line. Git executes hooks directly, and a
shebang-less script is executed under sh/dash, whose parser rejects the
hook's herestring at the manager-selection block: 'Syntax error: redirection
unexpected' at line 180 — every push from every worktree failed.

The regression was masked all run: core.hooksPath serves the MAIN checkout's
working-tree hook, and local main was fast-forwarded to the danshapiro#820/danshapiro#819 era
only after the migration branch had been pushed — so the first push after
the fast-forward was the hook's real debut. The T9 hermetic tests invoked
the hook via bash explicitly, which is why the gap survived review.

Guard tests added to prepush-manager.test.ts: the hook must declare a bash
interpreter (proven red against the shebang-less file) and stay parseable by
bash -n.

Pushed with --no-verify per the documented bypass: the broken hook is the
artefact being fixed; the commit is the two-line shebang + guard tests, and
CI validates the branch fully on the PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant