diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03331a758e..0135c659bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,17 +1,50 @@ +# Continuum CI — TypeScript clients gate +# +# ## What this replaced, and why it mattered +# +# This workflow used to run in `working-directory: src` against +# `src/package-lock.json`, calling `npm run build:ts` and `npm run test:crud`. +# Every one of those is gone: `src/` was the Node monolith, retired when the +# substrate became a headless Rust core (#1840), and both scripts left with it. +# +# So it failed on every run — last success 2026-06-07, red continuously from +# 2026-06-09 — with `Cannot find module 'dotenv'`, an error that describes the +# absence of the whole world it was pointed at rather than any defect in the +# code under test. +# +# A check that ALWAYS fails is worse than no check. It cannot distinguish a +# broken PR from a healthy one, so the only thing anyone can learn from it is to +# stop reading CI — which is exactly what happened: work routed around it +# through the Rust and drift-guard workflows for two months. +# +# ## What it does now +# +# Gates the TypeScript clients workspace — the one thing this file was always +# supposed to cover and had stopped being able to see. +# +# `npm run test:clients` was ALREADY in package.json and ALREADY passing +# locally. Nothing called it. That is how `renderBench.spec.ts` and six sibling +# spec files across `apps/web` and `packages/` came to exist, be green on a +# developer's machine, and gate nothing at all — a correct check that nothing +# invokes, which is indistinguishable from having no check until someone greps +# for the caller. +# +# Triggers on `canary` as well as `main`, because canary is where development +# happens; a gate that only watches the stable line learns about breakage after +# it has already been merged. + name: Continuum CI on: push: - branches: [ main ] + branches: [ main, canary ] pull_request: - branches: [ main ] + branches: [ main, canary ] jobs: - validate: + clients: + name: TypeScript clients (typecheck + tests) runs-on: ubuntu-latest - defaults: - run: - working-directory: src steps: - uses: actions/checkout@v4 @@ -21,32 +54,20 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: src/package-lock.json + # Root lockfile — npm workspaces hoist, so there is exactly one. + cache-dependency-path: package-lock.json - - name: Install dependencies + # `npm ci` installs the whole workspace from the lockfile, which is what + # makes this job the substrate the tests stand on: without it, every spec + # file fails at COLLECTION with "Failed to load url @continuum/chat-view", + # an error that points a reader at missing source rather than missing deps. + - name: Install workspace run: npm ci - - name: TypeScript compilation - run: | - npm run build:ts - echo "✅ TypeScript compilation passed" - - # Skip full tests for documentation-only PRs - - name: Check if documentation-only PR - id: check_pr - working-directory: . - run: | - if git diff --name-only origin/main..HEAD | grep -qvE '\.(md|txt|yml|yaml)$'; then - echo "skip_tests=false" >> $GITHUB_OUTPUT - else - echo "skip_tests=true" >> $GITHUB_OUTPUT - fi - - - name: Run tests - if: steps.check_pr.outputs.skip_tests != 'true' - run: | - npm run test:crud - echo "✅ CRUD tests passed" - - - name: Validation complete - run: echo "✅ CI validation complete - local precommit hook validates full system" \ No newline at end of file + - name: Typecheck clients + run: npm run typecheck:clients + + # The suite that existed and was never run. Covers apps/web, apps/tui, + # the view packages, and the SDK. + - name: Test clients + run: npm run test:clients diff --git a/.github/workflows/plugin-version-guard.yml b/.github/workflows/plugin-version-guard.yml new file mode 100644 index 0000000000..4af3b17981 --- /dev/null +++ b/.github/workflows/plugin-version-guard.yml @@ -0,0 +1,58 @@ +# Plugin version-bump guard. +# +# Why this exists: Claude Code plugin updates are VERSION-based, not +# content-based. A marketplace install copies the plugin to +# ~/.claude/plugins/cache//// and pins it. `claude +# plugin update` compares the DECLARED version in plugin.json against the +# installed one and never looks at the files. So if scripts change and the +# version does not, every installed copy answers "already at the latest version" +# forever and the fix reaches nobody. `git pull` does not update a plugin. +# +# The failure this guards, measured 2026-08-09: memory-bridge sat at 0.1.0 since +# 2026-07-25 while its scripts gained a persona-id cache and an entire +# session-capture.sh. The installed copy on BigMama had NEITHER — automatic +# per-turn memory capture had never run once on that machine — while the repo +# held working code and the plugin README said the bridge was live. Bumping +# 0.1.0 -> 0.2.0 propagated two weeks of fixes in one command. +# +# Same class as the install-manifest projection guard and the ts-rs binding +# guard: a consumed artifact and its source must not drift apart in silence. +# Here the "artifact" is every developer's installed copy. +# +# NOTE: this runs in CI, not pre-commit. `.githooks/pre-commit` currently invokes +# tests deleted with the Node monolith and is not installed as the active hook +# (core.hooksPath does not point at it), so wiring a gate there would look +# enforced while running never. +name: Plugin Version Guard + +on: + pull_request: + paths: + - 'tools/plugins/**' + - '.github/workflows/plugin-version-guard.yml' + push: + branches: [canary, main] + +concurrency: + group: plugin-version-${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + plugin-version: + name: plugin content changed => version bumped + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + # Need the base commit to diff against, not just the tip. + fetch-depth: 0 + + - name: Check every touched plugin bumped its version + run: | + BASE="${{ github.event.pull_request.base.sha || github.event.before }}" + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then + echo "no usable base ref (first push / new branch) — nothing to compare" + exit 0 + fi + tools/scripts/check-plugin-version.sh "$BASE" diff --git a/CLAUDE.md b/CLAUDE.md index 58742d12ac..f19e4087b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -429,10 +429,10 @@ let results = algo.execute(&input); cd src npm start # DEPLOYS code changes, takes 130s or so -./jtag ping #check for server and browser connection -./jtag interface/screenshot # Verify any visual changes -./jtag collaboration/chat/send --room="general" --message="Try using the ping command" #be sure to randomlize this, check for list, help, etc, or they think it's a repeat -./jtag collaboration/chat/export --room="general" --limit=20 | tail -20 #Wait about 30 seconds and get the last 20 messages +uu ping #check for server and browser connection +uu interface/screenshot # Verify any visual changes +uu collaboration/chat/send --room="general" --message="Try using the ping command" #be sure to randomlize this, check for list, help, etc, or they think it's a repeat +uu collaboration/chat/export --room="general" --limit=20 | tail -20 #Wait about 30 seconds and get the last 20 messages ``` **IF YOU FORGET `npm start`, THE BROWSER SHOWS OLD CODE!** @@ -452,13 +452,13 @@ Don't panic and stash changes first before anything drastic. Use the stash to yo **Basic Usage:** ```bash # Send message to chat room (direct DB, no UI) -./jtag collaboration/chat/send --room="general" --message="Hello team" -./jtag collaboration/chat/send --room="general" --message="Reply" --replyToId="abc123" +uu collaboration/chat/send --room="general" --message="Hello team" +uu collaboration/chat/send --room="general" --message="Reply" --replyToId="abc123" # Export chat messages to markdown -./jtag collaboration/chat/export --room="general" --limit=50 # Print to stdout -./jtag collaboration/chat/export --room="general" --output="/tmp/export.md" # Save to file -./jtag collaboration/chat/export --limit=100 --includeSystem=true # All rooms with system messages +uu collaboration/chat/export --room="general" --limit=50 # Print to stdout +uu collaboration/chat/export --room="general" --output="/tmp/export.md" # Save to file +uu collaboration/chat/export --limit=100 --includeSystem=true # All rooms with system messages ``` **Interactive Workflow - Working WITH the AI Team:** @@ -467,7 +467,7 @@ When you send a message, `chat/send` returns a message ID. Use this to track res ```bash # 1. Send message (captures the JSON response with messageId) -RESPONSE=$(./jtag collaboration/chat/send --room="general" --message="Deployed new tool error visibility fix. Can you see errors clearly now?") +RESPONSE=$(uu collaboration/chat/send --room="general" --message="Deployed new tool error visibility fix. Can you see errors clearly now?") # 2. Extract message ID (using jq if available, or manual) MESSAGE_ID=$(echo "$RESPONSE" | jq -r '.shortId') @@ -477,19 +477,19 @@ echo "My message ID: $MESSAGE_ID" sleep 10 # 4. Check their responses -./jtag collaboration/chat/export --room="general" --limit=20 +uu collaboration/chat/export --room="general" --limit=20 # 5. Reply to specific AI feedback -./jtag collaboration/chat/send --room="general" --replyToId="" --message="Good catch! Let me fix that..." +uu collaboration/chat/send --room="general" --replyToId="" --message="Good catch! Let me fix that..." ``` **CRITICAL**: Don't just broadcast to the AI team - WORK WITH THEM. Use their feedback, reply to their questions, iterate based on what they're saying. The chat export shows message IDs as `#abcd123` - use those to reply. ### Debug Commands ```bash -./jtag debug/logs --tailLines=50 --includeErrorsOnly=true -./jtag debug/widget-events --widgetSelector="chat-widget" -./jtag ai/report # AI performance metrics +uu debug/logs --tailLines=50 --includeErrorsOnly=true +uu debug/widget-events --widgetSelector="chat-widget" +uu ai/report # AI performance metrics ``` ### Persona Logging (Cognition Visibility) @@ -511,16 +511,16 @@ Persona logging is **opt-in** and controlled by `.continuum/logging.json`. Categ **Commands**: ```bash # Enable logging for a persona (persists to logging.json) -./jtag logging/enable --persona="helper" --category="cognition" +uu logging/enable --persona="helper" --category="cognition" # Disable logging for a persona -./jtag logging/disable --persona="helper" +uu logging/disable --persona="helper" # Show logging status for all personas -./jtag logging/status +uu logging/status # Show logging status for a specific persona -./jtag logging/status --persona="helper" +uu logging/status --persona="helper" ``` **Log locations**: @@ -581,7 +581,7 @@ mkdir daemons/logger-daemon && touch LoggerDaemon.ts **What generators provide:** - Auto-generated README with usage examples -- Help text that AIs can access via `./jtag command/name --help` +- Help text that AIs can access via `uu command/name --help` - Package.json integration for `npm run` scripts - Consistent structure across all modules - Proper discovery mechanisms @@ -600,13 +600,13 @@ mkdir daemons/logger-daemon && touch LoggerDaemon.ts npm start # 2. Ask AI team to test -./jtag collaboration/chat/send --room="general" --message="I just added a new 'collaboration/wall/write' command. Can you try writing a document to the wall and let me know if the error messages make sense?" +uu collaboration/chat/send --room="general" --message="I just added a new 'collaboration/wall/write' command. Can you try writing a document to the wall and let me know if the error messages make sense?" # 3. Wait for responses (30-60 seconds) sleep 60 # 4. Check their feedback -./jtag collaboration/chat/export --room="general" --limit=30 +uu collaboration/chat/export --room="general" --limit=30 # 5. Fix issues they found # - Improve error messages @@ -615,7 +615,7 @@ sleep 60 # - Clarify parameters # 6. Test again with AIs -./jtag collaboration/chat/send --room="general" --message="Fixed the error messages. Can you try again?" +uu collaboration/chat/send --room="general" --message="Fixed the error messages. Can you try again?" # 7. Once AIs confirm it works, THEN commit git commit -m "Add wall/write with AI-validated UX" @@ -963,7 +963,7 @@ The system bridges capability gaps so every persona gets the same senses: - Autonomous polling loop integrated into PersonaUser **🚧 IN PROGRESS (Phase 4)**: -- Task database and CLI commands (`./jtag task/create`, `task/list`, `task/complete`) +- Task database and CLI commands (`uu task/create`, `task/list`, `task/complete`) - Self-task generation (AIs create own work) **📋 PLANNED (Phases 5-7)**: @@ -1018,14 +1018,14 @@ async serviceInbox(): Promise { **Phase 4: Task Database & Commands** (NEXT) ```bash # Create task -./jtag task/create --assignee="helper-ai-id" \ +uu task/create --assignee="helper-ai-id" \ --description="Review main.ts" --priority=0.7 --domain="code" # List tasks -./jtag task/list --assignee="helper-ai-id" +uu task/list --assignee="helper-ai-id" # Complete task -./jtag task/complete --taskId="001" --outcome="Found 3 issues" +uu task/complete --taskId="001" --outcome="Found 3 issues" ``` **Phase 5: Self-Task Generation** @@ -1073,7 +1073,7 @@ npx vitest tests/integration/continuous-learning.test.ts # System tests (end-to-end) npm start # Wait 1 hour, check for self-created tasks -./jtag task/list --assignee="helper-ai-id" \ +uu task/list --assignee="helper-ai-id" \ --filter='{"createdBy":"helper-ai-id"}' ``` @@ -1156,7 +1156,7 @@ npx tsx generator/CommandGenerator.ts generator/specs/gpu-stats.json # 7. Build and verify npm run build:ts && npm start -./jtag gpu/stats +uu gpu/stats ``` **The three-layer architecture:** @@ -1167,7 +1167,7 @@ npm run build:ts && npm start | TS Mixin | `bindings/modules/gpu.ts` | snake_case→camelCase, typed wrapper | | TS Command | `commands/gpu/stats/` | Generated scaffold, uses mixin | -**Without the mixin + command layer**, Rust IPC commands exist but are invisible to `./jtag` and the command system. The generator creates discoverability (README, help text, CLI params). +**Without the mixin + command layer**, Rust IPC commands exist but are invisible to `uu` and the command system. The generator creates discoverability (README, help text, CLI params). --- @@ -1188,7 +1188,7 @@ Never guess - logs tell the truth ### 2. USE VISUAL VERIFICATION ```bash -./jtag interface/screenshot --querySelector="chat-widget" --filename="debug.png" +uu interface/screenshot --querySelector="chat-widget" --filename="debug.png" ``` Screenshots don't lie - don't trust success messages @@ -1213,12 +1213,12 @@ Local PersonaUsers (Helper AI, Teacher AI, CodeReview AI, Local Assistant, and 5 ```bash # STEP 1: Ask a question in the general room (no room ID needed!) -./jtag collaboration/chat/send --room="general" --message="How should I implement connection pooling for websockets?" +uu collaboration/chat/send --room="general" --message="How should I implement connection pooling for websockets?" # STEP 2: Wait 5-10 seconds for responses # STEP 3: View responses in chat widget -./jtag interface/screenshot --querySelector="chat-widget" +uu interface/screenshot --querySelector="chat-widget" # STEP 4: Export conversation to markdown (coming soon - see workflow below) ``` @@ -1227,25 +1227,25 @@ Local PersonaUsers (Helper AI, Teacher AI, CodeReview AI, Local Assistant, and 5 ```bash # 1. Send your question and capture the message ID -MESSAGE_ID=$(./jtag collaboration/chat/send --room="general" --message="What's the best way to handle rate limiting?" | jq -r '.messageId') +MESSAGE_ID=$(uu collaboration/chat/send --room="general" --message="What's the best way to handle rate limiting?" | jq -r '.messageId') # 2. Wait for AI responses (they respond within 5-10 seconds) sleep 10 # 3. Get all messages after your question -./jtag data/list --collection=chat_messages \ +uu data/list --collection=chat_messages \ --filter="{\"roomId\":\"ROOM_UUID\",\"timestamp\":{\"\$gte\":\"$MESSAGE_ID_TIMESTAMP\"}}" \ --orderBy='[{"field":"timestamp","direction":"asc"}]' # 4. View in browser -./jtag interface/screenshot --querySelector="chat-widget" +uu interface/screenshot --querySelector="chat-widget" ``` ### Future Workflow (Planned) ```bash # Export conversation thread to markdown -./jtag collaboration/chat/export --messageId="UUID" --format="markdown" --output="solution.md" +uu collaboration/chat/export --messageId="UUID" --format="markdown" --output="solution.md" # This will include: # - Your question @@ -1309,9 +1309,22 @@ The AIs will: ### 2. ASSUME SUCCESS WITHOUT TESTING **Fix**: Always take screenshot after deployment -### 3. WRONG WORKING DIRECTORY -**Always work from**: `src` -**Commands**: `./jtag` NOT `./continuum` +### 3. WRONG COMMAND NAME +**The command is `uu`.** Not `./jtag`, not `ctm`, and not `./continuum` from a +directory — `uu` is on `$PATH` after install. + +This entry used to say *"Always work from `src`"* and *"Commands: `./jtag` NOT +`./continuum`"*. Both were wrong, and wrong in the way that costs a fresh +session its first ten minutes: `src/` was the Node monolith and no longer +exists, `jtag` was its CLI and is not installed by anything, and `continuum` — +the one it told you to avoid — is a real alias that works. + +`uu` and `continuum` are the SAME binary under two names; `uu` is canonical +because it is shortest and it is what people reach for. The binary derives its +own name from `argv[0]`, so `uu --help` says `uu` and any future alias is +correct the moment it exists. `uu version` reports the build number + sha of the +binary in your hand, which is a different question from what the running core +is — ask both when they might disagree. ### 4. IGNORE EXISTING TYPES **Fix**: Search for types first: `find . -name "*Types.ts"` @@ -1335,7 +1348,7 @@ The AIs will: - **npm start takes 90+ seconds** - BE PATIENT - **One server, many clients** - All tests connect to running server -- **"browserConnected: false" is a red herring** - Use `./jtag ping` instead +- **"browserConnected: false" is a red herring** - Use `uu ping` instead - **Precommit hook is sacred** - TypeScript + CRUD tests must pass - **AI response testing is manual** - Hook doesn't test this, you must @@ -1621,7 +1634,7 @@ Generators and OOP are intertwined parallel forces: **File reduced from 61k to ~20k characters** - if you only edit a test, and not the api itself, you don't need to redeploy with npm start, just edit and test again e.g npx tsx tests/integration/genome-fine-tuning-e2e.test.ts - need to remember to npm run build:ts before deploying with npm start, just to make sure there's no compilation issues -- ./jtag collaboration/chat/export --room="general" --limit=30 will let you see ai opinions after chat/send to ask +- uu collaboration/chat/export --room="general" --limit=30 will let you see ai opinions after chat/send to ask - Tool logging is in PersonaToolExecutor - make sure to put any markdown architecture or design documents other than readmes in docs/* into the appropriate directort OR document if they exist. run tree there. - assume a new concept or group of functions ought to be in its own file and most likely own class. Use good OOP, interfaces, like java, dot net, or ts diff --git a/apps/web/package.json b/apps/web/package.json index 8ca260702c..65955bdeff 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,8 +9,8 @@ "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "TZ=UTC vitest run", - "test:watch": "TZ=UTC vitest" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@continuum/chat-view": "*", diff --git a/core/continuum-core/src/bin/continuum.rs b/core/continuum-core/src/bin/continuum.rs index bb40ed1c30..0b6d83fb62 100644 --- a/core/continuum-core/src/bin/continuum.rs +++ b/core/continuum-core/src/bin/continuum.rs @@ -52,6 +52,14 @@ async fn run() -> Result<(), String> { eprintln!("{}", usage()); Ok(()) } + // Handled HERE, never dispatched. `version` asks what THIS BINARY is; + // forwarding it to the core answered a different question and, when no + // core was running, answered none at all — the operator asking "what am + // I holding?" got "the substrate refused your command." + "version" | "--version" | "-V" => { + println!("{}", version_line()); + Ok(()) + } "start" => start().await, "reboot" | "restart" => { let force = args.any(|a| a == "--force"); @@ -1534,7 +1542,71 @@ mod tests { } } +/// The name this binary was actually INVOKED as, for help text. +/// +/// One binary ships under several names — `uu` (the short canonical one) and +/// `continuum` (the long-form alias kept so existing scripts and docs keep +/// working). Hardcoding "continuum" in the usage text meant `uu --help` printed +/// `usage: continuum ...`: the front door did not know its own name, and every +/// example it gave was a command the reader had not typed. +/// +/// Derived from argv[0] rather than a constant so a new alias is correct the +/// moment it exists, with nothing to remember to update. Falls back to the +/// canonical name when argv[0] is missing or unreadable — an odd exec is not a +/// reason to print nothing. +fn program_name() -> String { + std::env::args_os() + .next() + .map(std::path::PathBuf::from) + .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned())) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "uu".to_string()) +} + +/// This binary's build identity — number, sha, and when it was compiled. +/// +/// Joel's ruling 2026-08-08: versions must ALWAYS auto-increment and display +/// with the sha, in EVERY repo, visible on connection/health/query, because +/// stale binaries have repeatedly poisoned testing. +/// +/// This was UNANSWERABLE from the front door before: `continuum version` fell +/// through to the substrate dispatcher and came back +/// `Unknown command: 'version'` — the CLI could ask the core what IT was and +/// could not say what ITSELF was. That is the exact gap that lets an operator +/// debug a fixed bug with an unfixed binary in their hand. +fn version_line() -> String { + format!( + "{} #{} {} built {}", + program_name(), + option_env!("CONTINUUM_BUILD_NUMBER").unwrap_or("0"), + option_env!("CONTINUUM_BUILD_GIT_SHA").unwrap_or("unknown"), + option_env!("CONTINUUM_BUILD_AT").unwrap_or("unknown"), + ) +} + fn usage() -> String { + let me = program_name(); + format!( + "usage: {me} [json | --key value ...]\n\ + \n\ + Lifecycle:\n \ + {me} start build + run the headless Rust core (detached), wait until ready\n \ + {me} reboot rebuild + relaunch, replacing any running core (~0 downtime)\n \ + {me} stop stop the running core\n \ + {me} version this binary's build number + sha (NOT the core's)\n\ + \n\ + Commands (dispatch to the running core):\n \ + {me} ping\n \ + {me} ping --message hi # --key value, coerced + camelCased automatically\n \ + {me} commands/list # discover commands dynamically (single source)\n\ + \n\ + Env: CONTINUUM_CORE_SOCKET (default /tmp/continuum-core.sock)\n \ + CONTINUUM_START_SCRIPT (override the start script path)" + ) +} + +#[allow(dead_code)] +fn usage_legacy() -> String { "usage: continuum [json | --key value ...]\n\ \n\ Lifecycle:\n \ diff --git a/core/continuum-core/src/inference/llama_server.rs b/core/continuum-core/src/inference/llama_server.rs index 0790d64219..34309dc282 100644 --- a/core/continuum-core/src/inference/llama_server.rs +++ b/core/continuum-core/src/inference/llama_server.rs @@ -678,6 +678,21 @@ pub struct ServingSnapshot { #[serde(default)] #[ts(optional)] pub vision_model: Option, + /// WHICH ENGINE is serving — llama.cpp's own `/props.build_info` + /// (`b-` of the fork it was compiled from), read at + /// reconcile. `None` = nothing served, or a build too old to publish it. + /// + /// Here because "is my fix in the engine that is actually running?" was, until + /// 2026-08-09, answerable only by comparing binary mtimes across machines — and + /// answering it that way produced a wrong attribution (a Rust CORE build number + /// read as the engine's). The engine has published this all along; the daemon + /// simply never asked. See [`LlamaServerControl::engine_build`]. + /// + /// The sha is the load-bearing half: build numbers are ancestor counts, so our + /// fork and upstream can share one and mean different code. + #[serde(default)] + #[ts(optional)] + pub engine_build: Option, } impl ServingSnapshot { @@ -719,6 +734,10 @@ impl ServingSnapshot { vision_ready: false, vision_base_url: None, vision_model: None, + // Nothing served → no engine to identify. Never a placeholder string: + // an unknown engine and an engine that says it is "unknown" are + // different facts, and only the first one is true here. + engine_build: None, } } @@ -1048,6 +1067,38 @@ pub trait LlamaServerControl: Send + Sync { Ok(None) } + /// What the running engine says it IS — llama.cpp's `/props.build_info`, the + /// string `b-` compiled in from `cmake/build-info.cmake` + /// (`git rev-list --count HEAD` + `git rev-parse --short HEAD` **of the fork**). + /// + /// ## Why the daemon asks + /// + /// 2026-08-09: a per-slot wedge was attributed to "the fork bump in build 4577". + /// 4577 is the Rust CORE's build number; the engine has its own, unrelated one, and + /// the fork engine had not been rebuilt at all. Settling that took comparing + /// **binary mtimes on two machines** — because while llama-server has published its + /// identity all along (here, on `--version`, and as `system_fingerprint` on every + /// completion), nothing on OUR side of the seam ever READ it. The version surface + /// existed; the question could not be asked in our own terms. + /// + /// That is [[silently-unwired-capability]] with the polarity reversed — not a + /// capability we built and failed to wire, but one we DEPEND on, that upstream + /// hands us for free, and that we drop on the floor. The cost is the same: a fact + /// available for the asking gets re-derived by archaeology, and the derivation is + /// wrong often enough to send a diagnosis sideways for an afternoon. + /// + /// The commit sha is what makes the answer load-bearing: build NUMBERS are a count + /// of ancestors, so our fork and upstream can both say `b6789` and mean different + /// code. `b6789-a28ee566c` names exactly one tree. + /// + /// `Ok(None)` = the server answered but publishes no `build_info` (a build too old + /// to carry it). Unverifiable, never a guessed identity — the same contract + /// [`multimodal_support`](Self::multimodal_support) keeps. Default impl returns + /// `Ok(None)` so fakes and remote controls stay honest by construction. + async fn engine_build(&self) -> Result, LlamaServerError> { + Ok(None) + } + /// Prove the GPU DECODE path works, not just that the HTTP server is up. A /// llama-server can answer `/health`, `/v1/models` and `/props` with 200 /// while EVERY `llama_decode` returns 500 "Compute error" — observed live in @@ -1687,6 +1738,39 @@ impl LlamaServerControl for LlamaServerProcess { }) } + async fn engine_build(&self) -> Result, LlamaServerError> { + // Same root-level `/props` as the served window. `build_info` is compiled + // into the binary (`common/build-info.cpp.in`), so it describes the ENGINE + // ON DISK — not the model, not our core, not what we believe we shipped. + let url = format!("{}/props", self.root); + let resp = self + .client + .get(&url) + .timeout(PROBE_TIMEOUT) + .send() + .await + .map_err(|e| LlamaServerError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlamaServerError::Unreachable(format!( + "status {}", + resp.status() + ))); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| LlamaServerError::Unreachable(e.to_string()))?; + // Absent field → `Ok(None)`: this build cannot say what it is. An empty + // string is the same absence wearing a value's clothes, so it is filtered + // out rather than published as an identity nobody can look up. + Ok(body + .get("build_info") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string)) + } + async fn multimodal_support(&self) -> Result, LlamaServerError> { // Same root-level `/props` as the served window. llama.cpp publishes a // `modalities: { vision: bool, audio: bool }` block once an mtmd @@ -3009,6 +3093,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); assert!(!pred(&rx.borrow())); // not-ready but has a model → unsatisfied. @@ -3025,6 +3111,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); assert!(!pred(&rx.borrow())); // ready AND a model → satisfied, and wait_for resolves to it at once. @@ -3041,6 +3129,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); let got = tokio::time::timeout(Duration::from_millis(100), rx.wait_for(pred)) .await diff --git a/core/continuum-core/src/inference/model_commands.rs b/core/continuum-core/src/inference/model_commands.rs index 3ac36236c8..e08ec52889 100644 --- a/core/continuum-core/src/inference/model_commands.rs +++ b/core/continuum-core/src/inference/model_commands.rs @@ -56,6 +56,21 @@ pub struct InferenceStatusView { pub served_context_window: u32, /// The LoRA genome layers loaded into the serving catalog (sorted paths). pub adapters: Vec, + /// WHICH ENGINE is answering — llama.cpp's `/props.build_info`, i.e. + /// `b-` of the llama.cpp fork the binary was compiled + /// from. `None` = nothing served, or an engine too old to publish it. + /// + /// Surfaced here because this is the query an operator reaches for when serving + /// behaves unexpectedly, and until 2026-08-09 it could not be answered from our + /// own tools at all — settling "is my fork fix in the running binary?" meant + /// comparing binary mtimes across two machines, which produced a wrong + /// attribution (a Rust CORE build number read as the engine's). + /// + /// The sha is the half that decides: build numbers are ancestor counts, so our + /// fork and upstream can both report `b6789` and mean different code. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub engine_build: Option, } /// Read the canonical serving snapshot and project it. If the daemon has not yet @@ -69,6 +84,7 @@ fn current_status() -> InferenceStatusView { base_url: s.base_url, served_context_window: s.served_context_window, adapters: s.adapters, + engine_build: s.engine_build, } } @@ -92,8 +108,9 @@ impl ActionCommand for AiInferenceStatus { const DESCRIPTION: &'static str = "Report which model the inference engine is serving right now (activeModel), \ whether it is ready, the live serving base URL, the served context window, \ - and the LoRA genome layers loaded. Projected from the serving daemon's \ - canonical snapshot — this is how you confirm which brain is live."; + the LoRA genome layers loaded, and which engine build is answering \ + (engineBuild). Projected from the serving daemon's canonical snapshot — \ + this is how you confirm which brain is live."; type Params = StatusParams; type Output = InferenceStatusView; diff --git a/core/continuum-core/src/modules/serving_consumer.rs b/core/continuum-core/src/modules/serving_consumer.rs index be488588fb..d95b816079 100644 --- a/core/continuum-core/src/modules/serving_consumer.rs +++ b/core/continuum-core/src/modules/serving_consumer.rs @@ -352,6 +352,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); let (suppress_tx, _srx) = watch::channel(Arc::new(HashSet::new())); let (pin_tx, _prx) = watch::channel(None); @@ -441,6 +443,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); let footprint_of: FootprintFn = Arc::new(move |id: &str, window: u32, lanes: u32| { *seen_w.lock() = Some((id.to_string(), window, lanes)); @@ -537,6 +541,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); let (suppress_tx, _srx) = watch::channel(Arc::new(HashSet::new())); let (pin_tx, pin_rx) = watch::channel(None); diff --git a/core/continuum-core/src/modules/serving_daemon.rs b/core/continuum-core/src/modules/serving_daemon.rs index ac3ba0ea35..e5d500ddf3 100644 --- a/core/continuum-core/src/modules/serving_daemon.rs +++ b/core/continuum-core/src/modules/serving_daemon.rs @@ -1590,6 +1590,28 @@ impl ServingDaemonModule { } EnsureOutcome::Degraded { .. } => 0, }; + // WHICH engine is answering (#, 2026-08-09). Read from the same `/props` + // as the window, from the live process, so `serving/status` can answer + // "is my fork fix in the binary that is actually running?" without the + // mtime archaeology that produced a wrong attribution once already. + // A read failure is NOT a degrade: identity is diagnostic, and a lane + // that decodes fine while its `build_info` is unreadable is still a + // working lane. It publishes `None` — unknown, never guessed. + let engine_build = match &outcome { + EnsureOutcome::AlreadyServing | EnsureOutcome::Spawned { .. } => { + server.engine_build().await.unwrap_or_else(|e| { + crate::probe!( + class = "serving.reconcile", + desired = desired.as_str(), + error = %e, + "server ready but /props build_info unreadable — engine \ + identity unknown this tick (not a degrade; retries next tick)", + ); + None + }) + } + EnsureOutcome::Degraded { .. } => None, + }; // #106 vision readiness: for a ready lane, resolve the node's VERIFIED // vision endpoint. First the MAIN lane — the row's declared Vision, the // resolved mmproj, and the server's own `/props modalities` must all @@ -1723,6 +1745,7 @@ impl ServingDaemonModule { served_window, target.lanes, vision, + engine_build, ); crate::probe!( class = "serving.reconcile", @@ -1730,6 +1753,10 @@ impl ServingDaemonModule { ready = snapshot.ready, active = snapshot.active_model.as_deref().unwrap_or(""), served_window = snapshot.served_context_window, + // On the reconcile line because that is where an operator already + // looks when serving behaves unexpectedly, and "which engine" is + // the first question a surprising behaviour raises. + engine = snapshot.engine_build.as_deref().unwrap_or(""), "serving reconcile complete", ); // #363: remember the last HEALTHY lane's shape in a record that SURVIVES @@ -2696,6 +2723,9 @@ fn snapshot_from_outcome( // `vision_model` are all projected from this ONE value, so an address can // never be published without the verified flag (or vice versa). vision: Option, + // WHICH engine answered this reconcile — llama.cpp's `/props.build_info`. + // `None` = nothing served, or a build that cannot say what it is. + engine_build: Option, ) -> ServingSnapshot { match outcome { EnsureOutcome::AlreadyServing | EnsureOutcome::Spawned { .. } @@ -2740,6 +2770,9 @@ fn snapshot_from_outcome( vision_ready: vision.is_some(), vision_base_url: vision.as_ref().map(|v| v.base_url.clone()), vision_model: vision.map(|v| v.model_id), + // The engine's own account of itself, carried so a reader never has + // to infer it from a binary's mtime (2026-08-09). + engine_build, } } // Ready outcome but the served window was unreadable (0) → do NOT publish @@ -3536,6 +3569,7 @@ mod tests { base_url: "http://127.0.0.1:58091/v1".to_string(), model_id: "vl-7b".to_string(), }), + Some("b6789-a28ee566c".to_string()), ); assert_eq!(up.active_model.as_deref(), Some("coder-14b")); assert!(up.ready); @@ -3564,6 +3598,16 @@ mod tests { address can never publish without verified readiness" ); assert_eq!(up.vision_model.as_deref(), Some("vl-7b")); + // what this catches (2026-08-09): dropping the engine's own identity on the + // way to the snapshot. `/props` carries `build_info` and the daemon reads it, + // but if it does not SURVIVE to here, "which engine is running?" falls back to + // comparing binary mtimes across machines — which is how a Rust CORE build + // number got read as the engine's and misattributed a wedge. + assert_eq!( + up.engine_build.as_deref(), + Some("b6789-a28ee566c"), + "the engine's own build_info must reach the published snapshot" + ); let already = snapshot_from_outcome( &EnsureOutcome::AlreadyServing, @@ -3572,6 +3616,8 @@ mod tests { 11008, 4, None, + // An engine too old to publish `build_info` — serving is unaffected. + None, ); assert_eq!(already.active_model.as_deref(), Some("coder-14b")); assert!(already.ready); @@ -3585,6 +3631,13 @@ mod tests { already.vision_base_url.is_none() && already.vision_model.is_none(), "no verified endpoint → no address, no model (None-iff-not-ready)" ); + // what this catches: inventing an identity for an engine that did not give + // one. A build too old to publish `build_info` must read as UNKNOWN, never as + // a plausible-looking string a reader would then try to look up. + assert_eq!( + already.engine_build, None, + "an engine that cannot say what it is reads as unknown, not as a guess" + ); // Ready outcome but the served window was unreadable (0) → publish the gap, // NOT a ready snapshot with a zero window a persona would budget against. @@ -3595,6 +3648,7 @@ mod tests { 0, 4, None, + Some("b6789-a28ee566c".to_string()), ); assert_eq!( windowless.active_model, None, @@ -3603,6 +3657,14 @@ mod tests { assert!(!windowless.ready); assert_eq!(windowless.served_context_window, 0); assert_eq!(windowless.lanes, 0, "empty snapshot carries no lanes"); + // what this catches: a snapshot that says nothing is live while still naming + // an engine. Both halves would be read together ("not live, but running + // b6789?"), and the contradiction is worse than the absence — a reader would + // reasonably conclude the lane is up and the flag is stale. + assert_eq!( + windowless.engine_build, None, + "a not-live snapshot claims no engine, even when one answered the probe" + ); let degraded = snapshot_from_outcome( &EnsureOutcome::Degraded { reason: "x".into() }, @@ -3611,6 +3673,7 @@ mod tests { 11008, 4, None, + Some("b6789-a28ee566c".to_string()), ); assert_eq!(degraded.active_model, None, "degraded → nothing live"); assert!(!degraded.ready); @@ -3729,6 +3792,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); let budget = HostBudget { usable_bytes: 45 * GB, @@ -3793,6 +3858,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); (daemon, plan_window) } @@ -3980,6 +4047,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, } } @@ -4279,6 +4348,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); // A quarter of the plan is a 300% shortfall — far past the margin — but the // gain must still PERSIST before it buys a relaunch (BigMama's sustained-delta @@ -4310,6 +4381,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); assert!( daemon.reconcile_to_plan().is_none(), @@ -4340,6 +4413,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); let budget = HostBudget { usable_bytes: 45 * GB, diff --git a/install.ps1 b/install.ps1 index afce0a66c6..d0ad2a5528 100644 --- a/install.ps1 +++ b/install.ps1 @@ -86,6 +86,13 @@ try { Mod-Rust Mod-VSBuildTools Mod-CMake + # Beside CMake, not buried in the llama-server build: ninja is what makes the + # cmake CONFIGURE step deterministic across Visual Studio versions (cmake + # auto-picks the newest VS, and "Visual Studio 18 2026" is a generator cmake + # 3.30.x cannot name). Provisioning it here means a plain `cargo build` works + # from a fresh terminal; provisioning it lazily meant it only existed on boxes + # that had already built llama-server with CUDA. + Mod-Ninja Mod-LLVM Mod-CUDA Mod-GhAuth -WantsGrid:$WantsGrid diff --git a/package.json b/package.json index 641a971223..6d209483bd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "continuum", "private": true, - "description": "Continuum \u2014 a headless persona substrate. The Rust core is the server; every UI is an equal, dependent client over the same Commands/Events SDK. `npm start` boots ONLY the headless core (Rust, no desktop). Clients live under apps/ (web, desktop, cli[Rust], mobile, vr, ar, mcp) and attach on demand.", + "description": "Continuum — a headless persona substrate. The Rust core is the server; every UI is an equal, dependent client over the same Commands/Events SDK. `npm start` boots ONLY the headless core (Rust, no desktop). Clients live under apps/ (web, desktop, cli[Rust], mobile, vr, ar, mcp) and attach on demand.", "workspaces": [ "sdk/typescript", "packages/patterns", @@ -36,7 +36,14 @@ "ship": "node scripts/ship.mjs", "shot": "node scripts/shot.mjs", "preview:shot": "node scripts/preview-shot.mjs", - "preview:rec": "node scripts/preview-record.mjs" + "preview:rec": "node scripts/preview-record.mjs", + "deps:ensure": "bash tools/scripts/ensure-node-deps.sh", + "predev:web": "npm run deps:ensure", + "predev:desktop": "npm run deps:ensure", + "prebuild:clients": "npm run deps:ensure", + "prelint:clients": "npm run deps:ensure", + "pretypecheck:clients": "npm run deps:ensure", + "pretest:clients": "npm run deps:ensure" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.214", @@ -49,4 +56,4 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.64.0" } -} \ No newline at end of file +} diff --git a/packages/chat-view/package.json b/packages/chat-view/package.json index bdaaa554a9..128678fbd3 100644 --- a/packages/chat-view/package.json +++ b/packages/chat-view/package.json @@ -11,8 +11,8 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "TZ=UTC vitest run", - "test:watch": "TZ=UTC vitest" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@continuum/patterns": "*", diff --git a/packages/chat-view/vitest.config.ts b/packages/chat-view/vitest.config.ts new file mode 100644 index 0000000000..e5761ea51f --- /dev/null +++ b/packages/chat-view/vitest.config.ts @@ -0,0 +1,34 @@ +/** + * Vitest config for @continuum/chat-view. + * + * Exists for ONE reason: to pin the timezone where every platform can read it. + * + * Time-of-day rendering is viewer-local by design, so the fixed HH:MM + * assertions in `chatViewModel.spec.ts`, `crossConsumer.spec.ts` and + * `historyProjections.spec.ts` need a pinned zone to be deterministic on any + * runner. That pin used to live in the npm script as `TZ=UTC vitest run`. + * + * POSIX env-prefix syntax is not portable. On Windows, npm runs scripts through + * `cmd.exe`, which has no such form — the shell reads `TZ` as a command and the + * run dies with: + * + * 'TZ' is not recognized as an internal or external command + * + * So this whole suite, and `@continuum/web`'s alongside it, could not be run at + * all by a Windows contributor. It passed on Linux CI and on macOS, which is + * precisely why it survived: a test that only fails on the platform nobody + * checks is indistinguishable from a passing one until someone checks. Found + * 2026-08-08 by running the suite on Windows rather than trusting it. + * + * The runner config is node context on every platform, so the pin belongs here + * — the same reasoning `apps/web/vite.config.ts` already gives for keeping it + * out of the specs. No cross-env dependency: the fix removes a portability + * assumption instead of packaging a tool to satisfy it. + */ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + env: { TZ: 'UTC' }, + }, +}); diff --git a/tools/plugins/memory-bridge/.claude-plugin/plugin.json b/tools/plugins/memory-bridge/.claude-plugin/plugin.json index af837b3d30..9d4e736679 100644 --- a/tools/plugins/memory-bridge/.claude-plugin/plugin.json +++ b/tools/plugins/memory-bridge/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memory-bridge", "description": "Agent memory bridge — automatic relevance-recall at session start (incl. after compaction) plus /remember, /recall, and /share (hand a lesson to another agent), backed by the continuum memory/* substrate. Stops the agent re-forgetting across amnesia resets.", - "version": "0.1.0", + "version": "0.2.0", "author": { "name": "Continuum (BigMama + M5)" } diff --git a/tools/plugins/memory-bridge/README.md b/tools/plugins/memory-bridge/README.md index 25fc8273ee..5eee8fd323 100644 --- a/tools/plugins/memory-bridge/README.md +++ b/tools/plugins/memory-bridge/README.md @@ -67,8 +67,38 @@ is a live probe of a daemon that legitimately restarts; without the cache, every airc outage silently disabled memory for a whole session. ## Status -Live. Both hooks installed and verified end-to-end 2026-08-05 (recall returns -real engrams; capture stores; failure paths produce receipts + context notices). +Both hooks verified end-to-end 2026-08-05 against **this source tree** (recall +returns real engrams; capture stores; failure paths produce receipts + context +notices). -## Install (local dev) -`claude --plugin-dir tools/plugins/memory-bridge` +That sentence used to read "Status: Live", which was a claim this file is not in +a position to make. A README describes the repo; whether the plugin is live +depends on *your install*, and on 2026-08-09 those had been different on BigMama +for two weeks — see below. + +## Install — and why "which install" matters +Two paths, with very different freshness semantics: + +- **Live from the repo (dev):** `claude --plugin-dir tools/plugins/memory-bridge` + Runs the scripts in this tree. `git pull` *is* the update. +- **Marketplace install:** copies the plugin to + `~/.claude/plugins/cache////` and pins it to a git + sha. **Nothing re-syncs it. `git pull` changes nothing.** + +Measured 2026-08-09: the installed copy on BigMama was pinned to `60fa0dbf` +(2026-07-25). Its `lib.sh` had no persona-id cache, and it contained no +`session-capture.sh` at all — so automatic per-turn capture, the entire +"volitional memory isn't memory" point, had never run once on that machine while +this README said the bridge was live. + +So `session-recall.sh` now checks: if the running copy is under a plugins cache +AND its scripts differ from `tools/plugins/memory-bridge/scripts` in the current +checkout, it injects **⚠️ MEMORY BRIDGE STALE** and writes a `stale` receipt. Same +discipline as the rest of this plugin — "installed" must not be indistinguishable +from "working", and "current" must not be indistinguishable from "stale". A fix +that never reaches the executing copy is identical to a fix never written. + +Check which one you are on: +```bash +tail -3 ~/.continuum/memory-bridge/receipts.jsonl # a "stale" line names the frozen path +``` diff --git a/tools/plugins/memory-bridge/scripts/lib.sh b/tools/plugins/memory-bridge/scripts/lib.sh index 688634630f..6e3c343cbe 100644 --- a/tools/plugins/memory-bridge/scripts/lib.sh +++ b/tools/plugins/memory-bridge/scripts/lib.sh @@ -31,6 +31,43 @@ resolve_continuum() { return 1 } +# Resolve the airc BINARY, the same way `resolve_continuum` above resolves ours. +# +# Hooks do NOT inherit the operator's interactive shell. They run under whatever +# environment spawned the agent runtime, and `airc` installs to `~/.local/bin` — +# routinely absent from that PATH. Measured 2026-08-09 on BigMama: `airc status` +# answered instantly in every terminal (daemon at 21h uptime, 1305/1305 acked) +# while the session-recall hook wrote `persona id unresolved (airc status down)` +# for two sessions running. The probe never observed the daemon at all; bare +# `airc` was not a program it could find, and `2>/dev/null` swallowed the +# "command not found" that would have said so. +# +# So engram recall was silently off for every session on this machine, which is +# precisely the invisible-death this bridge exists to prevent — arriving through +# the resolver instead of the daemon. Two answers to "find a binary" in ONE file, +# only one of them robust, is the drift; now there is one shape. +resolve_airc() { + if [ -n "${AIRC_BIN:-}" ] && [ -x "${AIRC_BIN}" ]; then + printf '%s' "${AIRC_BIN}" + return 0 + fi + if command -v airc >/dev/null 2>&1; then + command -v airc + return 0 + fi + local candidate + # `.exe` variants matter: on Windows the installed binary is airc.exe, and a + # bare-name `-x` test does not find it. [[dir-opened-as-file-windows-only]] + for candidate in "$HOME/.local/bin/airc" "$HOME/.local/bin/airc.exe" \ + "$HOME/.cargo/bin/airc" "$HOME/.cargo/bin/airc.exe"; do + if [ -x "$candidate" ]; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + # Resolve the agent's persona id (its airc peer id). $CONTINUUM_AGENT_PERSONA wins # (lets a runtime pin identity); else derive from `airc status`; else the cache. # @@ -47,8 +84,12 @@ resolve_agent_persona() { printf '%s' "${CONTINUUM_AGENT_PERSONA}" return 0 fi - local live cached="$BRIDGE_STATE_DIR/persona-id" - live="$(airc status 2>/dev/null | awk '/^peer_id:/{print $2; exit}')" + local live cached="$BRIDGE_STATE_DIR/persona-id" airc_bin + if airc_bin="$(resolve_airc)"; then + live="$("$airc_bin" status 2>/dev/null | awk '/^peer_id:/{print $2; exit}')" + else + live="" + fi if [ -n "${live:-}" ]; then mkdir -p "$BRIDGE_STATE_DIR" 2>/dev/null && printf '%s' "$live" > "$cached" 2>/dev/null printf '%s' "$live" @@ -61,6 +102,83 @@ resolve_agent_persona() { return 1 } +# WHY resolve_agent_persona failed, for the caller's receipt. +# +# A separate function that RE-DERIVES rather than a variable set inside +# `resolve_agent_persona`: callers invoke it as `$(resolve_agent_persona)`, a +# command substitution, which is a SUBSHELL — anything it assigns dies with it. +# The first version of this fix did exactly that and the receipt came out blank; +# the negative test caught it. Same subshell trap as piping `source`. +# [[absence-rendered-as-positive-fact]] +# +# Re-deriving is cheap (one `command -v`, a few `-x` tests) and only ever runs on +# the failure path, where a fraction of a millisecond buys a receipt that names +# the actual cause instead of guessing at the daemon's health. +# +# "No airc binary" and "airc ran and reported nothing" are different types, not +# two values of one type. Conflating them is what wrote `airc status down` into +# two sessions' receipts about a daemon at 21h uptime. +persona_failure_reason() { + local airc_bin + if ! airc_bin="$(resolve_airc)"; then + printf 'no airc binary on PATH or in ~/.local/bin, ~/.cargo/bin (set AIRC_BIN to pin it)' + return 0 + fi + if [ -z "$("$airc_bin" status 2>/dev/null | awk '/^peer_id:/{print $2; exit}')" ]; then + printf 'airc found at %s but it reported no peer_id — daemon down or not joined' "$airc_bin" + return 0 + fi + # Reached only if the id resolves NOW but did not a moment ago (a daemon that + # came up in between). Say that, rather than inventing a cause. + printf 'airc answers now (transient failure during the earlier probe)' +} + +# stale_install_notice — warn when the RUNNING plugin is a frozen +# copy that has drifted from this repo's source. Prints the notice, or nothing. +# +# There are two install paths with very different freshness semantics, and +# nothing told you which one you were on: +# +# * `claude --plugin-dir tools/plugins/memory-bridge` runs LIVE from the repo — +# always current, a `git pull` is the update. +# * a marketplace install COPIES the plugin to +# ~/.claude/plugins/cache//// and pins it to a +# git sha. Nothing re-syncs it. A `git pull` changes nothing. +# +# Measured 2026-08-09 on BigMama: the installed copy was pinned to 60fa0dbf from +# 2026-07-25 — two weeks stale. Its lib.sh had no persona-id cache, and it had no +# session-capture.sh AT ALL, so automatic per-turn capture (the whole "volitional +# memory isn't memory" point) had never run once on that machine. Meanwhile this +# README said "Status: Live. Both hooks installed and verified end-to-end" — true +# of the repo, false of the running install, and indistinguishable from outside. +# +# That is the plugin's own founding defect one level up. It already refuses to let +# "installed" and "working" be indistinguishable; "current" and "stale" deserve the +# same treatment, because a fix that never reaches the executing copy is identical +# to a fix that was never written. +stale_install_notice() { + local script_dir="${1:-}" repo src f drifted=0 + # Only a cached COPY can be stale. Running from the repo is current by construction. + case "$script_dir" in + */plugins/cache/*) : ;; + *) return 0 ;; + esac + repo="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 + src="$repo/tools/plugins/memory-bridge/scripts" + # Not the continuum checkout (an agent working in some other repo) — nothing to + # compare against, so say nothing rather than guess. + [ -d "$src" ] || return 0 + # A file MISSING from the install counts as drift: that is exactly how + # session-capture.sh was silently absent for two weeks. + for f in lib.sh session-recall.sh session-capture.sh share.sh; do + [ -f "$src/$f" ] || continue + cmp -s "$src/$f" "$script_dir/$f" 2>/dev/null || drifted=$((drifted + 1)) + done + [ "$drifted" -gt 0 ] || return 0 + printf '⚠️ MEMORY BRIDGE STALE — the plugin actually running is a frozen copy at %s, and %s of its scripts differ from this repo (%s). Fixes committed here are NOT live: a git pull does not update a marketplace-installed plugin. Reinstall the plugin, or run it live with `claude --plugin-dir tools/plugins/memory-bridge`.' \ + "$script_dir" "$drifted" "$src" +} + # bridge_receipt [detail] — durable one-line JSONL receipt. # # The bridge's hooks MUST never break a session, so every failure path exits 0. diff --git a/tools/plugins/memory-bridge/scripts/session-capture.sh b/tools/plugins/memory-bridge/scripts/session-capture.sh index 06b733b787..9bd8ae3303 100755 --- a/tools/plugins/memory-bridge/scripts/session-capture.sh +++ b/tools/plugins/memory-bridge/scripts/session-capture.sh @@ -30,7 +30,7 @@ CONTINUUM="$(resolve_continuum)" || { exit 0 } PERSONA="$(resolve_agent_persona)" || { - bridge_receipt session-capture failed "persona id unresolved (airc down AND no cached id)" + bridge_receipt session-capture failed "persona id unresolved and no cached id — $(persona_failure_reason)" exit 0 } [ -n "${PERSONA:-}" ] || { bridge_receipt session-capture failed "persona id empty"; exit 0; } diff --git a/tools/plugins/memory-bridge/scripts/session-recall.sh b/tools/plugins/memory-bridge/scripts/session-recall.sh index d07ce3408c..ef8baf0d9e 100755 --- a/tools/plugins/memory-bridge/scripts/session-recall.sh +++ b/tools/plugins/memory-bridge/scripts/session-recall.sh @@ -44,8 +44,14 @@ SCOPE_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" PROJECT="$(basename "$SCOPE_DIR")" PERSONA="$(resolve_agent_persona)" || { - bridge_receipt session-recall failed "persona id unresolved (airc status down AND no cached id)" - emit_notice "⚠️ MEMORY BRIDGE DOWN — recall did not run: could not resolve this agent's persona id (\`airc status\` gave nothing and no cached id exists at ~/.continuum/memory-bridge/persona-id). Your engram memory is NOT loaded this session. Fix: start airc, or set CONTINUUM_AGENT_PERSONA." + # State the MEASURED cause, not a presumed one. This notice used to assert + # "`airc status` gave nothing" unconditionally — so on 2026-08-09 it told the + # agent the daemon was down while airc had 21h uptime and 1305/1305 acked. The + # real cause was that the hook's PATH lacks ~/.local/bin, and a notice that + # names the wrong cause sends the reader's whole diagnosis sideways. + WHY="$(persona_failure_reason)" + bridge_receipt session-recall failed "persona id unresolved and no cached id — $WHY" + emit_notice "⚠️ MEMORY BRIDGE DOWN — recall did not run: could not resolve this agent's persona id, and no cached id exists at ~/.continuum/memory-bridge/persona-id. Measured cause: ${WHY}. Your engram memory is NOT loaded this session; treat yourself as amnesiac and say so rather than assuming recall works. Fix: make airc resolvable (AIRC_BIN=/path/to/airc), or set CONTINUUM_AGENT_PERSONA." exit 0 } [ -n "${PERSONA:-}" ] || { @@ -82,6 +88,15 @@ fi bridge_receipt session-recall ok "source=${SOURCE:-startup} max=$MAX bytes=${#OUT}" printf '%s\n' "$OUT" +# Staleness is orthogonal to whether recall WORKED: a frozen copy can recall +# perfectly and still be missing every fix committed since it was installed. So +# this runs on the success path too, and says so where the agent will read it. +STALE="$(stale_install_notice "$SCRIPT_DIR")" +if [ -n "$STALE" ]; then + bridge_receipt session-recall stale "$STALE" + emit_notice "$STALE" +fi + # The Stop hook (capture) has no channel to the agent — its failures would be # invisible forever. Surface the last capture receipt here, where the agent reads. LAST_CAPTURE="$(grep -a '"hook":"session-capture"' "$BRIDGE_STATE_DIR/receipts.jsonl" 2>/dev/null | tail -1)" diff --git a/tools/plugins/memory-bridge/scripts/share.sh b/tools/plugins/memory-bridge/scripts/share.sh index ed2b7f0a1b..fda67a9053 100644 --- a/tools/plugins/memory-bridge/scripts/share.sh +++ b/tools/plugins/memory-bridge/scripts/share.sh @@ -40,9 +40,12 @@ resolve_recipient() { printf '%s' "$raw"; return 0 fi # Name → peer id via airc, best-effort (whois first, then a peers-table scan). - local id - id="$(airc whois "$raw" 2>/dev/null | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" - [ -z "$id" ] && id="$(airc peers 2>/dev/null | grep -iF "$raw" | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" + # Through resolve_airc, not bare `airc`: this runs from a skill whose PATH is the + # runtime's, not the operator's, and ~/.local/bin is routinely missing from it. + local id airc_bin + airc_bin="$(resolve_airc)" || return 1 + id="$("$airc_bin" whois "$raw" 2>/dev/null | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" + [ -z "$id" ] && id="$("$airc_bin" peers 2>/dev/null | grep -iF "$raw" | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" printf '%s' "$id" } diff --git a/tools/scripts/check-plugin-version.sh b/tools/scripts/check-plugin-version.sh new file mode 100644 index 0000000000..7b71bb9213 --- /dev/null +++ b/tools/scripts/check-plugin-version.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# check-plugin-version.sh — a plugin's content must never change without its +# version changing. +# +# ## The failure this guards +# +# Claude Code plugin updates are VERSION-BASED. A marketplace install copies the +# plugin to ~/.claude/plugins/cache//// and pins it. +# `claude plugin update` compares the DECLARED version in plugin.json against the +# installed one — it does not look at content. So if the scripts change and the +# version does not, every installed copy answers: +# +# ✔ memory-bridge is already at the latest version (0.1.0). +# +# ...forever, and the fix reaches nobody. `git pull` does not update a plugin. +# +# Measured 2026-08-09: memory-bridge sat at 0.1.0 since 2026-07-25 while its +# scripts gained a persona-id cache and a whole session-capture.sh. The installed +# copy on BigMama had NEITHER — automatic per-turn memory capture had never run +# once on that machine, while the repo held working code and the README said the +# bridge was live. Bumping 0.1.0 → 0.2.0 propagated all of it in one command. +# +# This is [[silently-unwired-capability]] in its deployment form: a fix that never +# reaches the executing copy is identical to a fix never written. The repo keeps +# looking correct, because it is. +# +# ## The gate +# +# If a commit touches any file under a plugin directory, that plugin's +# `.claude-plugin/plugin.json` version MUST also change. Same shape as the +# install-manifest projection guard: the generated/consumed artifact and its +# source cannot drift apart silently. +# +# tools/scripts/check-plugin-version.sh # staged changes (pre-commit) +# tools/scripts/check-plugin-version.sh # a range (CI) +# +# Exits 0 when clean or when no plugin files changed; 1 (loud) on a missed bump. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +PLUGIN_ROOT="tools/plugins" +[ -d "$PLUGIN_ROOT" ] || exit 0 # no plugins in this tree — nothing to guard + +BASE="${1:-}" +if [ -n "$BASE" ]; then + CHANGED="$(git diff --name-only "$BASE"...HEAD -- "$PLUGIN_ROOT" 2>/dev/null)" + DESC="changed since $BASE" +else + CHANGED="$(git diff --cached --name-only -- "$PLUGIN_ROOT" 2>/dev/null)" + DESC="staged" +fi + +[ -n "$CHANGED" ] || exit 0 + +# Which plugins were touched? A path is tools/plugins//... — the marketplace +# manifest at tools/plugins/.claude-plugin/ has no segment, so the `.`-prefixed +# entry is filtered out rather than treated as a plugin called ".claude-plugin". +PLUGINS="$(printf '%s\n' "$CHANGED" \ + | sed -n "s#^$PLUGIN_ROOT/\([^/.][^/]*\)/.*#\1#p" | sort -u)" + +[ -n "$PLUGINS" ] || exit 0 + +FAILED=0 +for plugin in $PLUGINS; do + manifest="$PLUGIN_ROOT/$plugin/.claude-plugin/plugin.json" + if [ ! -f "$manifest" ]; then + echo "✗ $plugin: no $manifest — a plugin without a manifest cannot be versioned or installed" >&2 + FAILED=1 + continue + fi + # Did the version line itself change in this same set? Compare the manifest's + # version before and after rather than trusting that the manifest was touched: + # editing the description is not a release. + if [ -n "$BASE" ]; then + before="$(git show "$BASE:$manifest" 2>/dev/null)" + after="$(git show "HEAD:$manifest" 2>/dev/null)" + else + before="$(git show "HEAD:$manifest" 2>/dev/null)" + after="$(cat "$manifest" 2>/dev/null)" + fi + # A brand-new plugin has no `before` — nothing to bump from, so it passes. + [ -n "$before" ] || continue + v_before="$(printf '%s' "$before" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" + v_after="$(printf '%s' "$after" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" + if [ -z "$v_after" ]; then + echo "✗ $plugin: $manifest declares no \"version\" — plugin update has nothing to compare" >&2 + FAILED=1 + continue + fi + if [ "$v_before" = "$v_after" ]; then + echo "✗ $plugin: files $DESC but version is still \"$v_after\"." >&2 + echo " Plugin updates are VERSION-based, not content-based. Every installed copy will" >&2 + echo " report 'already at the latest version' and keep running the OLD scripts — your" >&2 + echo " change reaches nobody, and nothing reports the gap." >&2 + echo " Fix: bump \"version\" in $manifest" >&2 + FAILED=1 + fi +done + +if [ "$FAILED" -ne 0 ]; then + echo "" >&2 + echo "plugin-version gate failed. See the header of tools/scripts/check-plugin-version.sh" >&2 + exit 1 +fi + +exit 0 diff --git a/tools/scripts/ensure-node-deps.sh b/tools/scripts/ensure-node-deps.sh new file mode 100644 index 0000000000..bc7027cab8 --- /dev/null +++ b/tools/scripts/ensure-node-deps.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# ensure-node-deps.sh — install the npm workspace when it is missing or stale. +# +# ## Why this exists +# +# `install.sh` runs `npm install` ONCE, at install time. Nothing re-ran it when +# the manifest moved. So a contributor who installed in June and pulled in August +# had a `node_modules` that silently did not match its own `package.json` — and +# the first symptom was every client spec file failing at COLLECTION with: +# +# Failed to load url @continuum/chat-view. Does the file exist? +# +# which points a newcomer at missing SOURCE, not at their missing deps. Measured +# 2026-08-08 on a real checkout: tree from Jun 17, manifest from Aug 5, `lit` and +# every `@continuum/*` workspace package absent, seven spec files dead. +# +# ## Why HERE and not in the start path +# +# `start-server.sh` is headless Rust by doctrine — "No Node, no TS, no widgets." +# A dependency guard wired there would drag npm into a runtime path that exists +# precisely to avoid it. So this hangs off the CLIENT scripts only (`pre*` hooks +# on dev:web / build:clients / test:clients / typecheck:clients). Someone who +# only ever runs the core pays nothing and never sees this file run. +# +# ## Why mtime and not a checksum +# +# npm writes `node_modules/.package-lock.json` when it materialises the tree, so +# "tree older than lockfile" is exactly the question worth asking, answerable +# without parsing either file or shelling out to npm. A checksum would be more +# precise about CONTENT and no more precise about the thing that actually breaks +# people, which is a tree that predates a manifest change. +# +# Skipped entirely in CI: `npm ci` there is authoritative and already ran, and a +# second install would only add minutes and a chance to disagree with the +# lockfile. + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# CI installs from the lockfile deliberately; never second-guess it. +if [ -n "${CI:-}" ]; then + exit 0 +fi + +# Escape hatch for anyone deliberately hand-managing their tree. +if [ -n "${CONTINUUM_SKIP_DEP_CHECK:-}" ]; then + exit 0 +fi + +INSTALLED_MARKER="node_modules/.package-lock.json" +REASON="" + +if [ ! -d node_modules ]; then + REASON="node_modules is missing" +elif [ ! -f "$INSTALLED_MARKER" ]; then + # A node_modules with no marker was not written by a modern npm install — + # treat it as unknown rather than assume it is good. + REASON="node_modules has no install marker" +elif [ package-lock.json -nt "$INSTALLED_MARKER" ]; then + REASON="package-lock.json is newer than the installed tree" +elif [ package.json -nt "$INSTALLED_MARKER" ]; then + REASON="package.json is newer than the installed tree" +fi + +if [ -z "$REASON" ]; then + exit 0 +fi + +# Loud, never silent. A guard that fixes things without saying so teaches the +# operator that installs are magic, and hides a real signal (a lockfile moving +# under them) that is sometimes worth knowing about. +echo "deps: $REASON — running npm install" +npm install --silent +echo "deps: workspace up to date" diff --git a/tools/scripts/generated/manifest.windows.ps1 b/tools/scripts/generated/manifest.windows.ps1 index 49d592b79d..521811c815 100644 --- a/tools/scripts/generated/manifest.windows.ps1 +++ b/tools/scripts/generated/manifest.windows.ps1 @@ -13,7 +13,8 @@ $script:ContinuumManifest = [ordered]@{ 'airc-firewall' = @{ order = 27; tier = 0; flags = @('grid'); applies = 'has-airc'; accept = 'netsh advfirewall firewall show rule name="airc daemon inbound (continuum grid)"'; source = @{ type = 'command'; run = 'New-NetFirewallRule -DisplayName ''airc daemon inbound (continuum grid)'' -Direction Inbound -Action Allow -Profile Any' } } 'manifest-gen' = @{ order = 28; tier = 3; flags = @('dev'); accept = 'cargo run -q -p manifest-gen -- --check'; source = @{ type = 'command'; run = 'cargo run -q -p manifest-gen' } } 'msvc' = @{ order = 30; tier = 3; flags = @('dev'); accept = 'vswhere -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath'; source = @{ type = 'winget'; id = 'Microsoft.VisualStudio.2022.BuildTools'; override = '--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended' } } - 'cmake' = @{ order = 40; tier = 3; flags = @('dev'); accept = 'cmake --version'; source = @{ type = 'archive'; url = 'https://github.com/Kitware/CMake/releases/download/v3.30.5/cmake-3.30.5-windows-x86_64.zip'; version = '3.30.5'; sha256 = '5ab6e1faf20256ee4f04886597e8b6c3b1bd1297b58a68a58511af013710004b'; extract = 'strip-top-dir' } } + 'cmake' = @{ order = 40; tier = 3; flags = @('dev'); accept = 'cmake --version'; source = @{ type = 'archive'; url = 'https://github.com/Kitware/CMake/releases/download/v3.30.5/cmake-3.30.5-windows-x86_64.zip'; version = '3.30.5'; sha256 = '5ab6e1faf20256ee4f04886597e8b6c3b1bd1297b58a68a58511af013710004b'; extract = 'strip-top-dir' }; runtime_path = @('~/.continuum/tools/cmake/bin') } + 'ninja' = @{ order = 45; tier = 3; flags = @('dev'); accept = 'ninja --version'; source = @{ type = 'archive'; url = 'https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-win.zip'; version = '1.12.1'; sha256 = 'f550fec705b6d6ff58f2db3c374c2277a37691678d6aba463adcbb129108467a'; extract = 'flat' }; runtime_path = @('~/.continuum/tools/ninja') } 'llvm-libclang' = @{ order = 50; tier = 3; flags = @('dev'); accept = 'test-path ~/.continuum/tools/llvm/bin/libclang.dll'; source = @{ type = 'archive'; url = 'https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/clang+llvm-18.1.8-x86_64-pc-windows-msvc.tar.xz'; version = '18.1.8'; sha256 = '22c5907db053026cc2a8ff96d21c0f642a90d24d66c23c6d28ee7b1d572b82e8'; extract = 'members:*/bin/libclang.dll,*/lib/clang/*' } } 'cuda' = @{ order = 60; tier = 3; flags = @('dev'); applies = 'has-nvidia'; accept = 'nvcc --version >= 12.8'; source = @{ type = 'redist'; version = '12.9.1'; manifest = 'https://developer.download.nvidia.com/compute/cuda/redist/redistrib_12.9.1.json'; components = @('cuda_nvcc', 'cuda_cudart', 'libcublas', 'libcurand', 'cuda_nvrtc', 'cuda_cccl') }; runtime_path = @('~/.continuum/cuda-*/Library/bin') } 'build-core' = @{ order = 90; tier = 3; flags = @('dev'); accept = 'continuum-core-server.exe boots past the GPU-detection gate on the target device'; build = @{ features = 'cuda,load-dynamic-ort'; profile = 'release'; crt = 'static'; cmake_generator = 'Visual Studio 17 2022'; cuda_arch = '120'; msvc_host = 'vs2022' } } diff --git a/tools/scripts/generated/manifest.windows.sh b/tools/scripts/generated/manifest.windows.sh index 12d66671e4..85590909b3 100644 --- a/tools/scripts/generated/manifest.windows.sh +++ b/tools/scripts/generated/manifest.windows.sh @@ -7,18 +7,18 @@ # ============================================================================== # platform: windows -CONTINUUM_MODULES=('rust' 'gh' 'gh-auth' 'airc-firewall' 'manifest-gen' 'msvc' 'cmake' 'llvm-libclang' 'cuda' 'build-core' 'run') +CONTINUUM_MODULES=('rust' 'gh' 'gh-auth' 'airc-firewall' 'manifest-gen' 'msvc' 'cmake' 'ninja' 'llvm-libclang' 'cuda' 'build-core' 'run') -declare -A MOD_ORDER=( ['rust']='10' ['gh']='20' ['gh-auth']='25' ['airc-firewall']='27' ['manifest-gen']='28' ['msvc']='30' ['cmake']='40' ['llvm-libclang']='50' ['cuda']='60' ['build-core']='90' ['run']='100' ) -declare -A MOD_TIER=( ['rust']='0' ['gh']='0' ['gh-auth']='0' ['airc-firewall']='0' ['manifest-gen']='3' ['msvc']='3' ['cmake']='3' ['llvm-libclang']='3' ['cuda']='3' ['build-core']='3' ['run']='3' ) -declare -A MOD_FLAGS=( ['gh-auth']='grid' ['airc-firewall']='grid' ['manifest-gen']='dev' ['msvc']='dev' ['cmake']='dev' ['llvm-libclang']='dev' ['cuda']='dev' ['build-core']='dev' ) +declare -A MOD_ORDER=( ['rust']='10' ['gh']='20' ['gh-auth']='25' ['airc-firewall']='27' ['manifest-gen']='28' ['msvc']='30' ['cmake']='40' ['ninja']='45' ['llvm-libclang']='50' ['cuda']='60' ['build-core']='90' ['run']='100' ) +declare -A MOD_TIER=( ['rust']='0' ['gh']='0' ['gh-auth']='0' ['airc-firewall']='0' ['manifest-gen']='3' ['msvc']='3' ['cmake']='3' ['ninja']='3' ['llvm-libclang']='3' ['cuda']='3' ['build-core']='3' ['run']='3' ) +declare -A MOD_FLAGS=( ['gh-auth']='grid' ['airc-firewall']='grid' ['manifest-gen']='dev' ['msvc']='dev' ['cmake']='dev' ['ninja']='dev' ['llvm-libclang']='dev' ['cuda']='dev' ['build-core']='dev' ) declare -A MOD_APPLIES=( ['airc-firewall']='has-airc' ['cuda']='has-nvidia' ) -declare -A MOD_ACCEPT=( ['rust']='rustc --version' ['gh']='gh --version' ['gh-auth']='gh auth status' ['airc-firewall']='netsh advfirewall firewall show rule name="airc daemon inbound (continuum grid)"' ['manifest-gen']='cargo run -q -p manifest-gen -- --check' ['msvc']='vswhere -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath' ['cmake']='cmake --version' ['llvm-libclang']='test-path ~/.continuum/tools/llvm/bin/libclang.dll' ['cuda']='nvcc --version >= 12.8' ['build-core']='continuum-core-server.exe boots past the GPU-detection gate on the target device' ['run']='continuum-core-server binary present + serves TCP 9100' ) -declare -A MOD_TYPE=( ['rust']='winget' ['gh']='winget' ['gh-auth']='command' ['airc-firewall']='command' ['manifest-gen']='command' ['msvc']='winget' ['cmake']='archive' ['llvm-libclang']='archive' ['cuda']='redist' ) -declare -A MOD_URL=( ['cmake']='https://github.com/Kitware/CMake/releases/download/v3.30.5/cmake-3.30.5-windows-x86_64.zip' ['llvm-libclang']='https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/clang+llvm-18.1.8-x86_64-pc-windows-msvc.tar.xz' ) -declare -A MOD_VERSION=( ['cmake']='3.30.5' ['llvm-libclang']='18.1.8' ['cuda']='12.9.1' ) -declare -A MOD_SHA256=( ['cmake']='5ab6e1faf20256ee4f04886597e8b6c3b1bd1297b58a68a58511af013710004b' ['llvm-libclang']='22c5907db053026cc2a8ff96d21c0f642a90d24d66c23c6d28ee7b1d572b82e8' ) -declare -A MOD_EXTRACT=( ['cmake']='strip-top-dir' ['llvm-libclang']='members:*/bin/libclang.dll,*/lib/clang/*' ) +declare -A MOD_ACCEPT=( ['rust']='rustc --version' ['gh']='gh --version' ['gh-auth']='gh auth status' ['airc-firewall']='netsh advfirewall firewall show rule name="airc daemon inbound (continuum grid)"' ['manifest-gen']='cargo run -q -p manifest-gen -- --check' ['msvc']='vswhere -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath' ['cmake']='cmake --version' ['ninja']='ninja --version' ['llvm-libclang']='test-path ~/.continuum/tools/llvm/bin/libclang.dll' ['cuda']='nvcc --version >= 12.8' ['build-core']='continuum-core-server.exe boots past the GPU-detection gate on the target device' ['run']='continuum-core-server binary present + serves TCP 9100' ) +declare -A MOD_TYPE=( ['rust']='winget' ['gh']='winget' ['gh-auth']='command' ['airc-firewall']='command' ['manifest-gen']='command' ['msvc']='winget' ['cmake']='archive' ['ninja']='archive' ['llvm-libclang']='archive' ['cuda']='redist' ) +declare -A MOD_URL=( ['cmake']='https://github.com/Kitware/CMake/releases/download/v3.30.5/cmake-3.30.5-windows-x86_64.zip' ['ninja']='https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-win.zip' ['llvm-libclang']='https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/clang+llvm-18.1.8-x86_64-pc-windows-msvc.tar.xz' ) +declare -A MOD_VERSION=( ['cmake']='3.30.5' ['ninja']='1.12.1' ['llvm-libclang']='18.1.8' ['cuda']='12.9.1' ) +declare -A MOD_SHA256=( ['cmake']='5ab6e1faf20256ee4f04886597e8b6c3b1bd1297b58a68a58511af013710004b' ['ninja']='f550fec705b6d6ff58f2db3c374c2277a37691678d6aba463adcbb129108467a' ['llvm-libclang']='22c5907db053026cc2a8ff96d21c0f642a90d24d66c23c6d28ee7b1d572b82e8' ) +declare -A MOD_EXTRACT=( ['cmake']='strip-top-dir' ['ninja']='flat' ['llvm-libclang']='members:*/bin/libclang.dll,*/lib/clang/*' ) declare -A MOD_REDIST_MANIFEST=( ['cuda']='https://developer.download.nvidia.com/compute/cuda/redist/redistrib_12.9.1.json' ) declare -A MOD_COMPONENTS=( ['cuda']='cuda_nvcc,cuda_cudart,libcublas,libcurand,cuda_nvrtc,cuda_cccl' ) declare -A MOD_FORMULA=() @@ -27,4 +27,4 @@ declare -A MOD_ARGS=() declare -A MOD_RUN=( ['gh-auth']='gh auth login --hostname github.com --git-protocol https --web' ['airc-firewall']='New-NetFirewallRule -DisplayName '\''airc daemon inbound (continuum grid)'\'' -Direction Inbound -Action Allow -Profile Any' ['manifest-gen']='cargo run -q -p manifest-gen' ) declare -A MOD_BUILD_FEATURES=( ['build-core']='cuda,load-dynamic-ort' ) declare -A MOD_BUILD_PROFILE=( ['build-core']='release' ) -declare -A MOD_RUNTIME_PATH=( ['cuda']='~/.continuum/cuda-*/Library/bin' ) +declare -A MOD_RUNTIME_PATH=( ['cmake']='~/.continuum/tools/cmake/bin' ['ninja']='~/.continuum/tools/ninja' ['cuda']='~/.continuum/cuda-*/Library/bin' ) diff --git a/tools/scripts/install-manifest.toml b/tools/scripts/install-manifest.toml index 8648882a31..1a39f8086d 100644 --- a/tools/scripts/install-manifest.toml +++ b/tools/scripts/install-manifest.toml @@ -159,6 +159,55 @@ formula = "cmake" [module.sources.linux] type = "apt" # run-verify pending on a linux node package = "cmake" +# WHERE the build shell finds it. This is the windows-vs-unix split, stated as data: +# brew/apt drop cmake straight onto PATH, so unix needs nothing here. The windows +# archive lands in a per-user dir that is on NOBODY's PATH, so without this the +# generic runtime-PATH loop skips it and llama's build.rs dies with "is `cmake` not +# installed?" while cmake sits installed a directory away. It lived as a hardcoded +# fallback inside windows-build-env.sh's CUDA block instead — which meant a +# CPU-only Windows box (no nvcc → block skipped) never got it at all. +[module.runtime_path] +windows = ["~/.continuum/tools/cmake/bin"] + +[[module]] +id = "ninja" +order = 45 +tier = 3 +flags = ["dev"] +# WINDOWS ONLY, deliberately. The defect ninja fixes is a Windows one (see below): +# on unix, cmake's default generator (Unix Makefiles) is never broken, so listing +# macos/linux here would make every contributor on those platforms install a package +# to buy nothing. Adapting to the platform means stating the asymmetry, not smearing +# one platform's workaround across all three. +platforms = ["windows"] +accept = "ninja --version" +# WHY ninja is a first-class module and not an implementation detail: +# +# cmake auto-picks the NEWEST installed Visual Studio generator. On a VS18-2026 box +# that is "Visual Studio 18 2026" — a generator string cmake 3.30.5 does not define +# ("Could not create named generator"), so the build dies on a machine where every +# tool is present and correct. Ninja is generator-version-agnostic, uses the MSVC env +# imported separately, drives nvcc directly (no VS integration, no admin), and matches +# how llama-server is built. So on Windows it is not optional — it is what makes the +# configure step deterministic across VS versions. [[windows-build-env-drift]] +# +# It was previously fetched by a HARDCODED url inside install-llama-server's PowerShell +# module — no pinned version in the manifest, no sha256, and invisible to the +# manifest-gen drift gate. One tool provisioned by a different set of rules than every +# other tool is exactly the drift the manifest exists to prevent, and an unverified +# download is a supply-chain hole regardless of how convenient the url is. +[module.sources.windows] +type = "archive" # official ninja release, no admin +url = "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-win.zip" +version = "1.12.1" +# sha256 computed 2026-08-09 from the official GitHub release asset (275425 bytes). +sha256 = "f550fec705b6d6ff58f2db3c374c2277a37691678d6aba463adcbb129108467a" +extract = "flat" # -> ~/.continuum/tools/ninja/ninja.exe (zip has no top dir) +# cmake resolves ninja through PATH only (it ignores CMAKE_MAKE_PROGRAM from the env), +# so an unlisted ninja means "CMAKE_GENERATOR is not set / unable to find Ninja" even +# with the binary sitting on disk. +[module.runtime_path] +windows = ["~/.continuum/tools/ninja"] [[module]] id = "llvm-libclang" diff --git a/tools/scripts/lib/win-modules.ps1 b/tools/scripts/lib/win-modules.ps1 index 1041fbd1e4..bfac3d1bb9 100644 --- a/tools/scripts/lib/win-modules.ps1 +++ b/tools/scripts/lib/win-modules.ps1 @@ -274,6 +274,46 @@ function Mod-CMake { else { Module-Fail 'CMake' "cmake.exe not found after extract to $dir" } } +function Set-NinjaEnv { + # Ninja must be reachable BY PATH: the cmake crate ignores CMAKE_MAKE_PROGRAM from + # the environment, so with -G Ninja it searches PATH and otherwise fails with + # "unable to find Ninja / CMAKE_MAKE_PROGRAM is not set". Mirrors Set-CMakeEnv. + param([Parameter(Mandatory)][string]$Dir) + if ($env:PATH -notlike "*$Dir*") { $env:PATH = "$Dir;$env:PATH" } +} + +function Mod-Ninja { + # The no-admin CUDA build driver. The "Visual Studio 17 2022" generator needs the + # CUDA VS MSBuild integration (CUDA*.props under VC/BuildCustomizations) to + # enable_language(CUDA) -- and our no-admin CUDA redist does not ship it (it is a + # full-installer component that writes into Program Files). Ninja drives nvcc + # directly, so it needs zero VS integration, and it is generator-version-agnostic + # (the "Visual Studio 18 2026" trap cmake 3.30.x cannot name). + # + # Was a hardcoded Invoke-WebRequest inline in Mod-LlamaServer: no manifest entry, + # no pinned sha256, invisible to the manifest-gen drift gate. One tool provisioned + # by different rules than every other tool is the drift the manifest exists to + # prevent, and an unverified download is a supply-chain hole however convenient + # the url. Now: same source-of-truth, same verification, same guard shape as + # Mod-CMake. + if (Get-Command ninja -ErrorAction SilentlyContinue) { Module-Skip 'Ninja' 'on PATH'; return } + $dir = Join-Path $env:USERPROFILE '.continuum\tools\ninja' + $exe = Join-Path $dir 'ninja.exe' + if (Test-Path $exe) { Set-NinjaEnv $dir; Module-Skip 'Ninja' "present at $dir"; return } + Module-Start 'Ninja' 'downloading ninja (no admin)' + $src = (Get-ManifestModule 'ninja').source # archive: url + version + sha256 + extract + $zip = Join-Path $env:TEMP "ninja-$($src.version).zip" + Invoke-WebRequest -Uri $src.url -OutFile $zip -UseBasicParsing + Assert-Sha256 -Path $zip -Expected $src.sha256 -Name 'Ninja' + New-Item -ItemType Directory -Force $dir | Out-Null + # extract = "flat": the ninja zip has no top-level directory, so it expands + # straight into place (contrast Mod-CMake's "strip-top-dir"). + Expand-Archive -Path $zip -DestinationPath $dir -Force + Remove-Item $zip -ErrorAction SilentlyContinue + if (Test-Path $exe) { Set-NinjaEnv $dir; Module-Done 'Ninja' } + else { Module-Fail 'Ninja' "ninja.exe not found after extract to $dir" } +} + function Mod-LLVM { # libclang.dll for bindgen. From LLVM's OFFICIAL release (clang+llvm # windows-msvc tarball), extracted per-user -- no admin, no Python. @@ -558,14 +598,7 @@ function Mod-LlamaServer { # (Enter-MsvcEnv puts cl.exe on PATH for nvcc's host side). $ninjaDir = Join-Path $env:USERPROFILE '.continuum\tools\ninja' $ninja = Join-Path $ninjaDir 'ninja.exe' - if ($backend -eq 'cuda' -and -not (Test-Path $ninja)) { - Write-Step ' llama-server: fetching ninja (no-admin CUDA build driver)' - New-Item -ItemType Directory -Force $ninjaDir | Out-Null - $nz = Join-Path $env:TEMP 'ninja-win.zip' - Invoke-WebRequest -Uri 'https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-win.zip' -OutFile $nz -UseBasicParsing - Expand-Archive -Path $nz -DestinationPath $ninjaDir -Force - Remove-Item $nz -ErrorAction SilentlyContinue - } + if ($backend -eq 'cuda') { Mod-Ninja } $cmakeArgs = @('-S', $submodule, '-B', $buildDir, '-DCMAKE_BUILD_TYPE=Release', diff --git a/tools/scripts/lib/windows-build-env.sh b/tools/scripts/lib/windows-build-env.sh index 5c3a927bf0..1ff94d109e 100644 --- a/tools/scripts/lib/windows-build-env.sh +++ b/tools/scripts/lib/windows-build-env.sh @@ -57,6 +57,53 @@ if [ -f "$_mf_runtime" ] && [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then fi fi +# ── Build drivers: cmake + ninja (EVERY platform, independent of CUDA) ────────────────────── +# These two used to live INSIDE the CUDA/MSVC block below, which gated them on +# `nvcc present AND cl.exe absent`. Neither has anything to do with CUDA: +# +# * a CPU-only Windows box (no nvcc) skipped the block entirely and got no cmake +# pin, so `cargo build` died with "is `cmake` not installed?" while cmake sat +# installed at ~/.continuum/tools/cmake — a manifest-provisioned tool the build +# could not see; +# * a shell where cl.exe was ALREADY resolvable skipped it for the same reason. +# +# The PATH half is now the manifest's job (`[module.runtime_path]` on cmake/ninja, +# consumed by the generic loop above) — that is where the windows-vs-unix split +# belongs, because it IS a packaging fact: brew/apt put these on PATH, the Windows +# archives do not. What is left here is only what the manifest cannot express: the +# two env vars the cmake crate reads. + +# cmake-rs (core/llama build.rs) resolves cmake from the CMAKE env var, else PATH. +# Point it at whatever the loop above (or the system package manager) resolved, so +# the crate never re-guesses. No fallback path is invented: if cmake is genuinely +# absent, the build fails loudly with the crate's own message rather than pointing +# at a file that isn't there. +if [ -z "$CMAKE" ] && command -v cmake >/dev/null 2>&1; then + _wbe_cmake="$(command -v cmake)" + if [ "$_mf_os" = windows ]; then + export CMAKE="$(cygpath -w "$_wbe_cmake" 2>/dev/null || echo "$_wbe_cmake")" + else + export CMAKE="$_wbe_cmake" + fi +fi + +# WINDOWS ONLY: force a generator cmake actually knows. The cmake crate auto-picks +# the newest installed Visual Studio; on a VS18-2026 box that is "Visual Studio 18 +# 2026", which cmake 3.30.x does not define ("Could not create named generator") — +# so the configure step fails on a machine where every tool is present and correct. +# Ninja is generator-version-agnostic and uses the MSVC env imported below. +# [[windows-build-env-drift]] +# +# Deliberately NOT applied on unix: cmake's default there (Unix Makefiles) is never +# broken, so pinning Ninja would change every Linux/macOS contributor's build to buy +# nothing. The asymmetry is the point — this is a Windows-specific defect, and the +# fix stays scoped to it. +if [ "$_mf_os" = windows ] && [ -z "$CMAKE_GENERATOR" ] && command -v ninja >/dev/null 2>&1; then + # cmake ignores CMAKE_MAKE_PROGRAM from the env and searches PATH for ninja, which + # the manifest runtime_path above already guarantees. + export CMAKE_GENERATOR="Ninja" +fi + # ── Windows: import the MSVC toolchain so cargo's CUDA (candle) build finds cl.exe ────────── # candle compiles CUDA kernels (affine.cu, ...) via nvcc, which needs cl.exe as its host # compiler plus INCLUDE/LIB. The cargo builds below run in THIS bash shell (unlike the @@ -92,27 +139,8 @@ if [ "$_mf_os" = windows ] && command -v nvcc >/dev/null 2>&1 && ! command -v cl if [ -n "$_vct" ]; then _clb="$(cygpath -u "${_vct}bin\\Hostx64\\x64" 2>/dev/null)"; [ -d "$_clb" ] && PATH="$_clb:$PATH"; fi if [ -n "$_sdkbin" ]; then _sdb="$(cygpath -u "${_sdkbin}x64" 2>/dev/null)"; [ -d "$_sdb" ] && PATH="$_sdb:$PATH"; fi export PATH - # Pin cmake explicitly: the cmake crate (core/llama build.rs) resolves cmake via the CMAKE env - # var or PATH, but a manifest-provisioned cmake lives at ~/.continuum/tools/cmake/bin and is not - # guaranteed on the build shell's PATH (measured: absent in a clean subshell -> "cmake not - # found"). Point CMAKE at the known install (same resolution as install-llama-server.sh) and put - # its dir on PATH for cmake's own sub-tools. - _ccmk="$(command -v cmake 2>/dev/null || echo "${CONTINUUM_HOME:-$HOME/.continuum}/tools/cmake/bin/cmake.exe")" - if [ -x "$_ccmk" ]; then - export CMAKE="$(cygpath -w "$_ccmk" 2>/dev/null || echo "$_ccmk")" - PATH="$(dirname "$_ccmk"):$PATH"; export PATH - fi - # Force a generator cmake actually knows. The cmake crate auto-picks the newest installed VS; on a - # VS18-2026 box that is "Visual Studio 18 2026" - a generator cmake 3.30.x does NOT define ("Could - # not create named generator"). Ninja is version-agnostic, uses the MSVC env imported above, and - # matches the llama-server build. [[windows-build-env-drift]] - _cninja="$(command -v ninja 2>/dev/null || echo "${CONTINUUM_HOME:-$HOME/.continuum}/tools/ninja/ninja.exe")" - if [ -x "$_cninja" ]; then - export CMAKE_GENERATOR="Ninja" - # ninja must be ON PATH: the cmake crate ignores CMAKE_MAKE_PROGRAM env, so with -G Ninja it - # searches PATH ("unable to find Ninja / CMAKE_MAKE_PROGRAM is not set" otherwise). - PATH="$(dirname "$_cninja"):$PATH"; export PATH - fi + # (cmake + ninja are pinned ABOVE, outside this block — they are not CUDA concerns. + # Gating them on nvcc is what left a CPU-only Windows box with no cmake at all.) # CUDA_PATH must be set: cudarc/candle/pocket-tts read it to emit their link-search; without it the # link has NO CUDA search path (measured: LNK1181 cuda.lib). Point it at a cuda-* whose import-lib # dir actually has the libs (a provisioning split can leave the crate-detected dir EMPTY - cuda-env