Skip to content

1.0.0 - 2026-08-22

Choose a tag to compare

@github-actions github-actions released this 22 Aug 12:18
c3c5e08

Release Notes

Added

  • groove doctor says what an old .kb-mcpignore left in the index.
    ADR-0007 renamed the
    project with no aliases and no automatic migration, so an ignore file under the
    old name is not read and stops excluding anything at all. What comes back into
    the index is whatever the two gates that still apply admit — the current
    exclusion rules, and whether [parsers].enabled opens that extension. Nothing
    said so.

    Two findings, both warnings. indexed-despite-legacy-ignore names the indexed
    documents the old file matches that the current rules do not exclude — real
    paths, up to five of them, not a count. legacy-ignore-not-examined is what
    comes out when the check could not be completed — the file is there and cannot
    be read, or the filesystem will not say whether it is there at all: a check
    that could not run is not a check that found nothing
    , and reporting the two
    the same way is how a clean bill of health stops meaning anything. Its wording
    claims only that, with what was actually observed carried alongside, because
    one of those two cases never established that the file exists.

    The remedy has three branches, because the destination has three states. With
    the name .grooveignore free, the fix is a rename. With a working ignore
    file there, it is copying over the lines you still want — never an overwrite.
    And when the name is not free but nothing is being applied from it — a
    directory, a refused link, a file over the cap, or a name the filesystem would
    not answer for — the remedy says that and sends you to the destination first,
    because neither renaming nor copying into it produces an ignore file and the
    documents just reported would stay indexed either way. No branch asks for a
    deletion: a knowledge base whose new file is broken looks identical from here,
    and the old file may be the only copy of the patterns.

    "Free" throughout means the filesystem said so, not that it failed to say
    otherwise. Whether a name is free, taken, or unanswerable is one three-valued
    question with one implementation, asked by both the check and the remedy.

    The old file goes through the same ExclusionRules the index walk asks, so
    this is not a second implementation of the exclusion rule; and it is asked
    about documents that are in the database, which is what lets the finding
    carry paths instead of the observation that a filename exists. The check is
    for the migration and is due to be removed in 1.1.0
    , in one file.

  • All four front pages open with a bannerREADME.md, README.ja.md,
    and the two docs/index pages. It draws what separates this from a plain
    vector store: a semantic path and a lexical path converging on one node, and
    ranked results leaving it for an MCP client — the RRF fusion of the
    sqlite-vec and FTS5 legs.

    It carries no words. A wordmark would repeat the # GrooveSeek heading
    directly beneath it, a caption strip at this width is illegible on a phone,
    and text drawn into an image is text no screen reader and no translation can
    reach. The alt attribute carries the meaning instead, and it is written per
    language.

    Light and dark, chosen by prefers-color-scheme, like the logo and the
    screenshot before it. WebP rather than PNG — 33 KB against roughly 1 MB, for
    an image every visitor loads. The PNGs are committed as a fallback, and
    assets/README.md says what to swap.

    That file also stops repeating a claim it could never check. It recorded
    a report that raw.githubusercontent.com serves .svg as text/plain,
    which is why nothing here references an SVG; the report could not be measured
    when it was written because the host answered 429 all session. Measured now
    against this repository's own files, the host answers image/svg+xml — and
    image/webp for the banner. Whether GitHub's Markdown renderer would then
    display an SVG is a second question, about its camo proxy, and is still
    untested; the PNGs stay for that reason rather than the old one.

    The 56-pixel logo those four pages used to open with is gone, since the
    banner carries the mark at its centre. No page embeds assets/logo-* any
    more
    /ui never did, drawing the diamond as a character and its favicon
    as an inline data: URI. The files stay: the SVGs are where the mark is
    defined, which is what the banner's colours were measured against.

