Skip to content

perf(claude-ops): audit only transcript lines appended since last Stop - #4192

Merged
kyle-sexton merged 13 commits into
mainfrom
claude-ops-hook-failure-audit-sets-the-s
Sep 16, 2026
Merged

kyle-sexton merged 13 commits into
mainfrom
claude-ops-hook-failure-audit-sets-the-s

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

No related issue: handoff-inbox item 20260913-034033 under program item 20260915-153000 (spawn budget per tool call); no GitHub issue was filed.

Stacked on #4185 (base branch guardrails-run-guards-in-process-no-subs): this hook rides on that PR's hook::buffer_stdin_to field read in the synced lib. GitHub retargets it to main when #4185 merges.

Summary

claude-ops' hook-failure-audit.sh Stop hook set the per-turn wall: it re-scanned the whole session transcript on every stop (a wc, a grep, a jq over every candidate) to discover that no new hook_non_blocking_error record had appeared, which is the common case.

Fix

  • A per-session cursor, ${CLAUDE_PLUGIN_DATA}/hook-failure-audit/<session>.cursor, holds the count of complete lines already audited and the transcript_path it was taken against, beside the existing warning marker and under the same 7-day prune.
  • A warm Stop reads with mapfile -s from one line before the cursor (that line is an anchor: the same read that fetches new lines proves the file still has that many; an empty result means shrinkage), prefilters candidates in bash with the same fixed string the grep used, and runs jq only for a candidate line. mapfile runs without -t so joined candidates are byte-identical to $(grep …); a final line with no newline is scanned but not counted, so it is re-read next turn.
  • The cursor resets to 0 (a full rescan) on: no data home, malformed cursor, different transcript_path, fewer lines than the cursor, pruned cursor, or bash without mapfile (the old grep path). It advances only at the four disposal points (no candidate, empty summary, nothing new, after the system message); a jq failure leaves it put. Rescanning cannot re-warn, because the marker still decides that.
  • The cold scan keeps its tail cap; one wc -lc now answers both the cap decision and the cursor's starting line count.
  • Payload fields ride on hook::buffer_stdin_to INPUT '.transcript_path' '.session_id', which fuses the library's validation probe into the builtin field read; hook::require_jq moves after it.

claude-ops is bumped 0.56.14 to 0.56.15 with the numbers in the CHANGELOG. Sibling #4189 also claims 0.56.14 on its branch; whichever merges second needs its version and CHANGELOG re-based (parity is checked against origin/main).

Verification

Job-object census (n=5, Windows Git Bash; floor 3 = bash -c, env, bash):

Arm Creations before Creations after
second Stop, 20 benign lines appended (the common turn) 10 3 (= floor)
first Stop, data dir exists 10 5
first Stop, fresh data dir 10 7
second Stop, a failure record appended 30 21

Wall clock after 0.23 to 0.36 s where before ran 0.36 to 1.38 s across two runs; the host drifts about 4x within an hour, so the creation count is the record.

Byte identity, old versus new hook on fresh data dirs, identical stdout: a single record; three mixed classes; multiple registrations plus both false-positive shapes; an over-cap transcript; a last line without a trailing newline; and a two-turn incremental sequence where turn 2 appends a failure past the cursor. The suite asserts the same in-tree: incremental turn-2 output equals a full rescan against identical marker state.

Suites: hook-failure-audit.test.sh 84/4 to 94/4 (10 new assertions; the 4 failures are pre-existing on this host, a Windows CRLF artefact in jq … @tsv on HAS_COMPLETED). Discrimination checked: the jq PATH-shim test fails against the old hook, and the path-reset test fails against a copy with the path check removed. Degraded paths: a no-mapfile copy warns then dedups, an unwritable marker home still warns, an empty transcript is silent. shellcheck, shfmt, markdownlint and check-changelog-parity.sh --check-bump origin/main clean.

Unproven: the strace budget block runs only on CI's Linux lane; the warm ceiling (0 creations, 0 execs) follows from the Windows census at floor, the cold ceilings (6 creations, 2 execs) are conservative estimates and are what to adjust if CI reports otherwise. The bash 3.2 fallback was exercised by forcing HAVE_MAPFILE=0, not on a real bash 3.2. An over-cap cold scan sets the cursor from the whole file's line count while reading only the last 2 MB; pre-window lines were never read before either, and the cursor makes that permanent for the session.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 5 commits September 15, 2026 13:51
…ields without jq

