From bfa11e9e7e29f0a5b83c75fc11941c209ad050ad Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Sat, 8 Aug 2026 22:18:06 -0500 Subject: [PATCH 1/8] fix(ci): the clients suite existed and nothing ran it; the workflow that should have was pointed at a deleted directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects that compose into one: the TypeScript client tests were unguarded, the guard that should have covered them was dead, and on Windows the suite could not be run at all. 1. THE SUITE WAS RUN BY NO WORKFLOW. `npm run test:clients` was already in package.json and already passing. Nothing invoked it. So renderBench.spec.ts and six sibling spec files across apps/web and packages/ existed, were green on their authors' machines, and gated nothing — a correct check nothing calls, indistinguishable from having no check until someone greps for the caller. 2. ci.yml HAD BEEN RED FOR TWO MONTHS. Last success 2026-06-07; failing continuously from 2026-06-09 with `Cannot find module 'dotenv'`. Root cause was not dotenv: the job ran in `working-directory: src` against `src/package-lock.json`, calling `npm run build:ts` and `npm run test:crud`. `src/` was the Node monolith, retired when the substrate became a headless Rust core (#1840). Every path and script it named is gone; the error was describing the absence of its whole world. A check that ALWAYS fails is worse than no check — it cannot distinguish a broken PR from a healthy one, so the only lesson available is to stop reading CI. That is what happened: work routed around it through the Rust and drift-guard workflows for two months. Replaced rather than patched, and it now watches `canary` as well as `main`, because a gate that only sees the stable line learns about breakage after the merge. 3. THE SUITE COULD NOT RUN ON WINDOWS AT ALL. `@continuum/web` and `@continuum/chat-view` declared `"test": "TZ=UTC vitest run"`. npm runs scripts through cmd.exe on Windows, which has no POSIX env prefix, so both died with `'TZ' is not recognized as an internal or external command`. The other three workspaces use a plain `vitest run` and were fine — the split is exactly the prefix. Fixed by REMOVING the portability assumption rather than packaging a tool to satisfy it: apps/web already pinned `test: { env: { TZ: 'UTC' } }` in its vite config, making the prefix redundant; chat-view had no config at all, so it gets one whose only job is that pin. No cross-env dependency added. Worth stating: this one is only findable by RUNNING the suite on Windows. Linux CI and macOS both pass. Adding the CI gate alone would have gone green and left every Windows contributor unable to run the tests locally — the gate would have hidden this rather than caught it. Verified on Windows after the fix: 25 test files, 153 tests, exit 0. Before it, the two affected workspaces produced no test run whatsoever. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --- .github/workflows/ci.yml | 85 ++++++++++++++++++----------- apps/web/package.json | 4 +- packages/chat-view/package.json | 4 +- packages/chat-view/vitest.config.ts | 34 ++++++++++++ 4 files changed, 91 insertions(+), 36 deletions(-) create mode 100644 packages/chat-view/vitest.config.ts 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/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/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' }, + }, +}); From eb2149d892e8d84fdd6a95b0b8ac83398b51deae Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Sat, 8 Aug 2026 22:30:33 -0500 Subject: [PATCH 2/8] =?UTF-8?q?feat(deps):=20the=20workspace=20repairs=20i?= =?UTF-8?q?tself=20when=20the=20manifest=20moves=20=E2=80=94=20on=20client?= =?UTF-8?q?=20scripts=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `install.sh` runs `npm install` ONCE. Nothing re-ran it when the manifest changed. 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 failing at COLLECTION with: Failed to load url @continuum/chat-view. Does the file exist? which points a newcomer at missing SOURCE rather than at their missing deps. Measured on a real checkout: tree from Jun 17, manifest from Aug 5, `lit` and every @continuum/* workspace package absent, seven spec files dead. WHERE IT HANGS, AND WHY NOT THE START PATH. `start-server.sh` is headless Rust by doctrine — "No Node, no TS, no widgets. The Node orchestrator stays out of the loop." A dependency guard wired there would drag npm into the one runtime path that exists to avoid it. So this hangs off `pre*` hooks on the CLIENT scripts only: dev:web, dev:desktop, build/lint/typecheck/test:clients. Anyone who only ever runs the core pays nothing and never sees it execute. mtime, not a checksum: npm writes node_modules/.package-lock.json when it materialises the tree, so "tree older than manifest" is exactly the question worth asking and needs no parsing. A checksum would be more precise about CONTENT and no more precise about what actually breaks people. LOUD, never silent. A guard that repairs 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. Skipped under CI, where `npm ci` is authoritative and already ran; CONTINUUM_SKIP_DEP_CHECK opts out for anyone hand-managing a tree. Verified both directions: silent on a fresh tree, fires with the reason named on a stale one, no-ops under CI=1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --- package.json | 13 ++++-- tools/scripts/ensure-node-deps.sh | 76 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tools/scripts/ensure-node-deps.sh 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/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" From 7656b21088f938aa972615c104167fee18e916ff Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Sat, 8 Aug 2026 23:28:32 -0500 Subject: [PATCH 3/8] =?UTF-8?q?fix(cli):=20one=20front=20door=20=E2=80=94?= =?UTF-8?q?=20`uu`=20is=20the=20command,=20and=20the=20binary=20knows=20it?= =?UTF-8?q?s=20own=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four names were in play and two were installed: uu ON PATH core/continuum-core/src/bin/continuum.rs continuum ON PATH same build, same size, same mtime — one binary, two names ctm absent declared as [[bin]] in apps/cli/Cargo.toml, never installed jtag absent used 40 times in CLAUDE.md, installed by nothing (Node era) So the declared name, the documented name, and the installed names were three different answers and nothing reconciled them. Joel reached for `uu` and was right; I nearly "corrected" him from the stale Cargo.toml, which would have been ratifying a declaration over the live system. WHAT CHANGED `program_name()` derives from argv[0]. The usage text was the literal string "usage: continuum ...", so `uu --help` printed examples for a command the reader had not typed — the front door did not know its own name. One binary can now ship under any number of names and each tells the truth; a future alias is correct the moment it exists, with nothing to remember to update. `version` is handled LOCALLY and never dispatched. `uu version` used to fall through to the substrate and return `Unknown command: 'version'` — the CLI could ask the core what IT was and could not say what ITSELF was, and with no core running it answered nothing at all. That is the gap that lets someone debug a fixed bug with an unfixed binary in their hand, which is the whole point of the version ruling. CLAUDE.md: 38 `./jtag` invocations rewritten to `uu`. Deliberately NOT rewritten: `.continuum/jtag/logs/...` paths (3, real directories), the [[jtag-probes-are-rtos-debugger]] memory slug, and the legacy JTAGClient snippets — none of those are commands, and a blind replace would have broken log paths. The mistake entry itself was inverted and is now the correction: it told every new session "Always work from `src`" and "Commands: `./jtag` NOT `./continuum`". `src/` is the retired Node monolith, `jtag` is installed by nothing, and `continuum` — the one it warned against — works. That is a documentation lie with a live cost: it is the first thing a fresh agent reads and the first ten minutes it wastes. Env vars corrected mid-edit: I wrote CONTINUUM_BUILD_SHA/_BRANCH from memory; build.rs emits CONTINUUM_BUILD_GIT_SHA and no branch at all. `option_env!` would have silently printed "unknown" rather than failing, so that would have shipped as a quiet lie about the build. cargo check -p continuum-core --bin continuum: 0 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --- CLAUDE.md | 97 ++++++++++++++---------- core/continuum-core/src/bin/continuum.rs | 72 ++++++++++++++++++ 2 files changed, 127 insertions(+), 42 deletions(-) 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/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 \ From de148b17d49e569795d2665466e4f2247194df01 Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Sun, 9 Aug 2026 12:36:52 -0500 Subject: [PATCH 4/8] =?UTF-8?q?feat(serving):=20the=20daemon=20asks=20the?= =?UTF-8?q?=20engine=20what=20it=20IS=20=E2=80=94=20/props.build=5Finfo=20?= =?UTF-8?q?on=20the=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 it took comparing binary mtimes on two machines. I told M5 the engine had no version surface. That was wrong: llama_build_info() has existed all along — `--version`, the startup log, `/props.build_info`, and `system_fingerprint` on every completion. The gap was entirely on OUR side. The daemon already GETs `/props` for the served window and the modalities verdict, and threw `build_info` away. So this is silently-unwired-capability with the polarity reversed: not something we built and failed to wire, but something upstream hands us free that we drop on the floor. Same cost — a fact available for the asking gets re-derived by archaeology, and the derivation was wrong. - LlamaServerControl::engine_build() reads /props.build_info, default Ok(None) so fakes and remote controls stay honest by construction - rides on ServingSnapshot, stamped at reconcile, probed on the reconcile line - surfaces as engineBuild on ai/inference/status The commit sha is the load-bearing half: build numbers are ancestor counts, so our fork and upstream can both say b6789 and mean different code. Three assertions pin it: the identity must reach the snapshot; an engine that cannot say what it is reads as unknown rather than a guess; and a not-live snapshot never names an engine even when one answered the probe. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --- .../src/inference/llama_server.rs | 90 +++++++++++++++++++ .../src/inference/model_commands.rs | 21 ++++- .../src/modules/serving_consumer.rs | 6 ++ .../src/modules/serving_daemon.rs | 75 ++++++++++++++++ 4 files changed, 190 insertions(+), 2 deletions(-) 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, From 779810a72489e533aa7328af10c65d757254e23a Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Sun, 9 Aug 2026 12:38:25 -0500 Subject: [PATCH 5/8] fix(install): ninja becomes a manifest module; cmake/ninja stop being CUDA's business MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, both found by a plain `cargo check` dying with "is `cmake` not installed?" on a box where cmake was installed a directory away. 1. The cmake + ninja pins lived INSIDE windows-build-env.sh's CUDA block, gated on `nvcc present AND cl.exe absent`. Neither is a CUDA concern. A CPU-only Windows box (no nvcc) skipped the block and got no cmake pin at all; a shell where cl.exe already resolved skipped it for the same reason. Hoisted into two self-guarded blocks that run on their own applicability. The PATH half is now the manifest's job — [module.runtime_path] on cmake and ninja, consumed by the generic loop that was already there. 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. Linux/macOS projections carry nothing, as they should. 2. ninja was fetched by a HARDCODED url inside the llama-server PowerShell module — no manifest entry, no pinned sha256, invisible to the manifest-gen drift gate. One tool provisioned by different rules than every other tool is exactly the drift the manifest exists to prevent, and an unverified download is a supply-chain hole however convenient the url is. Now a real Mod-Ninja with the same guard shape, sha256 verification and source-of-truth as Mod-CMake, and it runs in install.ps1 beside Mod-CMake instead of only on boxes that had already built llama-server with CUDA. ninja is declared platforms = ["windows"] deliberately. The defect it fixes is Windows-only (cmake auto-picks the newest VS; "Visual Studio 18 2026" is a generator cmake 3.30.x cannot name). On unix the default generator is never broken, so listing macos/linux would make those contributors install a package to buy nothing. Adapting to the platform means stating the asymmetry, not smearing one platform's workaround across all three. Verified: manifest-gen --check OK (4 files in sync), ninja absent from the linux and macos projections, CMAKE and CMAKE_GENERATOR=Ninja both resolve from a fresh shell with no CUDA involved, Mod-Ninja/Mod-CMake idempotent-skip, PowerShell parse + bash -n clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --- install.ps1 | 7 ++ tools/scripts/generated/manifest.windows.ps1 | 3 +- tools/scripts/generated/manifest.windows.sh | 22 +++--- tools/scripts/install-manifest.toml | 49 ++++++++++++++ tools/scripts/lib/win-modules.ps1 | 49 +++++++++++--- tools/scripts/lib/windows-build-env.sh | 70 ++++++++++++++------ 6 files changed, 159 insertions(+), 41 deletions(-) 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/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 From 2f3e10e88c2cd4c7771506aebd10712fd4e54a6b Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Sun, 9 Aug 2026 13:18:00 -0500 Subject: [PATCH 6/8] fix(memory-bridge): resolve the airc binary; stop reporting "daemon down" for "program not found" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engram recall has been silently off on BigMama. Every session start printed "MEMORY BRIDGE DOWN — could not resolve this agent's persona id (airc status gave nothing)" while airc had 21h uptime and 1305/1305 acked. The probe never observed the daemon. Hooks do not inherit the operator's interactive shell — airc installs to ~/.local/bin, routinely absent from a hook's PATH — so bare `airc` was not a program it could find, and 2>/dev/null swallowed the "command not found" that would have said so. The same file already had resolve_continuum() doing this correctly. Two answers to "find a binary" in one file, one of them robust, is the drift; now there is one shape (resolve_airc, honoring $AIRC_BIN, with .exe variants for Windows). share.sh's two bare `airc` calls go through it too. And the receipts stop guessing. "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" about a healthy daemon, twice, and sent an afternoon of diagnosis at the wrong layer. persona_failure_reason() re-derives the measured cause for both the receipt and the notice the AGENT reads. It re-derives rather than setting a variable inside resolve_agent_persona because callers invoke that as $(...) — a subshell, where any assignment dies. The first version of this fix did exactly that and the receipt came out blank. The negative test caught it, which is why it exists. Verified both directions: with ~/.local/bin stripped from PATH (the environment that failed for two sessions) the id now resolves; with no airc reachable at all the failure names the real cause instead of blaming the daemon. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --- tools/plugins/memory-bridge/scripts/lib.sh | 76 ++++++++++++++++++- .../memory-bridge/scripts/session-capture.sh | 2 +- .../memory-bridge/scripts/session-recall.sh | 10 ++- tools/plugins/memory-bridge/scripts/share.sh | 9 ++- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/tools/plugins/memory-bridge/scripts/lib.sh b/tools/plugins/memory-bridge/scripts/lib.sh index 688634630f..4a7e1d33d1 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,37 @@ 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)' +} + # 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..2f5730b306 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:-}" ] || { 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" } From e9c93059424ba1a1d50461da519706d6284739f9 Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Sun, 9 Aug 2026 14:13:18 -0500 Subject: [PATCH 7/8] =?UTF-8?q?feat(memory-bridge):=20detect=20a=20FROZEN?= =?UTF-8?q?=20install=20=E2=80=94=20"current"=20must=20not=20be=20indistin?= =?UTF-8?q?guishable=20from=20"stale"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge's founding rule is that "installed" must never be indistinguishable from "working". This is that rule one level up, and it is why yesterday's fixes would have changed nothing on the machine that needed them. There are two install paths with very different freshness semantics: * `claude --plugin-dir tools/plugins/memory-bridge` runs LIVE from the repo — `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. `git pull` changes nothing. Nothing told you which one you were on. Measured on BigMama: the running copy was pinned to 60fa0dbf (2026-07-25), two weeks stale. 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. The README said the bridge was live the whole time. So session-recall now compares its own running location against tools/plugins/memory-bridge/scripts in the current checkout, and on drift emits ⚠️ MEMORY BRIDGE STALE plus a `stale` receipt. A missing file counts as drift — that is exactly how session-capture.sh went absent unnoticed. It runs on the SUCCESS path too, because staleness is orthogonal to whether recall worked: a frozen copy can recall perfectly and still be missing every fix since it was installed. Only a cached copy can be stale, so running from the repo stays silent; and if the cwd is not the continuum checkout there is nothing to compare against, so it says nothing rather than guessing. README: the "Status: Live" line was a claim a README cannot make — it describes the repo, while liveness depends on your install. Replaced with the two paths and how to tell which you are on. Verified against the real frozen copy on this box (4 of 4 scripts differ → notice fires) and against a live repo run (silent). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --- tools/plugins/memory-bridge/README.md | 38 +++++++++++++-- tools/plugins/memory-bridge/scripts/lib.sh | 46 +++++++++++++++++++ .../memory-bridge/scripts/session-recall.sh | 9 ++++ 3 files changed, 89 insertions(+), 4 deletions(-) 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 4a7e1d33d1..6e3c343cbe 100644 --- a/tools/plugins/memory-bridge/scripts/lib.sh +++ b/tools/plugins/memory-bridge/scripts/lib.sh @@ -133,6 +133,52 @@ persona_failure_reason() { 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-recall.sh b/tools/plugins/memory-bridge/scripts/session-recall.sh index 2f5730b306..ef8baf0d9e 100755 --- a/tools/plugins/memory-bridge/scripts/session-recall.sh +++ b/tools/plugins/memory-bridge/scripts/session-recall.sh @@ -88,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)" From be8a31120028ac124d360bb99f0a269af1374c20 Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Sun, 9 Aug 2026 15:04:06 -0500 Subject: [PATCH 8/8] =?UTF-8?q?fix(plugins):=20bump=20memory-bridge=20to?= =?UTF-8?q?=200.2.0=20+=20gate=20the=20bump=20=E2=80=94=20the=20actual=20r?= =?UTF-8?q?eason=20fixes=20never=20shipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin updates are VERSION-based, not content-based. `claude plugin update` compares the DECLARED version against the installed one and never looks at the files. memory-bridge had sat at 0.1.0 since 2026-07-25, so every installed copy answered: ✔ memory-bridge is already at the latest version (0.1.0). ...no matter what changed. That single static string is why two weeks of work never reached the machine that needed it: the copy running on BigMama had no persona-id cache and no session-capture.sh AT ALL, so automatic per-turn memory capture — the whole "volitional memory isn't memory" point — had never run once, 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, verified here: marketplace update -> `Plugin "memory-bridge" updated from 0.1.0 to 0.2.0`, the new copy carries session-capture.sh + resolve_airc + the staleness detector, and now differs from this tree in 0 of 4 scripts. So the guard. check-plugin-version.sh fails when files under a plugin dir change without that plugin's plugin.json version changing, and it compares the version BEFORE and AFTER rather than trusting that the manifest was touched — editing a description is not a release. A brand-new plugin has nothing to bump from and passes. Verified both directions: passes on this commit's real bump, fails on a simulated missed one. Wired into CI, deliberately NOT into .githooks/pre-commit: that hook invokes tests/adversarial-protocol.test.cjs and tests/command-processing.test.cjs, both deleted with the Node monolith, and core.hooksPath does not point at it — so no pre-commit hook runs at all right now. Adding a gate there would have looked enforced while running never, which is the same defect one level up. Flagged rather than silently repaired; that hook needs its own decision. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc --- .github/workflows/plugin-version-guard.yml | 58 ++++++++++ .../memory-bridge/.claude-plugin/plugin.json | 2 +- tools/scripts/check-plugin-version.sh | 109 ++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/plugin-version-guard.yml create mode 100644 tools/scripts/check-plugin-version.sh 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/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/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