Changed

  • groove status, groove service status and groove service list print
    their results on stdout.
    All three used to write everything to stderr, so
    groove status | grep Documents received nothing — the pipe looked like it
    would work and silently did not.
    ADR-0008 declared the
    stdout/stderr split frozen but left these three explicitly unsettled;
    ADR-0010
    settles them, because after 1.0.0 moving them would be a major release.

    A caller redirecting with 2>&1 is unaffected. A caller capturing stderr
    alone now reads nothing where it used to read the counts.

    What stays on stderr is everything that is not an answer: index's progress,
    the confirmations from service install / uninstall / tray-install /
    tray-uninstall, and status's "No index found" — which reports an
    inability to answer and leaves stdout empty. The wording of these lines is
    still not stable; only the channel is. groove doctor --format json remains
    the machine-readable route to documents and chunks.

  • One implementation now answers Host and Origin wherever they are
    asked, /mcp included.
    The two questions had four implementations —
    rmcp's for /mcp, and GrooveSeek's own for /healthz and for the admin
    routes — fed one list and expected to agree. Measured, they did not: with
    the identical allow-list,
    Host: user:pw@127.0.0.1:PORT, @127.0.0.1:PORT, 127.0.0.1@localhost,
    127.0.0.1:65536 and localhost:abc were accepted on /mcp and refused
    next door, and the admin refusal bodies were missing the Forbidden:
    prefix the others carried.

    /mcp therefore refuses those five spellings now, where it used to
    answer 200. All are malformed Hosts that no browser or MCP client
    constructs, and the change can only refuse more, never less: no spelling was
    found that rmcp refused and GrooveSeek accepted. Its refusal wording is
    unchanged. See ADR-0009.

    What each route is compared against has not changed: allowed_origins
    still reaches /mcp and the admin routes only, the admin routes still match
    Host against a loopback-only list of their own, and /healthz still
    validates Host alone and only when healthz_public = false.

    Two consequences worth having on their own: a refused request no longer
    reserves a session seat before being turned away (measured with
    max_sessions = 1, a foreign Host used to get 429 and now gets 403),
    and refusal logging on /mcp is bounded — rmcp wrote one line per refusal
    with no limit, and the gate carries the same one-line-a-minute budget the
    session limit has used since v0.27.0.

  • An admin refusal no longer repeats the header it refused. The 403 body
    read Host 'kb.example.lan' not in admin allow-list, echoing caller-supplied
    bytes back; /healthz next door and rmcp on /mcp both say only that the
    header was not allowed. This surface now says the same, and the rejected
    value goes to the log instead, where the operator who can act on it will see
    it.

  • rebuild_index refuses a second call while one is running. A rebuild
    re-embeds the whole corpus while holding the embedder and the database, so a
    second call never ran beside the first — it queued behind it, with search,
    get_document and /ui unavailable for the sum of the two. Nothing bounded
    how many could queue: the session gate lets every non-initialize request
    past without taking a seat, so max_sessions did not apply, and
    spawn_blocking cannot be aborted, so closing the connection did not stop one
    either. A few dozen bytes of request bought a full re-vectorisation, as many
    times over as the caller liked.

    The second caller now gets an error naming how long the running rebuild has
    been going, instead of a wait with no upper bound. The bound is on the MCP
    tool
    : groove index runs in its own process and still overlaps a served
    rebuild.

Security

  • /ui and /api/admin/status validate Origin. The admin routes are
    served by GrooveSeek rather than by rmcp, so the check that guards /mcp
    never reached them: any page open in the operator's browser could call them
    cross-origin. Nothing leaked — they are GETs, and a foreign page cannot read
    a response that carries no CORS headers — but nothing kept that true either,
    and the first admin route with a side effect would have inherited the gap.
    They now compare against the same effective [transport.http].allowed_origins
    rmcp gets, with a test that asks both surfaces about one origin and requires
    the same answer. As on /mcp, a request carrying no Origin still passes,
    so the tray, curl and the page's own status poll are unaffected.

  • Admin refusals no longer write a log line each. Bound to a non-loopback
    address, the peer check refuses before anything else looks at the request, so
    a stream of cheap requests wrote an unbounded stream of lines to the daemon's
    log file. The session gate on /mcp already thinned its refusals to one line
    a minute, carrying the count of what it stood for; the admin gates now share
    one such budget between them.

  • /ui is served with a Content Security Policy and nosniff. The policy
    is default-src 'none' plus exactly what the page uses — its inline
    <script> and <style>, its data: favicon, same-origin fetch,
    frame-ancestors 'none' — so an external script or stylesheet added to the
    page fails there rather than loading. Both headers are attached outside the
    two gates, so refusals carry them too.

  • h2 was updated 0.4.13 → 0.4.18 to clear RUSTSEC-2026-0258. It is a
    transitive dependency, reached through hyper — and so through axum, which
    the HTTP transport is built on — and through reqwest, which the model
    downloader uses. No GrooveSeek code calls it directly.

