Release 2.3.0 - #60
Conversation
2.2.1's RESULTS.md published an honest asterisk: the first MCP response of a session carried the full memory brief (~16.5k chars), making it the largest packet Link sends - 11,269 tokens on the benchmark corpus against a 1,954-token steady state. "Bounding it is next" was the public promise. This is that. _compact_session_brief digests the full brief down to what an agent needs to behave well at session start: typed memory claims trimmed to 200 chars, active/review counts, and one guidance note pointing at recall for everything else. A hard 4,000-character budget is enforced in code - trailing memories are dropped until the digest fits, with a truncated flag - so the bound holds by construction, not by hope. Measured on the same corpus: first recall 2,313 tokens, steady state 1,954 - brief overhead down from ~9,300 tokens to ~360. On the demo wiki the digest is 956 chars carrying every memory. Retrieval recording still uses the full memory-name list, so lnk wins counts are unchanged. The budget is pinned by a test (digest must serialize under SESSION_BRIEF_MAX_CHARS), eval_token_economics.py measures the real MCP surface on every run, and README + RESULTS.md now state the bounded number instead of the asterisk. 1010 tests, ruff clean, ratchet 385, hygiene green.
Three small findings from the 2.2.1 post-release audit, none user-facing, all the kind of thing a meticulous adopter notices: 1. The MCP handshake reported serverInfo.version as the SDK version (e.g. 1.29.0) because FastMCP has no version parameter. The underlying lowlevel Server does; set it defensively via getattr so the FakeFastMCP test stub (and any SDK that renames the private attribute) degrades to the old behavior instead of crashing - the first attempt broke 69 contract tests by assuming the attribute. Real handshake now reports 2.2.1. 2. LinkBar Info.plist gains CFBundleVersion alongside CFBundleShortVersionString - macOS update and launch-services paths key off the build number, and it was absent entirely. 3. packaging/linkbar.rb was a 1.0.0-era snapshot and packaging/RELEASE-linkbar.md described the 2.0.0 release. The cask template is now generated from the live tap with placeholders (so it cannot rot into a stale pinned version), and RELEASE-linkbar.md is reduced to the LinkBar-specific mechanics with a pointer to the release recipe memory that actually gets exercised. 1010 tests, ruff clean, ratchet 385, hygiene green.
The user noticed the banner said "Link 2.2.1 is out" followed by
"secret gate, capture provenance, ranked proposals" - feature words
from a 1.x release. prepare_release.py stamps the version into the
banner but nothing owned the words, so they rotted invisibly across
four releases.
Two fixes:
- The live banner now describes 2.2 ("sync with no server, temporal
recall, proof your agents use it").
- prepare_release.py takes --banner "feature words" and substitutes
them after "is out" (HOMEPAGE_BANNER_WORDS_RE over the JS-escaped
template); omitting --banner prints a loud stderr warning naming
exactly what will rot. Verified the substitution against the real
homepage template and the release recipe memory now says to always
pass it.
1010 tests, ruff clean, ratchet 385, hygiene green.
The 2.3 flagship, built from the deep-research round. The most universal pain of multi-agent work is the switch: a rate limit hits mid-task (Pro exhausts after ~12 heavy prompts; the community documents 20-40% of every session's tokens going to context re-establishment), and the hand-rolled fix - HANDOFF.md files - is widespread enough to have a dictionary entry. Anthropic shipped cross-session messaging, but intra-Claude only. Cross-vendor is the whitespace only an agent-agnostic layer can fill. link_core/handoff.py: standalone packets under raw/handoffs/ (task, state, explicit next steps; "no return path" - the receiving session cannot ask the old one what it meant). Chained breadcrumbs to the previous handoff, automatic expiry (48h), machine-local (raw/ never syncs), secret-redacted at write - including the title and filename slug, a leak the new tests caught (first line of a note can carry the secret). Delivery is push, never pull, per the research's core design rule: - session-start hooks open with the HANDOFF WAITING block, above everything else - the MCP first-response digest carries handoff_waiting first - and delivers even when the store has zero memories (the demo caught the digest bailing on empty stores and swallowing the handoff) CLI: lnk handoff "note" --task --next (repeatable) --from; lnk handoffs [--clear FILE]. Agent instructions (shared template + embedded constant + LINK.md) teach: resume first, clear after pickup, offer a handoff when the user stops or switches. Bulk review (the review-debt fix riding along): accept-capture --all accepts every proposal with duplicates/conflicts skipped and reported, clears the capture only when nothing was skipped; delete-capture --all --confirm clears the pending inbox with dismissals recorded and the preview NAMES the workspace it will hit. Fixed en route: with --all the positional is always the target directory - the previous parse could route a destructive bulk delete to the default workspace fallback (caught in testing against a scratch dir; regression test pins it). Verified live end to end: a claude-code handoff written in one session opened the next session on both surfaces (hook text starts with HANDOFF WAITING; MCP first response carries the same block); bulk accept took a 4-proposal import to 4 memories and cleared the capture. 1019 tests (+9), ruff clean, ratchet 385, contracts and hygiene green.
The deepest weakness in the research round was B1: mid-session, memory only arrives if the agent thinks to ask, and mostly it does not. The session-start brief is a snapshot; forty minutes in, the user types "let's deploy payments on friday" and the Tuesdays-only memory sits unread. This is the per-prompt check that catches exactly that moment. link_core/guard.py: fires only when BOTH hold - the memory is constraint-shaped (never/always/only/do not/must not: the class whose violation hurts) AND the prompt overlaps it strongly (the same lexical ranker recall uses, confidence gate + score floor so a single stray shared token can never interrupt). Silence is the normal, correct output; precision-first because a naggy guard is a dead guard. Wiring: Claude Code's UserPromptSubmit hook runs `lnk hook prompt-check` (10s timeout, measured 82ms; no model load). Other agents' configs are untouched - they have no prompt event. Firings are recorded in the usage ledger as a new "guard" kind, so lnk wins counts the guard working. Live matrix verified: "lets deploy the payments service on friday" fires the Tuesdays constraint by name; "force push this to the shared branch" fires the force-push rule; haiku requests, short prompts, and one-token overlaps stay silent; non-constraint preferences never interrupt even on strong overlap. 7 new tests (precision matrix + hook-plan wiring both ways). 1026 tests, ruff clean, ratchet 385, contracts and hygiene green.
The handoff needs a face. A waiting handoff now opens the Inbox tab as a rust-tinted banner above everything else - title, age, source agent, body preview with next steps, and a Clear button for after pickup. Fed by lnk handoffs --json in the fast refresh batch, updated the moment a handoff lands via a directory watcher on raw/handoffs, and older pending handoffs are counted below the newest. Snapshot-verified against a live workspace; zero warnings.
Two remaining items from the deep-research round.
1. The handoff suggests itself at the right moment. Session-end hook
output is invisible (the session is over), so the trigger lives in
the per-prompt hook that already sees every prompt: when one
announces a stop or a tool switch ("switching to codex", "hit my
rate limit", "continue this tomorrow", "stopping here"), the hook
nudges the agent to offer lnk handoff before ending. Same
precision-first discipline as the guard - the agent-names gate keeps
"switching to a recursive approach" and "continue with the refactor"
silent; a 17-case matrix is pinned in tests. Constraint reminders
outrank the nudge when both would fire.
2. lnk setup provisions the fast semantic tier by default (B3): the
lexical-vs-fast gap (hit@1 0.589 vs 0.703) is the biggest quality
difference a new install feels, and the cost is one ~30 MB local
model fetched during the explicit setup command with a clear
message. Recall itself still never touches the network. Fast tier
ONLY on the default path - quality and rerank (~200 MB more) stay
explicit opt-ins - and only where Link owns the environment (the
managed venv / externally-managed pythons); user-managed pythons
keep the hint. --no-semantic opts out. First wiring naively called
the full semantic setup, which would have pulled all three tiers on
a cold install; caught and scoped before commit.
1029 tests (+3), ruff clean, ratchet 385, contracts and hygiene green.
Self-review caught the flaw in my own precision story: the guard had no memory of its own firings, so ten prompts about deployment meant ten identical Tuesday reminders - the exact nagging the design vowed against. The usage ledger already records every guard firing with the memory name and timestamp, so recently_guarded() walks it backward (events are append-ordered; past the 45-minute floor it stops) and the prompt-check hook skips a constraint it surfaced within the window. A different constraint can still fire immediately. Live-verified: first deploy prompt fires, the follow-up seconds later is silent.
Three fixes from the room-for-betterment review: 1. Guard coverage was 1 of 9 (only Claude Code has a prompt hook). But hookless agents recall, and a recall query paraphrases the request - so the guard now rides all three MCP recall paths (slim query mode, slim memory mode, full recall_memory), adding a "guard" field to the packet on a strong constraint overlap. The cooldown is shared with the prompt hook through the usage ledger, so the two surfaces never nag in stereo. Live-verified: a hookless recall for "deploy payments friday" carries the Tuesdays constraint; the follow-up is quiet. 2. Track 7, the honest slice: eval_token_economics now measures what a delivered handoff costs the receiving session (109 tokens on the benchmark packet). The savings side - community-reported 20-400f session tokens on re-establishment - needs real-agent studies, and the benchmark says so explicitly rather than faking it with a synthetic proxy. 3. README benchmark row updated for the new default: pip stays lexical 0.589, brew + lnk setup now lands the fast tier at 0.703, quality and rerank remain opt-in - so the row stays honest in both directions after the fast-tier-default change. 1031 tests (+1), ruff clean, ratchet 385, hygiene green.
Discovered during the fix-all triage: the wheel only exposed the MCP server ([project.scripts] link-mcp) - link.py was never packaged, so every pip user (which means every Windows user, who has no brew) got a memory server with no lnk. The "Windows support" gap was never about winget manifests; it was this. Fix without a refactor: hatch force-include ships the repo-root link.py as the importable link_cli module behind a new `lnk` console script, and serve.py / LINK.md / .linkignore ride along as data so workspaces still receive their runtime files - copy_runtime_files now falls back to link_cli.py as the source for the workspace link.py copy. Verified against a cold venv with the built wheel: lnk --version, init, remember, recall, handoff, handoffs all work, and the workspace receives LINK.md, link.py, and serve.py. Two tests pin the packaging (console script + force-includes declared; runtime-copy fallback). 1033 tests (+2), ruff clean, ratchet 385, hygiene green.
) First external bug reports, both with root cause and verified fixes. #58 is the serious one: LinkBar crashed at launch on every machine except the build host, since 1.0.0. SPM generates a Bundle.module accessor that fatalErrors when it cannot find its resource bundle, and it only checks the app root and the absolute build path compiled into the binary. bundle.sh put the bundle in Contents/Resources - neither location - so a packaged app always died. It looked fine here only because the maintainer machine has that build path. LSUIElement means no window, so the failure read as "the app never opened". Structurally invisible to our own testing: every snapshot ran the dev binary, and every manual check ran on the machine that satisfies candidate 2. Reproduced by hiding the build directory and running the installed binary, then fixed by never touching Bundle.module - a small resource lookup searches Bundle.main, the SPM bundle under Resources, the app root, and the executable directory, returning nil rather than crashing. The resource copy lost its 2>/dev/null || true so a failed copy fails the build. Verified: freshly bundled app launches and renders with every build path hidden. #59: build_wiki_cache assigned raw forward links per stem, so a nested sources/vendor-docs/INDEX.md erased root index.md edges; rebuild wrote an incomplete _backlinks.json that validate then called stale. Now merged, matching what build_backlinks and validation already do. Proven by A/B on the reporter fixture: with the fix agent-memory has its index backlink, without it the edge is gone. Three regression tests: stem-merge in the cache, no Swift source may touch Bundle.module, and the resource copy may not swallow failures. 1036 tests, ruff clean, ratchet 385, hygiene green.
…idden The two LinkBar tests from the fix commit are static text checks (no Bundle.module in source, no || true on the resource copy). They pin the cause, but nothing actually launched the packaged app - and the whole lesson of #58 is that only running the artifact with the build environment unavailable can catch that class of bug. My reply on the issue said the packaged path was exercised; it was exercised by hand, not by anything repeatable. This makes it true. scripts/smoke_linkbar_packaged.py bundles the app, moves every *_LinkBar.bundle under .build out of the way (simulating a machine that never built anything), launches the packaged binary in snapshot mode, requires a render, and restores the bundles in a finally block. It skips cleanly off macOS or without a swift toolchain. Proven in both directions: against the pre-fix files it fails with the exact reported fatal error; against the fix it renders. Wired into the macOS CI job and py_compile list, plus a unit test asserting the harness exists and is referenced by the workflow - so it cannot quietly stop running. 1037 tests, ruff clean, ratchet 385, hygiene green.
prepare_release.py stamps the six release files (with --banner words this time, so the homepage banner describes 2.3 instead of an older release). LinkBar bumps to 1.3.0 in DesignSystem.swift and bundle.sh. Release zip built: LinkBar-1.3.0.zip sha256 2a390edd628bdc796a856841a1b61e102d00d801d029cb66c8667e6c45d45b66. All gates green on the stamped tree: packaged-launch smoke (the #58 harness), 1037 tests, ruff, no-identity sync sim, ratchet 385, hygiene, contracts.
Brings the Bar CI investigation collector onto develop, along with the release-hygiene exemption for it and the token-benchmark FTS cache fix. Resolves the py_compile list by keeping both entries: bar_investigate.py from main and smoke_linkbar_packaged.py from develop.
Bar CI investigationRun windows-smokeFailed step: Run broad Windows tests packageFailed step: Build link-mcp |
A recall builds the persistent FTS index under .link-cache, which holds an open SQLite handle. POSIX unlinks open files happily, so a test that left the cache open still cleaned up on macOS and Linux; Windows failed at TemporaryDirectory teardown with WinError 32 on page-fts-v1.sqlite. That is a platform-only failure invisible to everyone developing here. tests/mcp_harness.py drives the server through a context manager that always closes the cache on exit, so the next MCP test cannot reintroduce this by forgetting. The guard, handoff, and usage tests now use it.
python -m build deliberately builds the sdist first and then builds the wheel from the extracted sdist, where no parent directory exists. The force-include entries referenced ../link.py and friends, which resolve in the source tree but not inside that extracted sdist, so CI failed with "Forced include not found: .linkignore". A hatchling build hook copies the four repo-root runtime files into the package before file collection, so both builds see local paths: from the source tree it copies from ../, and from an sdist the copies are already there. The copies are gitignored build artifacts; the repo root stays the single source of truth, and the hook fails the build if a file is missing from both places rather than shipping a wheel without the CLI.
Fixed (reported by @SparklesKitchen)
Added
Changed