run-guards.sh sources its guards into its own shell instead of one
command-substitution subshell per guard: `exit` is a dispatcher function
that records the guard's status and runs the next guard from inside the
call, stdout documents are collected through hook::emit_document, and a
guard that dies of a hard error hands its status to the abort boundary's
new chain slot (_GAB_CONTINUE), which runs the guards still owed in one
subshell. Per-invocation analysis state (the alias memo) is reset before
each guard, so a later guard's alias walk is not answered by an earlier
guard's memo.

lib/hook-utils.sh answers a well-formed payload's plain-string fields
with its builtin JSON parser (hook::_fast_fields) and runs jq only for a
shape it cannot prove; hook::jq_fields_uncached names the same body for
the dispatcher's cache, hook::emit_document is the one stdout path, and
hook::extract_bash_subject_to is the in-shell telemetry subject. The 17
carrying plugins take the synced copy with a patch bump.

Measured on Windows 11 + Git Bash with an exact job-object census of the
harness's own invocation: the Bash-lane chain went from 23 process
creations to 3 and 880 ms to 285 ms isolated p50; the PowerShell lane
from 100 to 80 creations and 3.3 s to 2.7 s. Decisions are byte-identical
(rc, stdout, stderr) against 0.33.11 over the perf baseline's 17-command
corpus in both tool modes, over 653 commands harvested from the guard
suites on the Bash lane and 200 on the PowerShell lane, and over the
Write, Edit and drive-root-tmp lanes.

Handoff item: 20260913-034031 (program 20260915-153000).

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
hook::extract_bash_subject_to assigns SUBJECT through a nameref, which
shellcheck 0.11 cannot follow; CI's lint lane failed SC2154 on the two
guards that read it in emit_tel. Declaring the variable empty first is
the idiom block-hook-bypass already uses. block-no-verify 256/0 and
flag-commit-pr-skill-bypass 35/0 unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Stop hook re-read the whole tail window on every turn: a `wc`, a `grep`,
and the library's stdin validation probe fired before it could learn that
nothing new had failed. The transcript is the session's own JSONL and grows
every turn, so no mtime or size sentinel on the file can answer "nothing new
to audit" — the signal has to be about the appended content.

A per-session cursor beside the warning marker records how many transcript
lines have been audited. A later Stop reads only the lines past it with
`mapfile`, pre-filters them with the same fixed string the grep used, and
exits having created no process. The cursor advances only once the lines it
covers are disposed of: no candidate, an empty structural selection, nothing
left unwarned, or a warning emitted. A jq failure leaves it where it stood.
Missing, malformed, pruned, ahead of a shortened transcript, or recorded
against a different transcript_path all reset it to a full rescan, and a
rescanned line cannot re-warn because the (hookName, command) marker still
decides that.

Job-object process accounting on Windows Git Bash, five runs per arm against
a 3-creation harness floor (`bash -c`, `env`, `bash`):

  turn with no new failure   10 -> 3    (the floor: the hook creates nothing)
  first Stop of a session    10 -> 5 (7 when the data dir is created)
  turn with a failure        30 -> 21

Wall clock is not the record here — the same before-arm measured 0.36 s and
1.38 s an hour apart on this host — but the after-arm held 0.23-0.36 s across
both runs where the before-arm ran 0.36-1.38 s.

Findings are unchanged byte for byte. Proven by diffing this hook's output
against the pre-change script over five fixtures (single record, three mixed
classes, several registrations with both false-positive shapes, an over-cap
transcript, a last line with no trailing newline) and over a two-turn
incremental sequence; the suite now asserts the same equality in-tree, plus a
PATH shim that fails loudly if any of jq/grep/wc/tail/sed/find/cat/mkdir runs
on a second Stop.

The payload fields now ride on `hook::buffer_stdin_to`, which fuses the
library's `jq -e .` probe into the field read. That is why this branch first
merges guardrails-run-guards-in-process-no-subs, which carries PR #4185's
lib/hook-utils.sh sync: `hook::jq_fields` answers plain-string payload fields
(transcript_path, session_id) with a builtin parser and spawns no jq.
`hook::require_jq` moves after the fused call, because without jq the library
returns an empty field array and reading it under `set -u` would kill the
hook rather than fail open.

The cold path keeps the tail cap — it is the one read the cursor cannot bound
— and pays one `wc -lc`, which answers the cap decision and the cursor's
starting line count in a single process.

