Skip to content

Replace auto-reindex hooks with staleness signals + background-bootstrap status#61

Merged
elliottregan merged 17 commits intomainfrom
feat/search-staleness-signals
Apr 22, 2026
Merged

Replace auto-reindex hooks with staleness signals + background-bootstrap status#61
elliottregan merged 17 commits intomainfrom
feat/search-staleness-signals

Conversation

@elliottregan
Copy link
Copy Markdown
Owner

@elliottregan elliottregan commented Apr 22, 2026

Summary

  • Remove auto-reindex hooks from lefthook.yml — the post-commit, post-checkout, and post-merge hooks that fired cspace-search index on every git event are gone. They were more costly than useful for typical commits.
  • Add staleness detection (search/corpus/staleness.go) — cheap checks that compare git state against qdrant without re-embedding. CodeStaleness diffs git ls-files hashes; CommitsStaleness compares commit counts.
  • Add index status file (search/status/) — atomic JSON status file at .cspace/search-index-status.json tracking per-corpus state (completed/failed/disabled), in-progress runs with throttled progress, and timestamps.
  • cspace search status CLI command — human-readable and --json output showing all corpus states, staleness, and any running index. Added to both cspace search and standalone cspace-search.
  • Staleness warnings in query responses — queries now annotate the Envelope.Warning field when the index is stale, across CLI and MCP surfaces.
  • search_status MCP tool — structured status output for agents to check index health before high-stakes queries.
  • Updated using-semantic-search skill — replaced auto-refresh docs with the new manual workflow: check status, reindex when stale.

Related decision: .cspace/context/decisions/2026-04-22-scope-cspace-search-to-code-commits-close-docs-decisions-con.md

Checklist

  • Remove auto-reindex hooks from lefthook.yml
  • Add staleness detection (search/corpus/staleness.go)
  • Add status file writer + types (search/status/)
  • Add cspace search status CLI command (both surfaces)
  • Surface staleness warnings in query responses (CLI + MCP)
  • Expose search_status MCP tool
  • Integrate status file writes into index.Run
  • Update using-semantic-search skill
  • Unit tests for staleness, status, and MCP

Test plan

  • go test ./search/... — all search package tests pass (corpus, status, mcp, index, query, config, cluster)
  • go vet ./... — clean
  • go build ./... — clean
  • Manual: cspace-search status shows correct output for never-indexed, disabled, completed, and stale corpora
  • Manual: cspace-search status --json emits valid structured JSON
  • Manual: cspace-search index --corpus=code writes status file with correct state and count
  • Manual: verify phase 16 background bootstrap still works in a fresh cspace up (requires live container)
  • Manual: verify search_status MCP tool returns correct output via cspace search mcp stdio

Additional: hook/CI hardening