Removed

  • groove validate --strict is gone. It was accepted and discarded — the
    documentation said so, which made it a promise the binary did not keep: a CI
    job that passed --strict believed it had asked for stricter checking and
    had not. Giving it meaning after 1.0.0 would change what an accepted flag
    does, which is a major release; removing it now costs nothing and adding it
    back when [options].allow_unknown_fields exists is a minor one. Scripts
    passing it will now fail to parse, which is the visible version of what was
    already happening silently. See
    ADR-0010.

  • kb.path is gone from /api/admin/status, and from /ui's status band.
    It held the knowledge base's absolute path, which on Windows reads
    C:\Users\<name>\... — the operator's account name, in a JSON body and in
    every screenshot of the page most likely to end up in a bug report. Nothing
    consumed it: the Windows tray reads daemon.pid and indexing.active, and
    the page's own comment claiming the tray needed the field was wrong. What
    identifies a knowledge base to the person looking at it — kb.documents,
    kb.chunks, kb.model — is unchanged. ADR-0008
    puts this surface outside the 1.0 freeze, which is why the removal happens
    now rather than during 1.x.

Fixed

  • groove service install on macOS reports an error instead of panicking.
    Two unwrap() calls in the LaunchAgent backend. Neither is reachable on a
    normal system — plist_path always returns a path under
    ~/Library/LaunchAgents, and a home directory is normally UTF-8 — but
    install() returns a Result and its caller already handles one, so a panic
    was the wrong way to say either had gone wrong.

  • Every word GrooveSeek writes to stderr is ASCII, and a test now says so.
    A Japanese Windows console is CP932, where an em dash or a kana arrives as
    mojibake, so AGENTS.md has required diagnostics to be ASCII since
    v0.25.0. Nothing checked it, and review kept finding the same defect one
    instance at a time.

    43 messages contributed characters that console cannot render: the whole
    of groove service install / uninstall / status on all three platforms,
    which spoke Japanese and now speaks English; em dashes in groove tune's two
    notes and two warnings, in three groove index PDF refusals, in the two
    [parsers].enabled refusals, in a watcher diagnostic, in the poisoned-mutex
    warning and in both PowerShell decoding errors; and the groove eval note
    this began with. Only the wording changed — an error that named a file still
    names the same file.

    The rule is about the words a message chooses, not the data it names, so a
    note called 日本語のノート.md still comes out of groove index as itself.

    groove index --progress draws its bar with =>- instead of eighth-block
    characters. The bar is drawn to stderr by indicatif, which made it the one
    place the rule was broken by a library's rendering rather than by a message —
    and the one that would have looked worst on the console the rule is about.

    tests/diagnostics_stay_ascii.rs walks the workspace source at run time
    instead of naming files, so a new file is covered by existing rather than by
    being remembered.

  • The golden query file and the eval history are read with a bound. Both
    default to living inside the knowledge base and both were read whole with no
    cap — a stray binary on one of those names was parsed as-is. They now go
    through the same route .grooveignore takes, so a hard link, a FIFO, or
    something that is not a regular file is a refusal rather than an unbounded
    read. Symlinks are refused on Unix only, which is that route's existing
    and deliberate scope: making one on Windows needs a privilege this threat
    model's attacker does not have, and refusing reparse points there would
    refuse every OneDrive and Dropbox placeholder.

    The size caps differ, because the two files differ. The golden is written
    by a person, and 1 MiB is far past what it is for. The history is written by
    groove eval and carries every golden query with its hits, once per retained
    run: measured, this repository's own 25-query golden produces 0.598 MiB
    after ten runs, and the same golden at limit = 20 produces 1.049 MiB. A
    megabyte is inside its ordinary range, so the history's cap is 64 MiB.

    A history that cannot be read now stops groove eval instead of reading as
    empty.
    An empty history is not inert — the new run is pushed onto it and
    saved back over the same path — so answering "empty" for a file that is
    intact and merely unread would replace every baseline with one run, and
    --fail-on-regression would then pass without having compared anything.
    Content that was read and does not parse still starts fresh, unchanged: those
    bytes held no baseline to lose. --no-history skips the file entirely.

    A dangling symlink counts as a file that is there, for the same reason.
    Path::exists follows the link and answers about the target, so a history
    kept on a volume that is not mounted read as absent — and absent is the
    answer that leads to the new file being renamed over the link.

    And what eval writes is now bounded by what it will read. history_size
    bounds the number of runs kept and bounded nothing about the size, so a large
    golden or a high --limit could write a history the next run refused —
    groove eval producing a file only it could no longer read. Saving now drops
    the oldest runs until the result fits, warning when it does; the run the
    next diff compares against is the last to go. If a single run does not fit,
    the save reports that instead of writing it, and says to reduce the golden or
    the limit, or to pass --no-history.

    groove tune reached the golden through a different function than groove eval did, so the two are now one: eval no longer keeps its own copy of the
    read-and-parse, and a bound added to either is a bound on both.

  • A refusal printed to stderr carried an em dash. AGENTS.md keeps stderr
    ASCII so a CP932 console does not render it as mojibake, and
    Refused::log_line's "not a regular file" message had two — printed from
    groove index, from the .grooveignore reader, and from get_document,
    since each was written. A fourth caller in eval is what got it noticed.

    The path in that line is still interpolated as-is: a note named in Japanese
    makes the message non-ASCII whatever the wording is, and escaping it would
    hand the reader \u{65e5}\u{672c} where they expected a filename. That
    trade-off is left where it is, and the test that pins the wording says so.

  • The documentation told you to type a prompt command no shipped recipe
    produces.
    docs/mcp-tools.md, its Japanese counterpart and prompts.rs
    all rendered the prompt path as /mcp__groove__<name>, but a client builds
    that path from the key you wrote in your .mcp.json, and all four
    bundled recipes call the server ai-knowledge. Anyone who copied a recipe
    was given the wrong command; anyone who chose their own name was given a
    different wrong one. The path is now written /mcp__<server>__<name> with
    the note that <server> is yours, and a test rejects any concrete name
    spelled into that position.

  • docs/index.md had no Japanese counterpart, though the language policy
    and Corpus's own documentation both said every page under docs/ has one.
    The landing page's navigation text — the row describing each page — was
    English-only. docs/index.ja.md now exists and the two
    link to each other, and a test walks docs/ in both directions so a page
    published in one language cannot go missing from the other again.

  • An allowed_origins entry without a scheme refused every browser, in
    silence.
    [transport.http].allowed_hosts takes a bare host:port — its
    parser falls back to reading the whole string as a host — and the key beside
    it looks identical but requires a scheme. allowed_origins = ["127.0.0.1:3100"] was therefore dropped by rmcp before any comparison,
    which left Origin validation switched on with nothing to match: every
    request carrying an Origin header got 403, including /ui's own search.
    Nothing warned. The "this list names no loopback origin" check strips the
    scheme optionally, so it read the host as 127.0.0.1 and concluded the list
    was fine.

    Such a config is now refused at startup, with a message quoting the entry and
    naming the key that does accept that spelling. A config that used to start
    will now stop
    — with an error you can act on, rather than a server that
    answers 403 to everything. An empty list is still accepted: that one is the
    documented off switch.

    The check runs where the list is consumed — resolving an HTTP transport —
    and nowhere earlier, so it cannot refuse a value that was never going to be
    read. Two earlier placements could. Checking during config loading meant a
    groove.toml in a cloned repository could stop every command, because a
    discovered config's allowed_origins is discarded as untrusted before it is
    ever used. Checking after that, but still during loading, meant a typo in an
    HTTP-only setting stopped index, search, validate and a stdio server —
    none of which read the key.

    The same list carries a second cost, which cannot be removed. rmcp matches an
    entry with no port against every port on that host — wider than RFC 6454,
    where an omitted port means the scheme's default. Writing the port in does
    not fix it: the browser omits the port too, so http://127.0.0.1:80 would be
    compared against a request that carries none and would refuse the very page
    it exists for. At port 80 the derived default therefore has to include the
    port-less spelling, and a page served from any other local port can reach
    /mcp; the server now says so at startup. An entry you write yourself is
    left alone, because https://kb.example.com is the shipped proxy recipe and
    means 443.

  • --path-glob split its value on commas, which no glob survives. A glob's
    own syntax uses commas — docs/{a,b}/** is one pattern — and the flag cut it
    in half, leaving docs/{a to be rejected as an unclosed alternate group. The
    MCP path_globs parameter takes an array and never had the problem, so the
    same value worked over one surface and failed over the other: exactly what
    aligning the two was meant to prevent.

    docs/usage.md had always described this flag as (repeatable) and never
    as comma-separated, so the code was the side that disagreed. --tag-any,
    --tag-all and --exclude-paths keep their commas, which is their documented
    contract — none of those values can contain one meaningfully.

    If you were passing several patterns in one --path-glob separated by commas
    — undocumented, and it would have broken on any pattern containing braces —
    pass the flag once per pattern instead.

  • groove-schema.toml.example offered two field types the schema refuses.
    The comment listed "integer" and "date" among its type values;
    schema.rs rejects both at compile time, because frontmatter is held as
    strings throughout and neither is implemented. Anyone following the template's
    own documentation met a schema that would not load. The body was always
    right — it already expresses a date as a string with a pattern — so it was the
    comment above it that was wrong. The file is also now in English, matching
    groove.toml.example; it was the last shipped example still in Japanese.

Internal

  • server.rs became four files. The search half is server/search.rs,
    document reading is server/documents.rs, and the corpus side of the kb://
    resource surface is server/kb_uri.rs. What stayed behind is the tool surface
    itself — the #[tool_router] / #[tool_handler] impls, the parameter and
    response types, and mod tests.

    No behaviour change: the bodies moved byte-identical and in the order they
    were already in. The only thing that changed was visibility, and only where
    the parent still calls or names something — each pub(super) was named by
    cargo check after a move that widened nothing, rather than chosen in
    advance.

  • The eval golden has five questions with two right answers, and the quality
    gate stopped averaging them together with the other twenty-five.
    No
    behaviour change; the last of the 2026-08-18 audit's test-coverage rows.

    Every golden query named exactly one document, so nothing measured whether a
    search returns several relevant documents when several are relevant. The five
    new ones are built on pairs the fixture corpus already had — the two release
    documents, the two database ones, the two authentication ones, the pair that
    both say when to act before you know why, and the pair an incident write-up is
    assembled from. No documents were added: the golden is never copied into the
    corpus, so a new query cannot change the candidate set the other queries are
    scored against, and the twenty-five re-measure identically.

    The two groups are averaged separately, because a query with two right
    answers caps recall@1 at 0.5 and blending them moves the headline number by an
    amount that depends on how many such queries the golden holds rather than on
    whether retrieval got worse — measured, adding five drags the blend from 0.92
    to 0.85 on BGE-small with nothing changed. The four existing floors keep their
    values and are now compared against the group they were measured over.

    On the new group recall@1 is 0.50 on both models — its ceiling — and MRR is
    1.000 on both, because every one of the five puts one of its two documents at
    rank 1. That is exactly the blindness the queries were added for. recall@5 is
    the one that separates the models, 0.80 against 1.00, where on the
    single-answer group the same metric separates them 0.96 against 1.00 and was
    rejected as a gate for that reason.

  • A hybrid search is now held to a fixed number of SQL statements, and the
    two bounds a graph walk is built on are property-tested.
    No behaviour
    change; two more of the gaps the 2026-08-18 audit named.

    The performance guard this project had compared wall-clock as a ratio. That
    is right for what it guards and wrong as the only one: timing on a shared
    runner is noise, so it is #[ignore]d, runs once a night, and its threshold
    has to be loose enough to survive that runner. Counting statements instead
    costs milliseconds, gives the same answer on every machine, and runs on every
    pull request — and it catches the regression a stopwatch notices last, a
    query issued per candidate, per result or per document.

    search_hybrid issues two statements: one for the vector leg, one for
    the full-text leg, with the fusion done in Rust over what they returned. Two
    at 50 chunks and at 500, asking for one result and for ten. Counting every
    statement SQLite traces instead gives 175 and 769 — FTS5 reading
    fts_chunks_docsize once per row it scores for bm25, which this project
    neither wrote nor wants to change, and a gate over that number would have
    been red the day it landed.

    The graph walk's node budget and seed cap take whatever an MCP client sends,
    including 0 and u32::MAX. Both are now generated rather than sampled, and
    the asymmetry at zero is stated as the rule rather than as two examples:
    max_nodes = 0 is a coherent request and is honoured, while
    max_seed_chunks = 0 would make an answer indistinguishable from "no such
    document" and becomes 1.

    .grooveignore's ! is generated too, across nine spellings, every
    hardcoded name, and three depths. Breaking the rule three ways showed what
    that adds: two of the three breakages are caught by the example tests as
    well, and the third — giving ! gitignore's own precedence, so it wins
    where an earlier line ignored something — passes every example and fails
    both properties, because each example spells its negation !name in a file
    with no ignore line at all. The shrunk counterexample is * followed by
    !.git.

  • The two Origin startup warnings are now checked for what they say and when
    they fire.
    They are the only thing an operator gets in two configurations
    that otherwise look like they are working — one where Origin validation is
    off, and one where /ui is served but its search is refused with nothing on
    screen to say why. Both are inline conditions rather than predicates, so
    nothing could reach them: neither the wording nor the trigger was tested.

    A third test asserts they stay quiet on an ordinary configuration. A
    warning that fires always would satisfy the other two and teach an operator
    to ignore the line.

    The shared stderr drain in tests/common keeps its lines now rather than
    discarding them. It has to be that one: a second reader on the same pipe
    would take lines away from the first, and the first is where the bound
    address comes from.

  • Nightly gained a macOS leg, and the launchd backend gained end-to-end
    tests.
    install, status and uninstall are reached only from #[ignore]
    territory, and nightly ran Linux and Windows — so on macOS none of them had
    ever been executed by anything. That is the same gap AU-09 closed for
    Windows, left open for the third platform.

    Measured on a GitHub-hosted runner before writing any of it: launchctl managername answers Aqua, so the gui/<uid> domain the backend bootstraps
    into exists there — which is not a given, since launchctl(1) says a GUI
    domain is created at GUI login and other CI fleets report
    Bootstrap failed: 125 for exactly this. bootstrap exits 0, RunAtLoad
    really starts the program, and bootout cleans up.

    The skip list for the big-model tests moved out of the matrix and into the
    step that uses it, now that two legs share it: a long string written once per
    leg is a string that gets updated once.

  • Four gaps the 2026-08-18 audit named now have tests. No behaviour change.

    groove service uninstall and status take --service-name; the instance
    name used to be positional on both, and nothing checked that the old spelling
    was refused rather than quietly ignored — which would have left
    groove service uninstall work removing the instance called groove.

    groove search reads rerank_by_default from groove.toml. The decision
    was a pure function with its own tests, none of which could see whether the
    command line handed it the key at all. The new test tells the two apart by
    the shape of score: an RRF sum is bounded by 2 / (rrf_k + 1), and a
    cross-encoder logit is not on that scale.

    Three boundary inputs: an empty knowledge base, a query far past the 1 KiB
    the MCP surface refuses, and a query made only of characters outside the BMP.
    The long-query test also records where the real ceiling is on that surface —
    Windows caps a whole command line at 32,767 characters, so a 64 KiB query
    fails before the process starts.

  • docs/stability.md writes out what a search answers with. It had
    promised since v0.27.0 that "every field documented today keeps its name,
    type, and meaning" while documenting none of them — a promise with no subject,
    in the document that says what 1.0 freezes. All 28 fields of the search
    response are now listed with their type and presence rule, for the MCP tool
    and for groove search --format json, taken from the types rather than the
    prose. Three tests hold the table to the response in both directions and hold
    the Japanese table to the English one.

    The tests found the hole on their first run: the table stopped at
    match_spans and expanded_from without describing what is inside them, so
    five fields sat outside the freeze while looking covered.

    low_confidence is frozen as a field, not as a judgement. The key is
    present and boolean; the formula, the default threshold, and which queries
    trip it are explicitly outside the freeze. Measured, it tracks how much the
    fused scores are distributed rather than whether the answer is right — on a
    corpus where all 25 golden queries were answered correctly at rank 1 it still
    fired on 14 of them, and reranking can switch it off outright — cross-encoder
    logits often make the mean negative, and the sign check then answers false
    whatever the spread was (measured: false for all 25 with bge-v2-m3). A
    false therefore tells a caller nothing when a reranker ran.
    docs/filters.md records both limits. No behaviour changed: no corpus-independent threshold exists, so
    moving the default would have swapped one arbitrary number for another.

  • Two chunks_exact(2) calls in the PDF parser became as_chunks::<2>().
    Rust 1.98.0 stabilised clippy::chunks_exact_to_as_chunks, and CI installs
    stable unpinned, so the lint arrived on its own and turned -D warnings
    into a failure on code nobody had touched. as_chunks::<2>().0 splits
    identically — the trailing odd element goes to .1 the way chunks_exact
    left it in .remainder() — and states the pair width in the type, which is
    what both call sites meant.

  • docs/usage.md now documents RUST_LOG. Raising the log level is the
    first step in diagnosing a wrong groove.toml, a get_best_practice that
    reports "not found", or a query that matches less than expected — and the
    variable appeared nowhere a user would look. The new section says which
    target to raise, what each level adds, and what is not behind it: the
    chosen config file is logged at info already, and index's progress does
    not go through the logger at all.

  • The flag-coverage check no longer reads docs/decisions/. An ADR
    explaining why a flag was removed has to name it, which failed the reverse
    direction of the check — and ADRs are immutable once merged, so the failure
    could not have been repaired, only worked around. CHANGELOG.md was already
    excluded for the same reason. This tightens the forward direction rather than
    loosening it: a flag named only in a decision record no longer counts as
    documented, and every_long_flag_the_binary_accepts_is_documented still
    passes, so none was relying on one.

  • Three holes in the tests that guard the frozen surface. The flag-coverage
    check pooled the English and Japanese documentation into one buffer, so a flag
    described in only one language satisfied it — while docs/stability.md, the
    page that gives "documented" its meaning, is the English one. Each language is
    now checked separately; both pass, which is the cheapest moment to make sure
    they keep doing so.

    The pairing tables covered two of the six MCP tools. rebuild_index now pairs
    with groove index, and the tools that have no command behind them —
    get_document, get_best_practice — declare their parameters and the reason
    they have no second surface, so a parameter cannot appear on any of the six
    unnoticed. list_topics is recorded as the one that takes none at all.

    And the pairing rule now checks values, not only names: a flag documented as
    repeatable must not carry a delimiter, and one documented as a comma list
    must.

  • Origin validation is now tested through a running server. The check
    shipped in 0.27.0 with twenty-five tests, every one of them against the
    function that assembles the allow-list rather than against the server that
    applies it — deleting the call that hands that list to rmcp left the whole
    suite passing. Four tests now bind a server, send real requests, and assert
    what comes back: a foreign origin is refused, a request with no Origin is
    not, the server accepts its own bound address, and an empty list really does
    turn the check off.

    They bind with --bind 127.0.0.1:0 and read the port back, because the
    allow-list is derived from the address the listener received; a test that
    supplies the port cannot tell that apart from one that echoes it. And they run
    in ordinary cargo test, without --ignore and without a feature flag, so a
    regression fails the pull request that causes it rather than the next nightly.

  • /ui and the request it sends are now exercised through a running
    server.
    The page started searching through /mcp in v0.27.0 and nothing
    asserted since that the request it sends is one the server accepts — the
    existing web UI tests are feature-gated and ignored, which is right for the
    ones that build an index and wrong for a check PR CI therefore never runs.
    Five tests, none ignored or gated: the page is served, the handshake-free
    tools/call it sends is accepted, dropping the protocol header is refused
    (which is what gives the previous one meaning), /api/search is absent from a
    shipped server, and /ui refuses a foreign Host.

    The request is read out of the page, not transcribed: the fetch target,
    the method, the headers, and the stringified body with its envelope and
    nesting all come from callTool. Anything the reader cannot model stops the
    test rather than being skipped — a computed target, an unrecognised value, an
    option the replay does not implement, a body that is no longer
    JSON-stringified — because a shape it cannot read must never be reported as a
    shape that matches. A transcribed request passes happily while the page it
    claims to describe has changed.

    A separate assertion pins the page to rmcp's STANDARD_HEADERS. Both are
    needed: measured, a page pinned to LATEST still gets a result, because rmcp
    accepts a handshake-free call on known older versions — so the live test
    alone would not have caught the mistake most likely to be made.

  • The tests no longer choose the port they tell the server to bind. They
    bound 127.0.0.1:0, read the number, dropped the listener, and passed that
    number on the command line — leaving a window in which anything else starting
    a server could take it, with a dozen of them running in parallel inside one
    test binary. The helper's own comment called the window theoretical.
    tests/mcp_protocol_surface.rs flaked twice in three days.

    They also captured the server's stderr and never read it, and a pipe nobody
    empties eventually blocks the process writing to it — which fits the symptom
    seen: a server that answered /healthz and then returned an empty body. The
    watcher spawner already drained, with a comment saying why, but only after
    /healthz answered, so the startup window went unread. Reading the assigned
    address requires draining from the moment the child starts, so one change
    closes both.

    Three spawners did it, not one: the shared helper, its watcher variant, and
    tests/http_transport.rs, which carried its own copy. --port and
    pick_free_port now appear nowhere under tests/, and the reader that finds
    the address is shared rather than copied — one parser, so a change to the
    server's wording cannot be fixed in one spawner and left in another. The
    flake is intermittent, so this is not shown to have fixed it; what is shown
    is that two known ways for these tests to interfere with each other are gone.

  • No test mutates the process environment any more. One was left: it set
    GROOVE_CONFIG_HOME, asserted, and put it back, with a note saying nothing
    else mutated the environment beside it. True, and beside the point — the
    hazard is not another writer, it is every concurrent reader.
    TrustRoots::from_env reads that same variable to decide which directories
    are trusted, so a test calling Config::discover() while this one held it
    would have seen /tmp/groove-test-override as a trust root, and failed
    somewhere else for a reason invisible from where it failed.

    The judgement now takes its input as an argument — resolve_config_home_in,
    matching Config::discover_in, which already had this shape. The same move
    applies to env_dir's rule that an empty value counts as unset: that rule
    had no test at all, because reaching it meant setting a variable. It is
    dir_from_env_value now, and deleting the filter fails a test instead of
    none.

grooveseek 1.0.0

Download grooveseek 1.0.0

File Platform Checksum
grooveseek-aarch64-apple-darwin.tar.xz Apple Silicon macOS checksum
grooveseek-x86_64-pc-windows-msvc.zip x64 Windows checksum
grooveseek-aarch64-unknown-linux-gnu.tar.xz ARM64 Linux checksum
grooveseek-x86_64-unknown-linux-gnu.tar.xz x64 Linux checksum

groove-tray 1.0.0

Download groove-tray 1.0.0

File Platform Checksum
groove-tray-x86_64-pc-windows-msvc.zip x64 Windows checksum

groove-svc 1.0.0

Download groove-svc 1.0.0

File Platform Checksum
groove-svc-x86_64-pc-windows-msvc.zip x64 Windows checksum