Bumps claude-ops 0.56.14 -> 0.56.15. The sibling branch
claude-ops-session-event-log-spawns-whil (PR #4189) also bumps claude-ops to
0.56.14, so whichever lands second needs its entry renumbered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI's machine-specific-paths hygiene check refuses a Windows user path
in a fixture. The value is opaque to the parser under test; hook-utils
504/0 unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton marked this pull request as ready for review September 16, 2026 00:04
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-16T00:07:49.760438Z d41207a Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 6m 28s —— View job


Claude finished the /review:code-review pass

  • Gather context (PR diff, files, custom instructions)
  • Invoke /review:code-review per custom instructions
  • Post inline comments for line-anchorable findings (none met the bar — see below)
  • Post summary comment with cross-file findings and overview

Summary

Reviewed the full diff against origin/guardrails-run-guards-in-process-no-subs (4 files, +347/-104), scoped to /review:code-review's criteria (correctness, maintainability; security is out of scope on this repo since claude-security-review exists). No blocking or high-signal findings — nothing an inline comment would improve on.

What I traced through in hook-failure-audit.sh:

  • Cursor arithmetic (L251-266): the mapfile -s $((CURSOR - 1)) anchor-line read, the shrink/reset-to-0 path when N == 0, and the trailing-newline check that excludes an incomplete final line from SCANNED all check out against the documented invariants — every failure mode (missing/malformed cursor, path mismatch, shrink, no mapfile) resolves to a full rescan, never to a silent skip.
  • Disposal points (L303-306, L345-349, L374-378, L447): cursor_advance is correctly skipped on every jq-failure exit and only called at the four points the PR body claims.
  • REVIEW.md's atomicity check (two related state files — the cursor and the warning marker — are now written non-atomically at different points): traced every interruption window between emit_system_message, cursor_advance, and the marker fingerprint write. Every crash point degrades toward re-warning, never toward silent suppression or a corrupted read (the cursor file's own read path rejects a malformed/partial write and falls back to a full rescan). That matches the file's own explicit fail-safe doctrine, so I'm not raising it as a gap.
  • Payload-field fusion (L85-92): confirmed plugins/claude-ops/hooks/hook-utils.sh already carries the filter-accepting hook::buffer_stdin_to this depends on (unchanged in this diff, inherited from stacked base perf(guardrails): run the guard chain in-process and answer payload fields without jq #4185), and that a jq-absent host still yields a 2-element-mismatched HOOK_JQ_FIELDS rather than an unbound-variable crash under set -u.
  • Skimmed the new hook-failure-audit.test.sh cursor-section tests (byte-identical incremental-vs-full-rescan assertion, shrink reset, transcript_path-mismatch reset, silent/no-spawn assertions) — the coverage matches the code paths above.

Unable to verify in this environment: this sandbox declined to run any shell script or lint tool here (bash hook-failure-audit.test.sh, shellcheck, shfmt all required interactive approval that wasn't available), so I could not independently execute the test suite, the strace budget block, or the shellcheck/shfmt/markdownlint/check-changelog-parity.sh claims in the PR description — those remain author-claimed and unverified by me. Everything above is from static reading of the diff and cross-referencing lib/hook-utils.sh/the synced plugin copy, not from a run I performed.

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 35s —— View job


Security review (/review:security-review)

  • Gather context (PR diff, changed files, custom instructions)
  • Invoke /review:security-review per custom instructions
  • Post findings

Scope: reviewed only the files changed in this PR (diffed origin/guardrails-run-guards-in-process-no-subs...HEAD): hook-failure-audit.sh, its test file, plugin.json, CHANGELOG.md.

No security issues found.

This is a performance refactor of an advisory, fail-open (exit 0 on every path), local-only Stop hook. It doesn't add a new external input surface — the trust boundary (payload from Claude Code's own hook envelope; the transcript file at the path that envelope names) is unchanged from before this PR. Specifically checked:

  • Path construction. CURSOR_FILE="$MARKER_DIR/${SESSION}.cursor" (hook-failure-audit.sh#L139) is built from SESSION, which is sanitized to [A-Za-z0-9_-] before this line (L101) — no traversal via a crafted session_id.
  • Injection. No new string ever reaches a shell command line or a jq program body unparameterized; the jq calls that changed (hook::buffer_stdin_to INPUT '.transcript_path' '.session_id' at L85) pass filters as fixed literals, not interpolated payload content. CURSOR_LINES is validated against ^[0-9]+$ before use in arithmetic context (L154), so no command-substitution risk in $((CURSOR - 1)).
  • New on-disk state (the cursor file). Lives under ${CLAUDE_PLUGIN_DATA}/hook-failure-audit/, the same plugin-owned, single-trust-level directory the pre-existing warning marker already used; nothing here widens who can write it.
  • Incremental-scan correctness vs. detection integrity. The cursor's "anchor" line (L251-L266) is checked for existence at the expected offset, not content equality, so a transcript rewritten in place at or below the cursor without changing the trailing line count wouldn't be detected as changed. This only matters if something other than Claude Code's own append-only writer mutates the transcript — a local actor with that capability already has stronger ways to suppress this advisory, fail-open detector (e.g. deleting the marker/cursor files, or the kill switch). Not a new trust boundary this diff introduces, so not reported as a finding.
  • Secrets/credentials. None handled or newly touched.
  • Actions hardening. No workflow files changed in this PR; out of scope for this lane regardless (zizmor's).
    · branch claude-ops-hook-failure-audit-sets-the-s

@github-actions

Copy link
Copy Markdown
Contributor

Last security-reviewed head: d41207a57cf6e1482ff1fd3f3efcf08313b03cd0. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d41207a57c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/hooks/hook-failure-audit.sh Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

kyle-sexton and others added 7 commits September 15, 2026 20:16
plugins/claude-ops/*.md is declared purged in scripts/em-dash-purged-paths.txt, so the lint lane treats one as a regression.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ty lint

The shell-portability lint reads the backslash-w in the previous spelling as a GNU-only regex class. The value is opaque to the parser under test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hook-failure-audit.sh validated the cursor's line count with a bare
^[0-9]+$ test, so a value like 08 passed but then failed bash's
octal-reading ((...)) arithmetic (08/09 error outright; a long enough
digit string wraps silently). Either way the Stop hook stopped running
its scan cleanly.

Tighten the check to ^(0|[1-9][0-9]{0,14})$: a canonical decimal capped
at 15 digits, well under 2^63. Anything else is rejected outright, not
coerced with 10#, and falls through to the existing CURSOR=0 cold-scan
path exactly like any other malformed cursor.

Adds cursor tests for a leading-zero value and a 20-digit value: no
shell diagnostic, a cold scan runs, and the cursor is rewritten to a
canonical value afterward. Adds a CHANGELOG bullet under claude-ops'
existing 0.56.15 entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ts key bound

hook::_fast_fields indexes the payload's key strings with an associative
array, which Bash added in 4.0, and it ran on every hook::jq_fields call.
On the 3.2 shell macOS ships, and which these hooks document support for,
`local -A` fails per call. hook::_fast_fields_supported is the predicate,
split out the way hook::read_supports_nchars is so a test can force the
below-floor branch on a modern host, and hook::jq_fields_uncached asks it
before entering the fast path. Below the floor jq answers, unchanged.

The index loop also skipped any string body longer than a fixed 60 bytes
before decoding it, while nothing capped the key names a caller may ask
for. Two wrong answers came out of that: a requested key longer than 60
characters was proven ABSENT while present, and a key of 11 or more
characters spelled with \u escapes (hook_event_name is 15, 90 escaped)
was missed the same way. The bound is now six times the longest requested
key name, the width of `\uXXXX` per identifier character, which is the
bound the header comment always described.

The suite gains four cases: the below-floor branch forced with the fast
path replaced by a tripwire, a present 70-character key, an absent one,
and hook_event_name spelled entirely in \u escapes. Each compares the
fast path against jq rather than against an expectation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The chain-slot paragraph asks a function plugged into _GAB_CONTINUE to be
builtins only, never exit, and never touch the trap, and the slot's own
comment says it must not return. run-guards.sh's consumer does none of
that: run_guards::guard_died forks a subshell for the guards still owed,
spawns jq to merge their documents, and ends at `builtin exit`.

Say so. run-guards.sh is the one documented exception, and it is one
because it is the dispatcher finishing the run the process owes rather
than a hook doing exit-time work. The discipline is unchanged for
everyone else, and the slot comment now matches it: a chained function
that returns hands control back and the handler settles the status.

Comment only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lliding plugins

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The base branch merged origin/main and renumbered its own claude-ops bump to
0.56.15, colliding with this branch's 0.56.15. This branch's two entries move to
0.56.16, above the base branch's hook-utils 0.56.15 and main's 0.56.14, and
plugins/claude-ops/.claude-plugin/plugin.json now reads 0.56.16.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Base automatically changed from guardrails-run-guards-in-process-no-subs to main September 16, 2026 12:29
kyle-sexton added a commit that referenced this pull request Sep 16, 2026
…anged (#4193)

No related issue: handoff-inbox item 20260913-034032 under program item
20260915-153000 (spawn budget per tool call); no GitHub issue was filed.

Stacked on #4185 (base branch
`guardrails-run-guards-in-process-no-subs`): the envelope parse rides on
that PR's `hook::jq_fields` builtin parser in the synced lib. GitHub
retargets it to main when #4185 merges.

## Summary

context-guard's `zone-crossing-inject.sh` fires on every `PostToolBatch`
and `UserPromptSubmit`. Each fire spawned a jq for the envelope and then
the zone resolver (its own bash plus a jq) even when nothing the
resolver reads had changed since the last fire, which is the common
case: the statusline snapshot is rewritten only when the statusline
renders.

## Fix

- An unchanged-input skip. A `$STATE_DIR/$SESSION.seen` mark records the
inputs behind the last completed resolve in two ways: its mtime, stamped
with a redirection and compared with `-nt`, and one flags line (`z=<0|1>
c=<0|1>`) recording whether `zones.json` and the compaction marker
existed when that resolve ran, read back with builtin `read`. The fire
exits before starting a process only when the per-session snapshot,
`zones.json` and the compaction marker are all no newer than the mark
AND both existence flags still match the current `-e` results; a mark
with no readable flags line never takes the skip. The mark moves only
after the resolve persisted its markers, so a resolver failure, an
`unknown` reading and a failed marker write are each retried next fire.
A missing snapshot is never skippable. Skipping can only choose silence:
no arrangement of timestamps or existence changes can manufacture an
injection the full path would not have made.
- The envelope parse is size-branched. Under 64 KiB it goes through
`hook::jq_fields`' builtin parser (zero spawns); above it keeps the
single here-string jq (2 creations), because the helper's oversize
fallback reads through a process substitution and measured 4. Plain
routing through the helper would have made the large-payload path 9 to
11 creations; the branch is what keeps every cell at or below before.
The `65536` literal mirrors the helper's private proof ceiling and is
documented at the site.
- `STATE_DIR` resolution moves ahead of the resolver, so a session with
no state root exits one process earlier.

context-guard is bumped 0.7.64 to 0.7.65 with the numbers in the
CHANGELOG, and the README gains a "Skipping the resolve when nothing
moved" subsection carrying the table.

## Verification

Job-object census (n=5, identical across reps; subject floor 3 = `bash
-c`, `env`, bash):

| Fire | Payload | Creations before | Creations after |
|---|---|---|---|
| first (resolves) | small | 11 | 9 |
| repeat, nothing moved | small | 9 | 3 |
| snapshot rewritten | small | 9 | 7 |
| first | 150 KB | 11 | 11 |
| repeat, nothing moved | 150 KB | 9 | 5 |
| snapshot rewritten | 150 KB | 9 | 9 |

No cell is worse than before. Small repeat fire wall p50 1448 ms to 237
ms (re-run 365 ms; the host is bimodal). On Windows a resolve costs 4
creations rather than 2 because `bash "$RESOLVER"` hits the
`bin\bash.exe` wrapper, which re-spawns `usr\bin\bash`.

Crossing messages are byte-identical, asserted in the suite against a
control session driven through the same zone sequence with no skipped
fire. Suite: `zone-crossing-inject.test.sh` 78/1 to 97/2, where both
failures are `strace: no usable trace` on this host (Git Bash's cygwin
strace rejects `-e trace=`; the second is the new strace block, not a
regression); `zone-gate` 26/0, `post-compact-mark` 18/0. shellcheck
clean; `check-changelog-parity.sh --check-bump origin/main` and
`--check` green.

Residuals: the strace pins (steady fire 0 creations / 1 execve;
resolving fire 2 / 3) are set by reasoning and verified only on CI's
Linux lane. The one miss window is the resolve itself: a snapshot
written between the resolver's read and the stamp is marked seen and its
crossing is reported one fire late, never lost, since the statusline
rewrites the snapshot on its next render; on a filesystem or bash build
that compares mtimes at whole-second granularity the window is up to one
second. The README still carries the older "0.7.49 brought it to 3"
paragraph; the new subsection supersedes it.

## Related

- #4185 (base of this stack). Program item 20260915-153000; siblings
#4188, #4189, #4190, #4191, #4192.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ump claude-ops to 0.56.17

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton merged commit e155902 into main Sep 16, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the claude-ops-hook-failure-audit-sets-the-s branch September 16, 2026 13:08
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