Audit findings

  1. rc: config was broken (root cause Route implementer through supervisor and persist event log #1)lefthook.yml had rc: export PATH="$HOME/go/bin:$PATH" which is a raw shell command, but lefthook's rc: expects a file path. The generated hook script rendered this as [ -f export PATH="..." ] && . export PATH="..." — a file-existence test on a nonsensical filename. It never matched, so ~/go/bin was never added to PATH, and golangci-lint/goimports were invisible to the hooks.

  2. --new-from-rev=HEAD~1 was too narrow (root cause Docs: Scaffold Astro + Starlight project #2) — Pre-commit lint only flagged issues introduced since the prior commit. Pre-existing lint in touched files slipped through, and if a prior commit was made without hooks (due to root cause Route implementer through supervisor and persist event log #1), the delta was against an unchecked baseline.

  3. lefthook install was never automated (root cause Docs: Scaffold Astro + Starlight site in docs/ #3)make setup-hooks existed but was not called by make build, make test, or any commonly-run target. The README didn't mention it. Fresh-clone contributors had no hooks active.

  4. CI lint was PR-only (root cause Docs: Getting Started guides #4) — The lint job had if: github.event_name == 'pull_request', so pushes to main (e.g. admin merges) skipped lint entirely. The check job ran vet and test but not fmt-check, letting format regressions through.

  5. No --no-verify abuse found — No commits in history used --no-verify.

  6. No core.hooksPath override — Git config was clean.

Changes

lefthook.yml + .lefthookrc (new file):

  • Extract export PATH="$HOME/go/bin:$PATH" into .lefthookrc; point rc: at the file so the hook script sources it correctly.
  • Widen pre-commit lint from --new-from-rev=HEAD~1 to full golangci-lint run ./....
  • Add vet-go (go vet ./...) as a parallel pre-commit step gated on *.go files.

Makefile:

  • setup-hooks now fails loudly if lefthook is not in PATH (instead of silently succeeding via the fallback chain in the hook script).
  • New check-hooks target warns to stderr if .git/hooks/pre-commit is missing; wired into build so contributors notice on first build.

.github/workflows/ci.yml:

  • Removed if: github.event_name == 'pull_request' from the lint job — lint now runs on pushes to main too.
  • Added Format check step (make fmt-check) to the check job.
  • Note for maintainer: mark check, lint, and integration-search as required status checks in repo settings after merge.

README.md:

  • Added make install-tools and make setup-hooks to the Development section.
  • Added a "Git Hooks" subsection explaining the lefthook setup.

Lint fixups (surfaced by the stricter pre-commit lint):

  • internal/cli/search.go — gofmt: removed trailing whitespace in struct tag alignment.
  • search/corpus/staleness.go — ineffassign: removed unused headHash variable (the function validates git state via rev-list --count).
  • search/mcp/server_test.go — staticcheck SA1012: replaced nil context with context.TODO() in test calls.

🤖 Generated with Claude Code

Addressed review feedback

All items from the review comment are now fixed:

# Severity Issue Commit
1 🔴 Critical Data-loss race in runSearchInit — outer writer clobbers inner writers 94c8b6d
2 🔴 Critical CommitsStaleness permanent false positive under commits.limit 3c576c9
3 🟠 Significant Staleness check costs 100–500ms on every query — added 30s TTL cache 0d6cd6c
4 🟠 Significant CodeStaleness misses deleted-but-still-indexed ghost entries 494828c
5 🟡 Minor Duplicate status logic between MCP and CLI — extracted status.Compute c76d629
6 🟡 Minor Writer.flush swallows errors silently — added ErrLog callback c76d629
7 🟡 Minor 30s git ls-files timeout too generous on hot path — reduced to 5s 0d6cd6c
8 🟡 Minor Redundant time.Now().UTC() calls — capture once per mutator c76d629
9 🟡 Minor DisableCorpus flushes unnecessarily — added no-op short-circuit c76d629

New tests added

Durable context decisions logged

5 pattern decisions logged to .cspace/context/decisions/ in 4171564:

  1. Writer instances must refresh state before writing OR be single-use
  2. Don't compare counts when limits are in play — compare identities or timestamps
  3. Secondary work injected into query paths needs a latency budget
  4. Delta detection needs symmetric difference, not one-sided membership
  5. Don't use planet names for agent-spawned cspace instances

Instance naming guidance

Updated lib/skills/delegating-container-agents/SKILL.md, lib/agents/coordinator.md, and CLAUDE.md to note that planet names are reserved for the TUI — agents should use descriptive names (issue-<n>, cs-agent-<n>, etc.) in 1b3fcd0.

Search bootstrap now opt-in

b1980b5 — Phase 16 (search bootstrap) is now role-aware:

  • cspace up: NO auto-index by default; pass --index to opt in
  • cspace advisor up: auto-index (session-continuous, search-heavy)
  • cspace coordinate: auto-index for the coordinator
  • Logged decision Docs: Architecture & Concepts #6: "Bootstrap search indexing is opt-in; advisors and coordinators get it by default"

🤖 Generated with Claude Code

elliottregan and others added 5 commits April 22, 2026 02:22
The lefthook hooks that auto-reindexed on every commit, checkout, and
merge are more costly than useful — typical commits touch a handful of
files, making a full reindex wasteful. Staleness signals (next commits)
will let agents reindex deliberately before high-stakes queries instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
search/corpus/staleness.go: CodeStaleness compares git ls-files hashes
against qdrant; CommitsStaleness compares commit count against indexed
point count. Both are cheap (sub-second on typical repos).

search/status/: New package for tracking index run state. Writes
.cspace/search-index-status.json atomically (write tmp + rename).
Tracks per-corpus state (completed/failed/disabled), in-progress runs
with throttled progress updates (~1/sec), and preserves prior corpus
states across Writer instances.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- index.Run now accepts an optional StatusWriter interface to report
  start/progress/finish/fail lifecycle events to the status file.
- Both CLI surfaces (cspace search, cspace-search) wire a status.Writer
  into every index run, including init loops which also mark disabled
  corpora.
- `cspace search status` (and `cspace-search status`) reads the status
  file, checks staleness for code/commits corpora against qdrant, and
  renders a human-readable summary. --json flag for programmatic use.
- Query paths in both CLIs and the MCP server now append staleness
  warnings to the response envelope when the index is out of date.
- New `search_status` MCP tool returns the same structured status output
  so agents can check index health before high-stakes queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace auto-refresh documentation with the new manual workflow:
check `cspace search status` or `search_status` MCP tool before
high-stakes queries, reindex explicitly when stale, and surface
failures before trusting results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 22, 2026

Deploy Preview for cspace-cli canceled.

Name Link
🔨 Latest commit cc18f84
🔍 Latest deploy log https://app.netlify.com/projects/cspace-cli/deploys/69e834ffd4734f0008665402

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 22, 2026

Deploy Preview for cspace-cli canceled.

Name Link
🔨 Latest commit 84948bb
🔍 Latest deploy log https://app.netlify.com/projects/cspace-cli/deploys/69e845e0c8d8a800080fcf25

@elliottregan
Copy link
Copy Markdown
Owner Author

Review: staleness signals + status file

Good structure and clean mapping of commits to tasks. Two blocking bugs before merge, plus some significant-cost concerns and minor polish.


🔴 Critical

1. Data-loss race in runSearchInitcontext/issues disable wipes prior code/commits state

In internal/cli/search_init.go, the init loop creates an outer status.Writer:

sw, _ := status.NewWriter(root)
for _, corpusID := range []string{"code", "commits", "context", "issues"} {
    err := runSearchIndex(corpusID, true)
    switch {
    case errors.Is(err, config.ErrCorpusDisabled):
        sw.DisableCorpus(corpusID)
    ...

runSearchIndex internally creates its own status.Writer per call and persists Last[code]=completed, Last[commits]=completed. The outer sw captured its in-memory snapshot at iteration start (empty) and never refreshes. When the loop reaches context (disabled) and calls sw.DisableCorpus("context"), the outer writer flushes its stale empty state + the new context entry, clobbering the code/commits entries from disk.

Net effect with default config (code+commits enabled, context+issues disabled): after cspace search init, the status file shows only context: disabled, issues: disabled — the two corpora you actually care about are erased.

Fixes in order of preference:

  • Move the disabled-state write into runSearchIndex itself (detect ErrCorpusDisabled from config.Build, write DisableCorpus with a fresh writer, return). Init loop then doesn't need its own sw.
  • Or: make status.Writer.DisableCorpus (and every mutator) re-read the file before merging its in-memory delta.

Either path needs a regression test that exercises the full init sequence and asserts all four corpora have the expected state afterward.


2. CommitsStaleness permanently reports false positives when commits.limit is set

search/corpus/staleness.go::CommitsStaleness compares rev-list --count HEAD against len(existing). With the default commits.limit: 500 and any repo > 500 commits, repoCount - indexedCount is never zero — so every query emits "N new commits since last index" forever, even immediately after a fresh index. The warning becomes meaningless noise and erodes trust in the signal.

Fix: compare max indexed commit date to git log -1 --format=%cI HEAD. The commits payload already carries a timestamp; scroll once for the max, compare, emit staleness only if HEAD > maxIndexed. A test with limit: 2 on a 5-commit repo would catch this.


🟠 Significant

3. Staleness check is on the MCP search hot path, costs 100–500ms per query

mcpAppendStalenessWarning (called from every search_code/search_commits MCP query) runs CodeStaleness or CommitsStaleness unconditionally. CodeStaleness does: full qdrant ExistingPoints scroll + git ls-files + read + SHA256 every tracked file. On this repo (~1265 files) that's a few hundred ms per query. Agents doing 5–10 mid-turn queries pay seconds of invisible overhead.

Options:

  • Cache the result with a short TTL (say 30s) keyed on corpus+projectRoot. Accept that staleness accumulating during the cache window doesn't surface immediately — the warning is advisory anyway.
  • Timebox it — if the check doesn't return within 100ms, skip the warning for this call.
  • Move staleness off the query path entirely and rely on search_status as the explicit agent-facing check. The skill update already points agents there; that might be enough.

My preference is the cache, with a test asserting the second call within the window doesn't touch git/qdrant.


4. CodeStaleness misses deleted-but-still-indexed files

The check only counts files whose current hash is absent from the index; it doesn't count ghost entries (files in the index whose paths are no longer tracked). After a significant delete, the index is stale in a way this signal won't surface. Cheap fix: after the git ls-files walk, compare len(existing) to len(tracked files that passed the filter); add the delta to changed with a qualifier in the Reason.


🟡 Minor

  • 5. Duplicate status logic between MCP and CLI. handleStatus in search/mcp/server.go and runSearchStatus in internal/cli/search.go compute the same shape. Extract a status.Compute(cfg, projectRoot) that both surfaces consume — keeps the MCP output schema and the CLI --json output guaranteed-identical.
  • 6. Writer.flush silently drops errors. If the tmp write or rename fails (disk full, perms), we lose the status update without any signal. Wire through a stderr log or an error return so the operator can see "status file write failed".
  • 7. 30-second timeout on CodeStaleness's git ls-files is too generous given this runs on the query path. 5s max, or propagate the caller's context deadline.
  • 8. Redundant time.Now().UTC() calls inside status.Writer mutators — capture once per call.
  • 9. DisableCorpus flushes per-corpus in the init loop — small but multiplied by bootstrap frequency. Consider a Flush() control or skip when state unchanged.

✅ Things I liked

  • MCP wrapper flattening preserved (no regression of the {\"clusters\":{\"clusters\":[]}} bug from before).
  • ErrCorpusDisabled sentinel reused cleanly from the Make corpus indexing configurable per project; disable GitHub issues corpus by default #60 work.
  • Atomic write-tmp-rename on status file — no partial reads.
  • Test coverage for the happy paths of both staleness functions and the status writer.
  • Skill update reads well — the "check status → reindex if stale → query" workflow is exactly right.

🧪 Test gaps to cover before merge

  • TestCommitsStaleness_WithLimit — regression test for the limit bug.
  • TestRunSearchInit_PreservesAllCorporaState — regression test for the data-loss race.
  • TestWriter_ConcurrentWrites — two writers racing on the same file; verify no data loss.
  • TestStaleness_CacheHit (once the cache lands) — second call within TTL makes no git/qdrant calls.

Summary

Ship-blockers: #1 (data loss) and #2 (permanent false positive). The rest are improvements.

elliottregan and others added 8 commits April 22, 2026 02:55
The lefthook rc: value was a raw shell command instead of a file path,
so the generated hook script never added ~/go/bin to PATH — making
golangci-lint and goimports invisible to the hooks. Fix by extracting
the export into .lefthookrc and pointing rc: at it.

Also: widen pre-commit lint from --new-from-rev=HEAD~1 to full ./...,
add go vet as a parallel pre-commit step, make setup-hooks fail loudly
when lefthook is missing, add a check-hooks warning to the build target,
and document the hook setup in the README.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The lint job had an `if: github.event_name == 'pull_request'` guard
that skipped it on pushes to main. The check job was missing fmt-check
entirely. Both are now unconditional so main stays clean even when PRs
are merged without full CI (e.g. admin merge).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- gofmt: remove trailing whitespace in struct tag alignment
  (internal/cli/search.go)
- ineffassign: remove unused headHash variable; the function already
  validates git state via rev-list --count (search/corpus/staleness.go)
- staticcheck SA1012: pass context.TODO() instead of nil to
  handleStatus in tests (search/mcp/server_test.go)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The init loop held an outer status.Writer whose in-memory snapshot went
stale as runSearchIndex created inner writers. When the outer writer
later called DisableCorpus, it flushed its stale view and wiped prior
code/commits entries.

Fix: move the disabled-state write into runSearchIndex itself. When
config.Build returns ErrCorpusDisabled, the function creates a fresh
single-use writer, records the disabled state, and returns the sentinel.
The init loop needs no writer of its own.

Mirrored the same change in cmd/cspace-search/main.go's runIndexCorpus.

Added regression test:
TestWriter_DisableCorpusDoesNotClobberExistingEntries exercises the full
sequence (two completed corpora, then two disabled) and asserts all four
entries survive with correct states and indexed counts.

Fixes PR #61 review item #1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CommitsStaleness compared rev-list --count against len(indexed), which
with commits.limit=500 and a 1000-commit repo permanently reports "500
new commits" even right after indexing. The signal was always noise.

Fix: compare HEAD's commit date against the max indexed commit date
instead of comparing raw counts. Added MaxDateLister interface and
MaxPayloadDate on the qdrant adapter — scrolls the "date" payload field
once and returns the max. Falls back to HEAD hash lookup for listers
that don't support the new interface.

Tests:
- Updated TestCommitsStaleness_UpToDate/NewCommits for date-based logic
- Added TestCommitsStaleness_RespectsLimit: 5-commit repo, index has 2
  points but maxDate matches HEAD — reports not stale (old logic would
  say "3 new commits")

Fixes PR #61 review item #2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mcpAppendStalenessWarning ran CodeStaleness/CommitsStaleness on every
MCP query — 100-500ms of git I/O + SHA256 + qdrant scroll per call.
With 5+ queries per agent turn, that's seconds of invisible overhead.

Fix: in-process cache keyed on (corpus, projectRoot) with 30s TTL.
CodeStalenessCached / CommitsStalenessCached wrap the raw functions
and hit the cache first. All query-path and status callers now use the
cached variants. First query in a session pays the full cost; subsequent
queries within the TTL window return instantly.

Also reduced git ls-files timeout from 30s to 5s — the staleness check
runs on the query hot path and should fail fast rather than hang.

Tests:
- TestStalenessCache_HitsWithinTTL: second call returns cached result
  without touching the lister (verified via call counter)
- TestStalenessCache_ExpiresAfterTTL: after TTL, the lister is called
  again (uses 1ms TTL override for speed)

Fixes PR #61 review items #3 and #7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CodeStaleness only counted tracked files whose hash was absent from the
index. It never counted ghost entries — files in the index whose content
hash no longer appears among tracked files (deleted or renamed since
last index). After a significant delete, the index was stale in a way
the signal couldn't surface.

Fix: track which indexed hashes are "seen" during the git ls-files walk.
After the walk, count unseen hashes as ghosts. Report both sides of the
symmetric difference: "N files changed, M deleted since last index".

Test: TestCodeStaleness_DeletedFiles — 3-file repo, delete one, verify
CodeStaleness reports stale with a deletion-aware reason.

Fixes PR #61 review item #4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
short-circuit DisableCorpus

Four minor improvements rolled into one commit:

#5 — Extract shared status.Compute(): both MCP handleStatus and CLI
runSearchStatus had duplicate code building the per-corpus status shape.
Now both call status.Compute(root, disabled, checker) and serialize the
same ComputedStatus struct. MCP maps it to its own StatusOutput; CLI
serializes directly.

#6 — Wire flush error reporting: Writer.flush now writes errors to
w.ErrLog (defaults to os.Stderr) instead of silently dropping them.
Callers can override ErrLog in tests. Test:
TestWriter_FlushReportsErrors verifies the error surface is hit when
writing to a nonexistent path.

#8 — Capture time.Now().UTC() once per mutator: each of StartCorpus,
UpdateProgress, FinishCorpus, FailCorpus, DisableCorpus now calls
time.Now().UTC() once at the top and reuses. Pure hygiene, no test.

#9 — DisableCorpus short-circuit: if the corpus is already disabled,
return without flushing. Avoids redundant I/O on re-runs. Test:
TestWriter_DisableCorpusShortCircuit verifies file mod-time doesn't
change on a second DisableCorpus call.

Also added TestCompute_Basic for the new Compute function.

Fixes PR #61 review items #5, #6, #8, #9.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
elliottregan and others added 4 commits April 22, 2026 03:24
Patterns behind the staleness-signal bugs, logged so future agents
avoid the same footguns:

1. Writer instances must refresh state before writing OR be single-use
2. Don't compare counts when limits are in play — compare identities
3. Secondary work on query paths needs a latency budget
4. Delta detection needs symmetric difference, not one-sided membership
5. Don't use planet names for agent-spawned cspace instances

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Agents spawning cspace instances should use descriptive or numbered
names (issue-<n>, cs-agent-<n>, short task labels) instead of planet
names, which are reserved for the human-facing TUI's auto-assignment.

Updated:
- lib/skills/delegating-container-agents/SKILL.md: replaced mars
  examples with descriptive names, added naming note
- lib/agents/coordinator.md: added naming guidance near cspace up usage
- CLAUDE.md: added note in "Instance naming" key pattern

Implements PR #61 Part C and matches context decision #5.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Auto-indexing at container boot blocked provisioning by minutes and
wasted resources in the common case where search isn't queried that
session. Most containers don't need a warm search index at boot.

Added BootstrapSearch bool to provision.Params. Phase 16 now runs only
when the flag is true:

- cspace up: defaults to false; add --index flag for explicit opt-in
- cspace advisor up (advisor.Launch): always true — advisors are
  session-continuous and search-heavy
- cspace coordinate: always true — coordinators consult search indexes
  when routing work
- TUI flows: false (interactive use, no overhead)

Test: TestBootstrapSearchParam validates the Params field defaults and
that Phases[15] is still "Bootstrapping search".

Updated using-semantic-search skill to document the opt-in behavior.

Logged decision: "Bootstrap search indexing is opt-in; advisors and
coordinators get it by default".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New master switch: `enabled: true` at the top of search.yaml is
required before any corpus indexes or responds. Prevents a freshly
cloned cspace project from incidentally indexing node_modules on
first container bootstrap (or on the first advisor / coordinator
spawn, both of which opt into phase-16 via PR #61's role gating).

Implementation:
- `Enabled bool` on search/config.Config, `enabled: false` in
  embedded default.yaml.
- `ErrSearchDisabled` sentinel in search/config/runtime.go, checked
  first in BuildWithConfig so every downstream call site (CLI
  query/index, MCP search_code/search_context/search_issues,
  init loops, phase-16 bootstrap) refuses uniformly.
- `cspace search status` / search_status MCP tool: ComputedStatus
  grows an `Enabled` flag; when false, Corpora is empty and
  clients show a one-line "not configured" hint.
- Init loops short-circuit with a single clear message instead of
  four per-corpus "disabled" lines.
- init_template.yaml leads with the master switch commented with
  opt-in guidance.

Tests updated to cover the new default and the opt-in path; the
pre-existing MCP status handler tests now pass an explicit
`Enabled: true` since the default is off.

Also fixes a latent bug in CommitsStaleness's date comparison that
surfaced during testing — time.Truncate(24*time.Hour) does not
perform calendar-day truncation when a timezone offset is involved,
so HEAD and max-indexed dates could disagree at UTC boundaries while
formatting to the same string. Switched to ISO-date lexical compare
which is timezone-agnostic and equivalent.
@elliottregan elliottregan merged commit 9d1a41e into main Apr 22, 2026
7 checks passed
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