Skip to content

feat(apps/cli): ctm generate — first inference command on the CLI (task #143 slice 2) - #1561

Merged
joelteply merged 473 commits into
mainfrom
feat/ctm-ai-generate
Jun 9, 2026
Merged

feat(apps/cli): ctm generate — first inference command on the CLI (task #143 slice 2)#1561
joelteply merged 473 commits into
mainfrom
feat/ctm-ai-generate

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

What this PR adds

Second ctm subcommand: ctm generate --prompt "..." dispatches ai/generate at the substrate over airc and prints the response.

export CONTINUUM_PEER_ID=<substrate-peer-uuid>
ctm generate --prompt "explain HandleRef"
ctm generate --prompt "..." --model "qwen3.5-4b-code-forged"
ctm generate --prompt "..." --json                           # raw JSON

Same Connection / CommandClient seam ctm metrics (PR #1559) uses. The substrate's ai/generate handler dispatches against its AdapterRegistry; if an AircRemoteInferenceAdapter is registered (per PR #1560), the inference transparently runs on a remote peer (e.g., the 5090) — the CLI doesn't know or care.

This proves the Tron-grid frame literally: human types a command on the Intel Mac, it round-trips to a substrate that may itself transparently route the inference further across the grid. Composition over construction.

What this PR also addresses

Architecture

  ctm generate --prompt "..."
       │
       ▼
  Connection::connect(airc, substrate_peer_id)
       │
       ▼
  CommandClient<AircIpcTransport>.execute("ai/generate", params)
       │
       ▼
  airc-lib request/await_reply (LAN socket → substrate peer)
       │
       ▼
  continuum-core-server / CommandRequestHandler
       │
       ▼
  ai/generate module → AIProviderAdapter::generate_text → reply
       │
       ▼ (back up the stack)
  serde_json::Value → ctm prints to stdout

Test plan

Followups (task #216)

  • execute() timeout wrapping
  • Generic-over-Transport (testable without an Airc handle)

🤖 Generated with Claude Code

joelteply and others added 30 commits May 14, 2026 17:35
…ation can't drop entry mid-flight (#1244)

* perf(concurrency,#1235): refcount-per-key cleanup so analyzer cancellation can't drop entry mid-flight

Pre-#1235 only the analyzer (first caller for a key) held the Drop
guard for the in_flight entry. That correctly fixed the panic-cleanup
case (#1232) but left a window during analyzer cancellation:

  T0: analyzer.single_flight("k") creates entry, holds guard
  T1: awaiter1.single_flight("k") clones the Shared, no guard
  T2: analyzer task is cancelled
  T3: analyzer's guard.drop fires, removes entry from in_flight
  T4: NEW caller.single_flight("k") finds no entry, starts FRESH
      work — duplicate inference for the same key, contract violated.
      awaiter1 still completes the original Shared.

Codex flagged this on #1233.

This change makes EVERY caller (analyzer + awaiters) hold a
RefCountGuard. The HashMap value becomes KeyEntry { shared, refcount:
Arc<AtomicUsize> }. Each caller bumps the refcount under the in_flight
lock when constructing its guard; each guard drops decrement it. The
entry is removed only when refcount hits zero — and only after a
double-check under the lock to handle the race where a brand-new caller
bumps the refcount between fetch_sub and lock acquisition.

Behavior preserved:
- Single producer for many waiters: same as before.
- Panic cleanup (#1232): work-future panic re-raises through every
  Shared clone; all guards drop during unwind, refcount → 0, entry
  removed.

Compiles clean. Tests follow in the next commit.

* test(concurrency,#1235): two cancellation-race tests for refcount cleanup

Two new tests proving the #1235 fix:

1. analyzer_cancellation_does_not_evict_entry_while_awaiters_hold_it
   - Analyzer + awaiter both register for the same key.
   - Analyzer task is cancelled (abort).
   - Awaiter is still holding the Shared.
   - A NEW caller arrives for the same key.
   - Asserts: in_flight_count stays 1 across the analyzer drop;
     work-future producer body runs EXACTLY ONCE across all three
     callers; new caller's result equals the analyzer's original
     result (joined the same Shared, didn't start fresh).

   Pre-#1235 this would have failed: analyzer's guard drop would have
   removed the in_flight entry, and the new caller would have started
   duplicate work (producers count == 2, not 1).

2. all_callers_cancelled_evicts_entry_for_fresh_start
   - Two callers register, both cancelled before completion.
   - Asserts: refcount → 0, entry evicted.
   - Fresh caller for the same key starts a fresh work future
     (the prior abandoned work is gone).

Both tests run on tokio multi-threaded runtime (default) so abort
+ Shared interactions reflect production behavior.

Full concurrency test suite: 6 passed (4 existing + 2 new), 0 failed.

---------

Co-authored-by: Test <test@test.com>
…→PressureSource adapter (#1245)

Closes the action-surface gap on Joel's "memory must be FULLY managed"
directive (2026-05-14). After PR-3 the broker can ACT on Docker
pressure; PR-4 makes it TELL operators what it did, AND brings the
DockerTierPool into the broker via a clean adapter rather than forcing
a duplicate trait implementation.

## Two pieces

### 1. ResourcePoolAdapter (paging/adapter.rs, 248 LOC, 9 tests)

Bridges Arc<dyn ResourcePool> → impl PressureSource. Required because
ResourcePool (sibling's #1228 — used by DockerTierPool, future HF
cache, future system-RAM tier) and PressureSource (Phase 7 broker
trait) are parallel traits covering the same conceptual ground.
DockerTierPool only implements ResourcePool, so it couldn't register
with the broker at all.

Derivation rules (all tested):
- pressure() = usage_bytes / capacity_bytes; 0 when capacity==0 (tier
  not under management — broker neither alerts nor acts on it)
- evict_some() forwards to evict_at_least(want); want = max(overshoot,
  10% of capacity) so a pool at exactly 100% gets a non-zero request
- stats_snapshot() derives PoolStats; hit/miss/eviction/inflight
  default to 0 since ResourcePool doesn't expose them (broker uses
  pressure + name for decisions; rest is diagnostics)

Filed follow-up issue tracking the trait-unification cleanup per Joel
"code concurrency ONCE then incorporate it" — adapter is the safe NOW
move; collapsing the two traits is the right LATER move.

### 2. PressureAlert + sink wiring (paging/broker.rs)

- New typed PressureAlert (ts-rs export, camelCase wire format) with
  tier_name, pressure, tier label, bytes_freed, action_taken, at_ms
- AlertSink type alias (Arc<dyn Fn(PressureAlert) + Send + Sync>)
- PressureBroker::add_alert_sink() — register as many sinks as needed
- emit_alert() — WARN log + every registered sink, called from
  relieve() once per pool the broker tried to relieve
- PressureTier::label() — canonical lowercase strings for IPC
- Closes the TODO at line 311 of broker.rs ("Future: emit IPC event or
  log when triggered=true")

## Why "even with 0 bytes freed" matters

Alert fires with action_taken=true even when evict_some returns 0
(fully-pinned pool, docker daemon down, etc). Zero-byte alert IS the
signal "this tier is hot AND stuck" — operator needs that distinct
from no alert. ReliefReport.triggered stays false in that case
(matches existing semantics: triggered tracks bytes-freed action),
but the alert surfaces.

## Tests (24 total, all green)

paging::adapter: 9 tests covering pressure derivation, capacity==0
short-circuits, evict_some 10% floor, overshoot semantics,
forwarding, dyn-dispatch via PressureSource trait object.

paging::broker: 6 new tests on top of existing 9 — alert per acted
pool, alerts per over-budget pool in critical, no alerts below
threshold, zero-byte alert when pool can't evict, PressureTier label
canonical strings, PressureAlert serde camelCase round-trip. Existing
broker tests pass unchanged (alert emission is additive — does not
alter triggered/bytes_freed semantics).

Clippy at baseline 162 (no drift). No unsafe, no async, no lock
nesting. parking_lot::RwLock for the new alert_sinks slot — same
discipline as everything else in the broker.

## #1222 status

- ✅ PR-1 (#1229): docker_tier discovery probe + paths::docker
- ✅ PR-2 (#1231): DockerTierPool impl ResourcePool (eviction stub)
- 📥 PR-3 (#1243): real evict_at_least via docker system prune
- 📥 PR-4 (this): pressure-broker alert surface + adapter

Operators can now subscribe to PressureAlerts via add_alert_sink to
forward into chat substrate / IPC / Grafana; until then alerts go to
the WARN log via runtime::logger("pressure-broker"). TS render layer
gets the typed wire format from shared/generated/paging/PressureAlert.ts.

Refs #1222.

Co-authored-by: Test <test@test.com>
…#1243)

* refactor(persona): split evaluator.rs (1231 LOC) into focused submodules (#1208)

`persona/evaluator.rs` was a single 1231-LOC file mixing four
independent concerns: persona sleep state, per-room rate limiting,
post-inference adequacy check, and the `full_evaluate` gate
orchestrator. Split into:

  evaluator/mod.rs         (886 LOC) — gate orchestrator, FullEvaluate
                                       request/result types, 18 gate
                                       integration tests
  evaluator/sleep_state.rs (99 LOC)  — SleepMode + SleepState +
                                       2 unit tests
  evaluator/rate_limiter.rs (132 LOC) — RateLimiterState + RoomRateState
                                        + 2 unit tests (track_response,
                                        rate_limit_expired)
  evaluator/adequacy.rs    (207 LOC) — RecentResponse + AdequacyResult +
                                       check_response_adequacy + 7 tests

`persona/mod.rs` re-exports unchanged: `pub use evaluator::{...}` still
exposes SleepMode, SleepState, RateLimiterState, AdequacyResult,
RecentResponse, GateDetails, FullEvaluateRequest, FullEvaluateResult.
External callers see no API change.

Why these specific cuts:
- SleepState is reused independently anywhere a persona's voluntary
  attention state matters (not just Gate 1).
- RateLimiterState is a per-room cadence tracker that's a SIGNAL to the
  LLM, not a hard gate — independent of full_evaluate.
- Adequacy check is a separate phase (post-inference, not pre-response)
  that happens to share the file because it was written together.

Tests:
- `cargo test --features metal,accelerate persona::evaluator` →
  32 passed (every test from the original file, redistributed by domain).
- Full `persona::*` test suite → 451 passed, 0 failed, 3 ignored
  (no other module's imports broke).

Each new file < 250 LOC, mod.rs < 1000 LOC. Closes #1208 for the
`evaluator.rs` slice; `admission.rs` (1225) and `model_resolver.rs`
(1232) remain as separate cards.

* feat(modules,#1222): real evict_at_least via docker system prune (PR-3, stacked on #1231)

Stacks on PR-2 (#1231). Replaces the PR-2 stub (return 0) with real
two-stage eviction.

## Strategy

Two-stage escalation that frees only as much as needed:

1. **Soft (always tried first)**:
     docker system prune --force --filter "until=24h"

   Drops dangling images, stopped containers, unused networks older
   than 24h. Safe — does NOT touch in-use images, named volumes, or
   recent dev iteration artifacts. This is what a developer would
   manually run on a 'docker eats my disk' day.

2. **Aggressive (only if soft didn't free enough)**:
     docker system prune --force

   Same prune without the time filter. Frees ALL dangling artifacts
   regardless of age. Still does NOT touch in-use images or named
   volumes (Docker prune semantics).

Returns the actual bytes freed (sum across both stages), parsed from
Docker's stable 'Total reclaimed space: X.YYUNIT' summary line.
Returns 0 when docker isn't installed / daemon down / command fails —
broker treats as 'tier can't act, surface pressure to operator' (same
shape as DockerTierProbe::Unsupported).

## Parser

Standalone parse_reclaimed_bytes(output: &str) -> Option<u64>:

  - Handles all Docker units (B, kB, MB, GB, TB) with SI multipliers
    (1kB = 1000B per docker/cli convention)
  - Picks LAST 'Total reclaimed space:' line (Docker prints per-section
    totals during interactive runs; final line is the canonical total)
  - Returns None on missing line / unknown unit / unparseable number —
    distinct from Some(0) which means 'pruned successfully but nothing
    to free'

## Tests

8 tests pass (5 from PR-2 + 3 new):

  - parse_reclaimed_bytes_handles_all_units (B/kB/MB/GB/TB)
  - parse_reclaimed_bytes_returns_none_when_line_missing (5 malformed
    inputs — None vs Some(0) distinction)
  - parse_reclaimed_bytes_picks_last_summary_line (canonical-total
    semantics)

evict_at_least_never_panics replaces the PR-2 stub-asserting test.
Doesn't assert positive freed-bytes count because that requires a
live Docker daemon with prunable artifacts (flaky in CI). The unit
behavior is covered by the parser tests; live integration validation
happens during PR-4 chat-substrate alert work.

Clippy stays at baseline 162.

## Stacking

Base = feat/docker-tier-pool-impl-1222 (NOT canary). Once PR-2
(#1231) merges, GitHub auto-rebases this PR's base to canary and
the diff resolves to only PR-3 changes.

## Now-shipped under #1222

  - PR-1 (#1229): docker_tier discovery probe + paths::docker
  - PR-2 (#1231): DockerTierPool impl ResourcePool (eviction stub)
  - PR-3 (this): real evict_at_least via docker system prune
  - PR-4 (still open): chat-substrate alerts on >90% capacity

Joel directive 2026-05-14: 'memory in this system, including the
docker allotment needs to be managed by the system, FULLY.' With
PR-3, the system can actually ACT on Docker pressure (not just
report it). Closes the action gap.

Refs #1222.

---------

Co-authored-by: Test <test@test.com>
…e-href guard (#1250)

Closes #1159 (PR-3 of #1100). Closes the URL-metadata-XSS surface that
PR-1 explicitly deferred (its doc comment named this slice).

## Vulnerability

URLCardAdapter.renderContent built the card HTML by string-interpolating
9 attacker-controlled fields without escaping:

- originalText (raw chat text — adversary types it)
- url (raw URL string — `"><script>...` works, `javascript:` works)
- title, description, siteName (async metadata fetch — server-attacker)
- favicon (constructed from domain — domain itself is parsed-safe but
  the slot was unescaped)
- domain (3 sites in template)

Two attack classes were live:
1. HTML injection — any of the 9 fields could break out of its quoted
   attribute or text context and inject `<script>`, `<img onerror>`, etc.
2. Scheme injection — the fallback-link `<a href="${url}">` accepted
   `javascript:`, `data:`, `vbscript:` URLs. A click executed in the
   page origin.

## Fix

1. **escapeHtml(s)** — same canonical 5-char escape used in
   TextMessageAdapter.escapeHtml. Safe in both text and double-quoted
   attribute contexts (escapes both `"` and `'`).

2. **safeHref(url)** — whitelist neutralizer. Returns `#` for any
   scheme outside the audit-once safe set (http, https, mailto, tel,
   ftp, sftp). Also passes protocol-relative `//` and same-document
   `#fragment` URLs as-is. Whitelist not blacklist because blacklists
   miss `\tjavascript:`, case mixing, `&NewLine;javascript:` HTML-entity
   smuggling, and any future code-executing scheme.

3. **Apply at every interpolation** in renderContent:
   - additionalText, url (×4 attribute sites + 1 anchor text),
     favicon, domain (×3), siteName, title, description → escapeHtml
   - href slot → safeHref then escapeHtml

## Tests

16 tests in tests/unit/url-card-adapter-xss.spec.ts, all pass.
Organized into four describe blocks (per-field escape, attribute-context
escape, href scheme neutralization, href whitelist preservation) so each
stays under the 80-line/function limit.

Each test asserts BOTH "raw injection string MUST NOT appear" and
"escaped form MUST appear" so a future bug regressing escape is
caught from both directions.

## Test-file naming + tsconfig discipline

- File is `.spec.ts` not `.test.ts` so it bypasses the
  `tsconfig.eslint.json` exclude `**/*.test.ts` (which would otherwise
  cause a parse-error and bump the ESLint baseline).
- Added the file to `tsconfig.eslint.json` include so it's parsed
  type-aware and lint-clean. Vitest discovers `.spec.ts` natively.
- This avoids the "no baseline bumps or parse-error debt for new test
  code" rule called out in the airc-8a5e direction broadcast 2026-05-14.

ESLint at 5461 baseline (no drift). TypeScript build clean.

## Path

PR-1 (#1154) — closed innerHTML Lit-reactivity hole, deferred metadata
XSS. PR-2 unrelated. PR-3 (this) — closes the deferred metadata XSS.
URLCardAdapter is now safe against the full audit set called out in
the original Joel review nit on #1154.

No behavior change for safe input. Single-encode contract preserved.

Refs #1100.

Co-authored-by: Test <test@test.com>
…1208) (#1251)

`persona/admission.rs` was 1225 LOC mixing the structural admission
gate, the IsMemorable trait, the v1 HeuristicIsMemorable recipe + its
policy tests, helpers, and the gate test suite. Split:

  admission/mod.rs     (985 LOC) — AdmissionGate::admit machinery,
                                    Candidate/Context/Config types,
                                    IsMemorable trait, envelope
                                    verification, seam recording, and
                                    the structural-gate test suite
                                    (replay, trust threshold, recipe
                                    error path, quarantine propagation,
                                    seam-emission invariants)
  admission/recipes.rs (326 LOC) — HeuristicIsMemorable struct + impl
                                    + 4 heuristic-policy tests
                                    (short_content, noise_phrase,
                                    duplicate, admit_synthesizes_engram)

`HeuristicIsMemorable` re-exported at the parent path via
`pub use recipes::HeuristicIsMemorable` — external callers see no API
change. Engram types previously imported privately from `super::engram`
are now re-exported `pub use` so submodules can reach them via `super::`.

Tests:
- `cargo check --features metal,accelerate -p continuum-core` clean.
- `cargo test --features metal,accelerate -p continuum-core --lib persona::admission`
  → 37 passed, 0 failed.

Closes #1208 — final slice. evaluator.rs done in #1242,
model_resolver.rs done in #1249, admission.rs done now.

Worktree-discipline note: this PR is the first work this session
authored from a proper `airc lane create` worktree rather than the
shared root checkout, after Joel called out that branch swaps in the
shared root were stomping uncommitted work.

Co-authored-by: Test <test@test.com>
…) override (#1254)

Joel reported: chat widget's "Send your first message / Try @Helper..."
empty-state placeholder doesn't clear when the room actually has
messages. Visible after sending "my first message" into a room that
already had two prior messages — the empty-state panel still shows
below them.

## Root cause

`EmptyStateWidget` (LitElement, custom element `<empty-state>`) defines:

    :host {
      display: flex;
      ...
    }

ChatWidget toggles the empty state via the HTML `hidden` attribute
(updateEntityCount → emptyState.toggleAttribute('hidden', !isEmpty)).
The `hidden` attribute applies `display: none` via the user-agent
stylesheet — but the more-specific author rule `:host { display: flex }`
WINS the cascade, so `hidden` has zero visual effect. The toggle silently
no-ops; the panel keeps rendering.

This is the well-known custom-element-with-explicit-display gotcha
documented in the HTML5 spec:
https://html.spec.whatwg.org/multipage/interaction.html#the-hidden-attribute

## Fix

Add an explicit `:host([hidden]) { display: none; }` rule to the
component's static styles. Wins by being more specific than `:host`
alone (attribute selector wraps the host pseudo-class).

Other consumers of `<empty-state>` (UserListWidget, RoomListWidget,
TrainingDashboardWidget, the various Reactive* widgets) avoided this
bug by accident — they use `${this.isEmpty ? this.renderEmptyState()
: nothing}` to conditionally include the element rather than always-in-
DOM + toggle-hidden. ChatWidget chose the toggle-hidden pattern
deliberately because of CSS sibling rules around .messages-container,
so the right fix is to make `hidden` work as expected for the component.

## Verification

- `npm run build:ts` clean.
- Comment in code documents the gotcha + spec link so future readers
  understand why the rule is load-bearing (4 lines of CSS that look
  redundant alongside `:host { display: flex }` until you know the
  cascade history).

CSS-only behavioral fix: zero functional changes, no test added (UI
visual verification is the appropriate sign-off; will follow up after
merge with `npm start` + screenshot of a freshly-loaded populated room
showing no empty-state placeholder).

Co-authored-by: Test <test@test.com>
…1256)

Per Joel's "TS moves DOWN into rust… if not UI/UX it is rust" rule
(2026-05-14), every TS command in `src/commands/*` that exists only to
route into a Rust IPC handler does the same five things:

  1. Validate required params (throw ValidationError with consistent
     message + missing-field name)
  2. Resolve the Rust IPC client singleton
  3. Call the typed mixin method on the client
  4. Translate the snake_case Rust response to camelCase Result via
     `createXResultFromParams`
  5. Return the wrapped result

Steps 1, 2, and 5 were ~30 LOC of pure boilerplate per command. Steps 3
and 4 are the only variable bits. Pre-#1198 status quo: every command
re-wrote the boilerplate inline — exactly the uncompressed redundancy
the compression principle in CLAUDE.md exists to prevent.

This PR adds:

- `RustBackedCommand<TParams, TResult, TRest>` base class
  (`daemons/command-daemon/shared/RustBackedCommand.ts`):
  - Subclass declares `requiredParams` (which fields must be non-empty).
  - Subclass implements `callRust(params, client)` (the variable mixin
    call) and `toResult(raw, params)` (the variable result wrapping).
  - Base class owns: validation loop, client resolution, error
    consistency, the `execute()` orchestration.
  - `validateParams()` is overridable — subclasses needing richer shape
    constraints (e.g., typeof-object checks) call super then add their
    own.
  - `TRest` generic threads the raw mixin response shape through to
    `toResult` for type safety (no `unknown` cast at the seam).

- Canonical example refactor:
  `commands/cognition/admit-inbox-message/server/CognitionAdmitInboxMessageServerCommand.ts`.
  ~64 LOC → ~85 LOC, but most of the new lines are typed declarations
  (`requiredParams`, `AdmitInboxMessageRustResponse` type alias) that
  replace inline boilerplate. Every other command can adopt the same
  shape and lose ~30 LOC of envelope.

This is **PR-1**: pattern + one example. Other ~50 Rust-backed commands
adopt incrementally (don't churn-rewrite all in one PR).

Verification:
- `npm run build:ts` clean.
- The refactored command preserves the existing custom message-shape
  validation (typeof-object check) via the `validateParams` override
  pattern.

Closes #1198 for the pattern + first migration. Follow-ups can adopt
the base class one command at a time.

Co-authored-by: Test <test@test.com>
* fix(channel,#1253): default tick DB to SQLite handle

* chore(clippy): lock warning baseline at 161

---------

Co-authored-by: Test <test@test.com>
…ingle trait, drop adapter shim (#1264)

Closes #1246.

## Smell

`PressureSource` (broker.rs) and `ResourcePool` (pool.rs) were parallel
traits covering the same conceptual ground from two angles:

| Trait              | Method shape                                          |
|--------------------|-------------------------------------------------------|
| PressureSource     | name, pressure (0..1), evict_some, stats_snapshot     |
| ResourcePool       | tier_name, capacity_bytes, usage_bytes, evict_at_least, snapshot |

`PagedResourcePool` implemented both via two manual impls. Tier pools
that don't follow the per-key-page shape (DockerTierPool) only
implemented `ResourcePool` and needed a `ResourcePoolAdapter` shim
(#1245 PR-4) to plug into the broker. Two traits, one shape, an
adapter to bridge them — exactly the "code concurrency / control
surface ONCE then incorporate it" smell Joel flagged 2026-05-14.

## Fix

ResourcePool absorbs `pressure()` and `stats_snapshot()` as default
methods derived from the trait's existing core (capacity / usage /
snapshot). Tier impls override only when they have richer telemetry
(`PagedResourcePool` overrides `stats_snapshot()` to expose its
internal hit/miss/eviction counters; everyone else inherits the
defaults).

PressureBroker now holds `Arc<dyn ResourcePool>` directly. The broker
calls `evict_at_least(want)` instead of `evict_some()`, where `want`
is computed by a new `evict_amount_for(pool)` helper that aims to
drop pressure to `HEALTHY_TARGET_PRESSURE = 0.60` (matching the old
`evict_under_pressure()` "drop until healthy" behavior). 10%-of-cap
floor ensures non-zero ask even at exactly 100% pressure.

## Deletions

- `paging/adapter.rs` (ResourcePoolAdapter — vestigial after the
  collapse; every tier now plugs into the broker directly)
- `PressureSource` trait + `impl<K,V> PressureSource for PagedResourcePool`
  blanket impl — both replaced by direct ResourcePool consumption

## API change

External callers that registered `Arc<dyn PressureSource>` with the
broker now register `Arc<dyn ResourcePool>` instead — ergonomics are
the same (any tier implementing ResourcePool plugs in directly), the
trait name is the only churn. Currently no out-of-tree callers exist
besides the broker tests.

## Tests

66/66 paging tests pass: 9 broker tests (including the broker-end-to-end
on a real PagedResourcePool, the alert emission across the four states,
and PressureTier/PressureAlert serde round-trips), 57 pool tests.

The MockPool + StuckPool test fixtures got rewritten to implement
ResourcePool directly. MockPool's settable pressure path stays via a
`pressure()` override; capacity/usage are synthetic so the broker's
`evict_amount_for` produces sane requests.

## Diff stats

- `paging/pool.rs`: +44/−6 (default methods on ResourcePool + override
  on PagedResourcePool's stats_snapshot)
- `paging/broker.rs`: heavy rewrite (PressureSource → ResourcePool,
  evict_some → evict_at_least + evict_amount_for, mock fixtures
  rewritten)
- `paging/mod.rs`: drops adapter export, drops PressureSource export
- `paging/adapter.rs`: deleted (252 LOC removed)
- `modules/cognition.rs`: comment updated (PressureSource → ResourcePool)

## Clippy / baseline

Clippy at 161 (was 162). The deleted adapter shed one warning.

## Why this matters

Tier pools (Docker, KV cache, future HF cache, future system-RAM,
future NVMe) now plug into the pressure broker via the SAME trait
they already implement for capacity reporting. No more "do I need a
shim?" question. The compression rule from CLAUDE.md applies:
"For ANY decision (logic or data), can you point to exactly ONE place
in the codebase?" — for "tier capacity + eviction + pressure", the
answer is now ResourcePool, period.

Co-authored-by: Test <test@test.com>
…#1265)

Sister command of cognition/admit-inbox-message (refactored in #1256).
Same shape — validate, call mixin, wrap result — now expressed as
RustBackedCommand subclass declarations rather than re-implemented
boilerplate.

- requiredParams = ['personaId']
- validateParams() override adds the kind-companion required-field
  checks (by_id needs id, by_keyword needs keyword, by_origin needs
  origin); calls super first
- callRust delegates to the typed mixin
- toResult shapes the snake_case Rust response into the camelCase
  result via the existing factory

Behavior preserved: every original validation message + return shape
matches. Net: 86 -> 100 LOC, but most new lines are typed declarations
and the explicit per-field error messages — boilerplate is gone.

npm run build:ts clean.

Co-authored-by: Test <test@test.com>
…ConcurrencyPolicy (#1270)

Closes #1247.

## Smell

`live/transport/livekit_agent.rs:86` declared a hand-rolled per-key
lock map:

```rust
static AGENT_CREATION_LOCKS: std::sync::Mutex<
    Option<std::collections::HashMap<(String, String), Arc<tokio::sync::Mutex<()>>>>,
> = std::sync::Mutex::new(None);
```

Used by `get_or_create_agent` to gate concurrent creation of the same
(call_id, user_id) — TOCTOU prevention against 3 concurrent callers all
calling `connect()` and creating 3 redundant agents+video loops.

Two problems:

1. **Reimplements `ConcurrencyPolicy`** — the substrate already shipped
   the canonical primitive in #1230 (TokioConcurrencyPolicy single_flight),
   hardened with panic-safe Drop guards (#1232) + refcount-per-key
   cleanup (#1235). The livekit code carried the exact bug class
   the substrate already solved.

2. **Lock-map entries leaked** — the prior code only released a per-key
   lock entry inside `remove_agent`. Transient agents that errored on
   `connect()` (network blip, LiveKit down) never reached `remove_agent`,
   so their lock-map entries lived forever. ConcurrencyPolicy's
   refcount drops in-flight slots automatically when the last awaiter
   completes, regardless of whether the work succeeded or panicked.

## Fix

Replace `AGENT_CREATION_LOCKS` with a module-level OnceLock holding
`Arc<TokioConcurrencyPolicy<(String, String), Arc<LiveKitAgent>, String>>`.

`get_or_create_agent`:
- Fast path unchanged: `agents.read()` lookup, return early if found.
- Slow path: construct an async work closure that does the post-policy
  re-check + `LiveKitAgent::connect()` + agents map insert + video loop
  spawn, then call `policy.single_flight(key, work)`.
- Concurrent callers for the same key all await the SAME Shared future
  the policy returns, so `connect()` runs ONCE and the result is
  broadcast to every caller.

`remove_agent`:
- No more lock-map cleanup — the policy self-evicts in-flight slots
  via refcount. `remove_agent` only owns the steady-state agents map
  now (drop the agent, disconnect from LiveKit room).

## Validation

- `cargo build --features metal,accelerate` — clean
- `cargo test live::transport --features metal,accelerate` → 16/16 pass
- `cargo clippy --features metal,accelerate` — 161 warnings (was 162;
  the deleted `#[allow(clippy::type_complexity)]` block shed one).

## Net diff

- ~50 lines removed (the static + lock-map cleanup in remove_agent)
- ~50 lines added (the module-level policy + work-closure shape in
  get_or_create_agent)
- Zero behavior change for the steady state; meaningful improvement
  for the transient-agent leak case.

## Architectural alignment

This is the second migration onto ConcurrencyPolicy after the
analyzer's adoption that already lives in canary. Same primitive, same
guarantees. Joel directive 2026-05-14 'code concurrency ONCE then
incorporate it' — livekit was the last per-key single-flight
reimplementation in the codebase that I'd flagged in #1247.

Co-authored-by: Test <test@test.com>
* fix(config): make sqlite the default main database

* chore: lower eslint baseline

* chore: lower eslint baseline after canary merge

* chore: sync generated cognition bindings

---------

Co-authored-by: Test <test@test.com>
inference/compute_router.rs declared a CPU-vs-GPU dispatch policy that
sequential_always_cpu=true on Apple Silicon and routed any matmul under
500K flops to CPU. The file had ZERO callers anywhere in the crate (only
its own tests use ComputeRouter). Production hot path goes through
LlamaCppAdapter -> LlamaCppBackend -> llama.cpp Metal/CUDA which already
loud-fails on no-GPU per inference/model.rs:82
("CPU fallback is disabled.").

Carrying dead code that contradicts the no-CPU-fallback alpha contract
on paper but never executes is the same anti-pattern this card was
filed against. Delete to remove the misleading signal; if a future
tier-aware router is needed, build it then.

Audit findings + 3 sibling cards (#1273 verify+delete Candle qwen3.5,
#1274 delete metal_deltanet.rs, #1275 regression test) posted in
#1262 (comment).

Verified:
- cargo check --features metal: clean (0 errors, pre-existing warnings)
- cargo test --lib --features metal: 2092 passed, 0 failed

Lane: alpha flywheel #1272 lane 6.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Test <test@test.com>
Remove the Candle-side Qwen3.5 inference path (the hybrid DeltaNet +
Attention recurrence loop in vendored/quantized_qwen35.rs and its
ModelBackend wrapper in backends/qwen35_gguf.rs). 1100+ LOC removed.

Why it was dead:
- AIProviderModule::register_adapters (modules/ai_provider.rs:221) only
  registers LlamaCppAdapter for local inference. CandleAdapter is
  imported but never instantiated.
- Qwen35GgufBackend was only reachable via backends::load_gguf_backend,
  whose only callers were unregistered (CandleAdapter, ContinuumModel,
  bin/* utilities) — none in the production hot path.
- Production Qwen3.5 chat goes through llama.cpp (vendored,
  statically linked) via LlamaCppAdapter → LlamaCppBackend.

Scope-down from initial #1273 plan:
The original plan was to delete the entire Candle inference chain
(CandleAdapter, ContinuumModel, quantized.rs, vendored qwen2/llama
backends). cargo check confirmed broader scope is entangled with
plasticity LoRA training tests, which use compact_llama_safetensors
+ rebuild_with_stacked_lora. That broader deletion needs a separate
audit of plasticity's production reachability and is deferred to a
follow-up card.

This PR keeps everything plasticity touches (model.rs,
candle_adapter.rs, quantized.rs, llama_safetensors.rs,
compact_llama_safetensors.rs, vendored qwen2/llama) and only deletes
the qwen3.5-specific Candle path that has no plasticity dependency.

Wire change:
- backends::load_gguf_backend now returns a typed error for
  "qwen3"|"qwen35" architectures pointing callers at LlamaCppAdapter,
  rather than silently dispatching to the deleted Candle backend.

Verified:
- cargo check --features metal: clean (0 errors, 61 pre-existing warnings)
- cargo test --lib --features metal: 2096 passed, 0 failed (4 more than
  baseline — vendored qwen35 module registration removed some dead-code
  warnings that were eating test discovery)

Lane: alpha flywheel #1272 lane 6.
Audit context: #1262 (comment)
Verification: #1273 (comment)

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r) (#1281)

`vendored/metal_deltanet.rs` was a stub for a never-wired Metal kernel.
Its sole "implementation" was `bail!("Metal DeltaNet kernel not yet
wired — use CPU path")` plus a doc comment "Returns Err to signal the
caller to fall back to CPU." Greps confirm zero callers anywhere
(only `vendored/mod.rs:8` declared the module).

Also delete the companion shader `vendored/deltanet_recurrence.metal`
which had no remaining call site after removing the stub Rust
function.

Carrying a "fall back to CPU" pattern in code that nothing reaches is
the same anti-pattern this card was filed against (#1262 audit).

Verified:
- cargo check --features metal: clean
- cargo test --lib --features metal: 2096 passed, 0 failed

Lane: alpha flywheel #1272 lane 6.
Audit: #1262 (comment)

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ract (#1282)

Add `tests/no_cpu_fallback_contract.rs` — three forbidden-strings
ratchets that fail the build if a future PR weakens the
no-CPU-fallback contract:

1. `select_best_device_panics_loudly_on_no_gpu` — asserts
   `inference/model.rs::select_best_device` keeps the
   `panic!("No GPU device available for inference. CPU fallback is
   disabled.")` loud-fail and tries CUDA + Metal before panicking.

2. `ort_providers_documents_no_cpu_fallback_contract` — asserts
   `ort_providers.rs` keeps the "CPU fallback is forbidden" comment
   that documents the rule from source.

3. `llamacpp_adapter_uses_loud_fail_for_no_local_model` — asserts
   `LlamaCppAdapter` uses the typed `NoLocalModelLoadable` error
   (shipped in #1093 / lane A PR-2) rather than a silent skip.

Pattern: same forbidden-strings ratchet shape as lane F PR-2 (#1129
TS persona forbidden-strings), applied to the Rust inference layer.
A test failure points the future-PR-author at the exact contract
they're about to weaken.

Closes the acceptance criterion #3 of #1262 ("regression test per
fallback path"). Final PR (4 of 4) for the silent CPU fallback audit.

Verified:
- cargo test --features metal --test no_cpu_fallback_contract:
  3 passed, 0 failed

Lane: alpha flywheel #1272 lane 6.
Audit: #1262 (comment)

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(airc): add realtime replay adapter

* chore: ratchet clippy baseline

---------

Co-authored-by: Test <test@test.com>
…orm GPU features (#1285)

Make the obvious developer command work on every platform without
requiring contributors to memorize the per-platform Cargo feature
incantation.

Before:
  cd workers/continuum-core && cargo test tick_db_handle --lib
  → fails in vendored llama crate; "metal" or "cuda" feature required

After:
  ./scripts/cargo-test.sh tick_db_handle --lib    (anywhere from src/)
  npm run test:rust -- tick_db_handle --lib
  → auto-detects platform, appends --features metal,accelerate (Mac) /
    --features cuda,load-dynamic-ort (Linux+Nvidia) / etc.

Implementation:
- `scripts/cargo-test.sh` sources the existing
  `scripts/shared/cargo-features.sh` detector (single source of truth
  for platform→features, also used by build-with-loud-failure.sh and
  git-prepush.sh) and forwards arbitrary args to `cargo test`.
- `npm run test:rust` alias added next to `test:precommit` /
  `test:prepush` for discoverability.
- `workers/continuum-core/TESTING.md` documents the friction, the
  wrapper, the CARGO_TEST_NO_FEATURES escape hatch (for verifying the
  loud-fail guard itself), and the relationship to the other test
  entry points.

The wrapper does NOT weaken the no-CPU-fallback compile guard — it
just spares the dev from typing the platform-correct features every
time. The guard still fires in CARGO_TEST_NO_FEATURES=1 mode.

Verified:
- ./src/scripts/cargo-test.sh --test generated_barrel_sync → 8 passed,
  0 failed (8.5s, used --features metal,accelerate on this Mac).

Closes #1257.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1189)

* refactor(chat,#1158): lift renderMessageElement default into AbstractMessageAdapter

Three adapters (TextMessageAdapter, URLCardAdapter, ToolOutputAdapter) had
byte-identical override bodies of the form: parseContent, createAdapterWrapper,
renderContent, template.innerHTML, wrapper.appendChild(fragment).

That is now the default body of AbstractMessageAdapter.renderMessageElement.
The overrides are deleted; the live message-content slot still never sees
innerHTML (the parse happens on a detached template), and Lit-managed
reactive children inside the message bubble keep their state.

ImageMessageAdapter retains its custom override -- it builds img nodes via
property assignment to keep src and alt out of any HTML-parse path and does
not go through renderContent to string.

Net minus 61 lines.

Closes #1158.

* chore(ratchet): lock in -2 eslint from #1158 adapter DRY lift

* chore(eslint-baseline): ratchet -2 from #1189 adapter base default lift

* chore(eslint-baseline): linux ratchet to 5459 (match macOS baseline)

Linux CI ratchet failed because eslint-baseline.linux.txt was still at
5461 while the macOS baseline (and current count on both platforms)
is 5459. The ratchet requires CURRENT == BASELINE strictly, so the
-2 improvement from #1189 needed to land in BOTH platform files.

Sibling: 8b51729 (chore(eslint-baseline): ratchet -2) updated
eslint-baseline.txt; this commit completes the platform symmetry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(eslint-baseline): re-ratchet -2 on both platforms after canary merge

After merging origin/canary into the branch, baselines (mac=5455,
linux=5456) need to drop by the #1189 deletion delta (-2) to
mac=5453, linux=5454. macOS verified locally by precommit:
"Current: 5453 errors". Linux value is +1 vs Mac per established
platform skew; CI will surface the exact number if it's off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(precommit,#1186): add chat-roundtrip persona-reply smoke test

Closes Joel beef: browser ping is pretty low bar (2026-05-14).

New test tests/precommit/chat-roundtrip.test.ts:
  1. Verifies at least one auto-responding user is seeded (catches BUG-105 family)
  2. Sends a unique probe via collaboration/chat/send into general
  3. Polls data/list collection=chat_messages with orderBy timestamp desc, limit 50
  4. Anchors on the probe by content match (sender-id and room captured)
  5. Asserts at least one reply appears in the same room, after the probe,
     from a different sender, with non-empty content

Wires into PRECOMMIT_TESTS so it runs alongside browser-ping. Window is 55s
to leave headroom under the 60s per-test cap that git-precommit.sh imposes.
Uses an explicit-question probe text because local personas filter
no-reply-needed messages aggressively (saves Metal cycles).

What this catches that browser-ping does not:
  - Cognition pipeline silently broken (the highest-value catch)
  - chat-send rejecting the probe (room missing, attribution broken)
  - Persona seed step regressed (no AI users to reply)
  - chat_messages write path broken

Validated live: Helper AI replied to the probe in 5s on a clean stack.
Repeated back-to-back runs can be slow due to Metal queue depth on local
inference; CI runs against a fresh stack and isn't affected.

Followups (sub-cards):
  - 1186 PR-2: path-tier dispatcher (run heavy tests only when relevant
    paths touched). Wires on top of codex #1193 precommit-config loader.
  - 1186 PR-3: adapter unit tests when widgets/chat/adapters/ touched
  - Test reliability: clean local-inference queue between tests OR
    target a dedicated cloud persona for deterministic reply latency

Refs 1186.

* fix(precommit,#1186,#1199): wire chat-roundtrip into precommit-config.sh source of truth

Codex shipped #1193 adding scripts/precommit-config.sh as the canonical
source for PRECOMMIT_TESTS. My #1186 PR-1 (chat-roundtrip test) edited
the legacy defaults branch in git-precommit.sh, which only fires when
the config file is missing.

This commit updates precommit-config.sh to include chat-roundtrip
alongside browser-ping. The defaults branch is left in sync as
belt-and-suspenders so the gate works on either path.

Refs #1186, follow-up to codex #1193.

---------

Co-authored-by: Test <test@test.com>
…0 LOC) (#1288)

Per the plasticity reachability audit on #1280
(#1280 (comment)),
production routes local inference exclusively through `LlamaCppAdapter`.
The Candle-side chain — `CandleAdapter`, `ContinuumModel`,
`select_best_device`, `load_model_by_id`, `quantized.rs::load_*_quantized`,
`backends::generate`, `backends::load_gguf_backend` — was reachable only
through itself or orphaned `bin/*` files. Plasticity's IPC handlers
(`plasticity/{analyze,compact,compress,topology,pipeline}`) work on
safetensors files via plasticity's own helpers and don't touch this
chain.

Deleted:
- `inference/candle_adapter.rs` (1486 LOC)
- `inference/quantized.rs` (287 LOC)
- `inference/model.rs` collapsed from 857 → 167 LOC, retaining only
  `rebuild_with_stacked_lora` (used by `backends/llama_safetensors.rs::CompactLlamaSafetensorsBackend`,
  test-only, slated for Phase 2 deletion alongside the safetensors
  backends once plasticity LoRA training is migrated or retired)

Wire updates:
- `ai/mod.rs`: drop `pub use crate::inference::CandleAdapter` re-export
- `inference/mod.rs`: drop `candle_adapter`/`quantized` modules + their
  re-exports; keep `model::rebuild_with_stacked_lora` only
- `modules/ai_provider.rs`: drop dead `CandleAdapter` import (it was
  imported but never instantiated by `register_adapters`)

Contract relocation (the audit's flagged risk):
The no-CPU-fallback `panic!("...CPU fallback is disabled")` in
`select_best_device` was deleted along with the rest of the dead chain.
The contract's actual production enforcement was already on llama.cpp:
`LlamaCppConfig::default()` sets `n_gpu_layers: -1` (= "all layers on
GPU"), and llama.cpp's loader hard-fails when no GPU is available.
`tests/no_cpu_fallback_contract.rs` is updated atomically to assert the
`n_gpu_layers: -1` invariant in `backends/llamacpp.rs` rather than the
deleted panic site. The `ort_providers` and `LlamaCppAdapter` assertions
survive unchanged.

Net: 7 files changed, +92 / -2546 LOC.

Verified:
- cargo check --features metal: clean (52 pre-existing warnings, 0 errors)
- cargo test --test no_cpu_fallback_contract: 3 passed (new contract
  assertion `llamacpp_default_config_requires_full_gpu_offload` green)
- cargo test --lib --features metal: 2166 passed, 0 failed

Phase 2 (deferred): delete safetensors backends + vendored
qwen2/llama backends + `rebuild_with_stacked_lora` once plasticity's
production reachability allows.

Audit: #1262 (comment)
Mission: Joel 2026-05-15 — "eliminate slop and slowly oxidize this project"

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(config): make sqlite the default main database

* chore: lower eslint baseline

* chore: lower eslint baseline after canary merge

* chore: sync generated cognition bindings

* docs(chat): add airc migration inventory gates

---------

Co-authored-by: Test <test@test.com>
* fix(chat,#1260): track room activity for temperature decay

* chore(lint): ratchet eslint baseline

* chore(lint): ratchet linux eslint baseline

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Test <test@test.com>
…e in Rust (#1298)

First slice of RecipeGenerateServerCommand.ts (371 LOC) → Rust per the
oxidization mission (#1248 umbrella). Same shape as #1289 (rate_proposals):
pure-functions slice first, IPC handler in PR-2, TS shim collapse in PR-3.

Per the carrier-types design block on #1295: the runtime registry state
that the TS prompt depends on (TemplateRegistry.list output, existing
recipe IDs from RecipeLoader.getInstance().getAllRecipes()) crosses the
IPC boundary as explicit RecipeGenerationRequest fields. Keeps the
prompt builder + validator pure, testable, and parity-checkable.

What's in this PR (4 modules, 40 tests):

- types.rs (5 ts-rs exports)
  - RecipeTemplateInfo, RecipeGenerateHints, RecipeGenerationRequest,
    RecipeGenerationResponse, RecipeDefinitionShape
  - All camelCase serde + ts-rs auto-export to shared/generated/cognition/
  - 5 round-trip / shape-acceptance tests

- prompt.rs (build_recipe_system_prompt + build_recipe_user_prompt)
  - System prompt mirrors TS buildSystemPrompt byte-for-byte (schema
    block, available-templates list, standard-pipeline pattern, rules)
  - User prompt mirrors TS buildUserPrompt (description + optional hints
    rendered as bulleted "Hints:" block)
  - 8 tests covering anchors, template rendering with 0/N entries, all
    hint types, partial hints, empty-hints skip-block

- parser.rs (parse_recipe_from_ai_response → RecipeDefinitionShape)
  - Same regex anchor as TS: /\{[\s\S]*\}/ extracts JSON envelope
  - Tolerates prose preamble + markdown fences (matches TS behavior)
  - Typed ParseError::NoJsonEnvelope / MalformedJson with raw_preview
    capped at 500 chars (mirrors TS slice(0, 500))
  - 7 tests covering happy-path + prose preamble + fence + no-JSON +
    malformed + unknown-fields-tolerated + missing-optionals + cap

- validator.rs (validate_recipe_structure → Vec<String>)
  - Mirrors TS validateRecipe checks: required fields, kebab-case
    uniqueId, pipeline shape, RAG template messageHistory, strategy
    enum + required arrays, role type + requires
  - In-request duplicate check via existing_recipe_ids carrier
  - Filesystem collision check + sentinel-template existence stay
    TS-side (PR-3 shim) — they're pure FS / runtime-registry concerns
  - 12 tests covering happy path, every required-field gap, kebab-case
    rejection, empty pipeline, malformed steps, invalid enums, missing
    strategy arrays, role schema, in-request duplicate

## Why no fallback

Per #1262, the TS path's silent error-on-malformed-JSON returns
{ success: false, error: '...' }. Rust returns typed Err — PR-2 IPC
handler maps it to validationErrors[] for the JTAG envelope.

## Next

- PR-2: cognition/generate-recipe IPC command wiring
  AIProviderRegistry::generate_text + the prompt+parser+validator
- PR-3: RecipeGenerateServerCommand.ts becomes thin shim that gathers
  templates + existing recipe IDs, calls Rust, FS collision-checks +
  saves on success

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
joelteply and others added 18 commits June 6, 2026 16:36
…v coupling at one seam (#151) (#1538)

* feat(probes): namespace-prefix + wildcard filter in JsonlProbeFileSink (#151 sprinkle 3)

Per Joel 2026-06-06: the JTAG probes are "fundamentally something
we'd like to make convenient to use to debug this massively complex
machine." Exact-match-only filtering hits its ergonomic ceiling
fast — `CONTINUUM_PROBE_CLASSES=persona.turn.start,persona.turn.silent,persona.turn.spoke,persona.response.render.prompt,persona.response.render.raw,…`
is the kind of incantation that stops getting typed correctly.

This slice replaces the exact-match HashSet lookup with a three-rule
matcher that mirrors `tracing_subscriber::EnvFilter`'s shape — the
shape every Rust dev already knows from `RUST_LOG`. Same env vars,
same sink layer, dramatically shorter operator commands.

## What ships

`routing::probe_file_sink::class_passes_filter` — pure helper,
unit-testable in isolation, used by the Layer's `on_event` path.
Rules in priority order:

1. **Empty filter set** = "no filter configured." Every class passes.
   (`CONTINUUM_PROBE_CLASSES` unset.)
2. **`*` is in the set** = explicit "match every class" wildcard. The
   firehose — distinct from rule 1 in intent per
   `[[no-fallbacks-ever]]`: empty is "I didn't configure", `*` is "I
   deliberately want everything."
3. **Exact OR namespace prefix.** `C == F` (exact) or
   `C.starts_with(F + ".")` (namespace prefix). The `.` guard
   prevents `persona` from accidentally matching `personality.x`.
   Same convention as RUST_LOG.

## Operator-side wins

Before:

```bash
CONTINUUM_PROBE_CLASSES=persona.turn.start,persona.turn.spoke,persona.turn.silent,persona.turn.error,persona.response.enter,persona.response.analyze.result,persona.response.render.prompt,persona.response.render.raw,persona.response.exit.spoke,cognition.analyze.enter,cognition.analyze.noop_single_specialty,cognition.analyze.cache_hit,cognition.analyze.parse
```

After:

```bash
CONTINUUM_PROBE_CLASSES=persona,cognition         # same coverage, 27 chars
CONTINUUM_PROBE_CLASSES=*                          # firehose
CONTINUUM_PROBE_CLASSES=persona.turn,cognition.analyze.parse  # exact + namespace, mixed
```

## Tests (+4 new, on top of the existing 5)

`routing::probe_file_sink::tests`:

- `class_filter_namespace_prefix_matches_subclasses` — `persona`
  prefix matches `persona.turn.spoke` AND
  `persona.response.render.prompt` but NOT `personality.something`
  or `cognition.analyze.parse`. Pins the dot guard.
- `class_filter_wildcard_matches_every_class` — `*` captures every
  class regardless of name.
- `class_filter_combines_exact_and_prefix_in_one_set` — the
  realistic operator pattern (one specific class + one namespace
  prefix) works in the same HashSet without rule contention.
- `class_passes_filter_pure_function_unit_tests` — direct unit
  tests on the helper covering all three rules + the dot guard +
  the literal-prefix-as-exact edge case. Future refactors of the
  per-event Layer can't drift the matching contract without
  breaking this pin.

The existing `class_filter_drops_unallowed_classes` test still
passes (exact match is a degenerate case of rule 3).

## Manual + README updated

- `docs/architecture/RTOS-DEBUGGER-PROBES.md` — "How to enable +
  read" section rewritten with the three-rule spec + example
  invocations + explicit `*` semantics.
- `README.md` — the "Debugging this substrate" section now leads
  with the short prefix form (`CONTINUUM_PROBE_CLASSES=persona,cognition`)
  instead of the long comma-separated list. First impression for
  any new contributor / agent matches the actual usability.

## Why this matters

Joel's framing: the probes are the substrate's debugger for a
massively complex machine. A debugger people don't type because
the syntax is painful isn't a debugger. Prefix matching is the
single cheapest change that takes the JTAG from "in principle
usable" to "actually used in every debug session."

## Doctrine

- `[[jtag-probes-are-rtos-debugger]]` — "easy one liners or it
  won't happen." Prefix matching shrinks the operator-side line
  by an order of magnitude.
- `[[observability-is-half-the-architecture]]` — same Layer shape,
  zero new infrastructure, just better operator UX.
- `[[no-fallbacks-ever]]` — empty filter vs `*` are distinct intents
  with distinct names. The substrate doesn't silently synthesize
  `*` from "no env var set."

card: `7d286195`
parent task: #151
foundation: #1535 (merged)
sprinkle 1: #1536 (merged)
sprinkle 2: #1537 (in review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(probes): boot wiring + typed-config refactor — env coupling at one seam (#151 sprinkle 4)

Two beats in one slice — and the second is here because the first
exposed the brittleness of the design Joel had already flagged.

## Beat 1: server actually installs the sink

Per Joel 2026-06-06: "Let's perfect debugging as we use it." The
`JsonlProbeFileSink` existed and the env vars were documented, but
`continuum-core-server`'s `main.rs` was installing a bespoke
`FmtSubscriber` — so setting `CONTINUUM_PROBE_FILE` had no effect.
The JTAG hardware was unwired.

New helper `routing::install_probe_tracing(config)` composes the
substrate-canonical stack in one call:

  1. `UriCaptureLayer` — URI ancestry for every probe
  2. `ProbeRouterLayer` — in-process broadcast for
     `debug/probes/{class}/stream` consumers
  3. `JsonlProbeFileSink` — disk-persisted JSONL log when
     `config.probe_file` is set
  4. `tracing_subscriber::fmt::layer()` — stderr text logs
     governed by `RUST_LOG` (falls back to `config.default_filter`)

`main.rs` swaps to the helper. Server logs `probes landing at
<path>` at boot when the file sink is configured — the operator-side
proof that the env var took effect.

## Beat 2: env-coupling collapsed to ONE seam

Joel 2026-06-06: "That's why env vars are problematic — they are
brittle." Cargo's parallel test runner raced two
`install_probe_tracing` tests that mutated `CONTINUUM_PROBE_FILE`
across threads; `std::env::set_var` is process-global mutable
state (slated for `unsafe` marking in Rust 2024 precisely because
of this class of bug). Operators hit the same class of brittleness
when env-var inheritance varies across subprocesses or shell
contexts.

This commit fixes the brittleness at the design layer instead of
working around it in tests:

### `ProbeTracingConfig { probe_file, probe_classes, default_filter }` (new)

Typed boot configuration. `install_probe_tracing` takes ONLY
typed values — no env access inside the library function.

### `ProbeTracingConfig::from_env(default_filter)` — THE env seam

The ONE function that touches `std::env`. Reads
`CONTINUUM_PROBE_FILE` + `CONTINUUM_PROBE_CLASSES` (the operator-
facing env names stay verbatim) into a typed config. Future
alternative sources (config file, CLI flags, hardcoded defaults)
become additional constructors on `ProbeTracingConfig` without
rippling into the install function.

### Consequences

- **`main.rs`**: `install_probe_tracing(ProbeTracingConfig::from_env("info"))?`
  — explicit env-coupling at the call site, not buried in the
  library
- **Tests**: construct `ProbeTracingConfig { probe_file:
  Some(temp.path()), ... }` directly — zero env mutation, fully
  parallel-safe. The previous merged-sequential test from the
  first revision of this slice is now split back into TWO
  parallel `#[test]` functions.
- **Operator UX**: unchanged. Same `CONTINUUM_PROBE_FILE` +
  `CONTINUUM_PROBE_CLASSES` env vars, same prefix-match filter
  rules from PR #1538.

## Tests (+3)

`routing::tracing_init::tests`:
- `install_is_idempotent_with_no_disk_capture` — double-call is
  safe via `try_init`; typed config means no env mutation; runs
  parallel-safely against any other test
- `install_surfaces_open_failed_for_unwritable_path` — typed
  `ProbeFileSinkError::OpenFailed` surfaces per
  `[[no-fallbacks-ever]]`; bad path passed as typed value, no env
  var racing
- `from_env_reads_documented_env_vars` — pins both populated and
  empty paths through the env constructor; the ONE test that
  touches `std::env` (scoped so the brittleness can't leak)

## Manual updated

`docs/architecture/RTOS-DEBUGGER-PROBES.md` — "How to enable + read"
section now describes the typed-config split:

  > The installer takes a typed `ProbeTracingConfig` — NOT env vars
  > directly. Env coupling lives at exactly one seam:
  > `ProbeTracingConfig::from_env(default_filter)`. This keeps the
  > library function parallel-test-safe (no `std::env::set_var`
  > racing under `cargo test`) and puts every config source (env,
  > file, CLI flags, hardcoded) on equal footing.

## Doctrine

- `[[jtag-probes-are-rtos-debugger]]` — debugger must be ON in real
  binaries, not just tests, to debug real problems.
- `[[observability-is-half-the-architecture]]` — same layers, same
  order, every entry point.
- `[[no-fallbacks-ever]]` — typed-error surfacing (env-var-unset
  ≠ path-unwritable; the two distinct intents stay distinct).
- The new lesson: process-global mutable state belongs at ONE
  seam. Library functions take typed values. The brittleness Joel
  named showed up first in our own tests; the design fix removes
  the class of bug rather than masking it.

card: `305c8fb9`
parent task: #151
foundation: #1535 (merged) — sprinkle 1: #1536 (merged) — sprinkle 2: #1537 (merged) — sprinkle 3: #1538 (in review)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
) (#1539)

* fix(persona): silence affordance — PASS token in prompt + brain-driven recognition (#151)

The actual persona fix. Found by reading the cognition INSIDE the
brain (not adding a Rust gate around it), made debuggable by the
probe infrastructure landed in #1535-#1538.

## What was broken

`PersonaResponse::Silent` exists as a first-class variant of the
brain's cognition cycle. `service_loop` honors it. But the prompt
the LLM actually sees — assembled by `persona::prompt_assembly::assemble`
— had ZERO text teaching the brain that silence was an option.

Concretely: every assembled system prompt looked like

```
You are Paige, an autonomous AI persona on the grid.
[Shared Analysis — Your Angle] (if present)
[Recent Memory] (if engrams)
[Social Awareness] (if signals)
[Voice Mode] (if voice)
```

then user-role messages: "Hi!" → respond.

A persona being told "you are Paige, respond" has no vocabulary for
"actually I have nothing to add." So it adds something. The
echo-storm bug (#151) is the inevitable consequence of an LLM prompt
that implicitly demands output without admitting silence as a valid
output shape. Even Joel's earlier rule
(2026-04-22, removed the external score_persona veto: "personas
choose themselves") was technically correct but operationally
unreachable — the brain couldn't choose silence because the prompt
never offered it.

## The fix

Universal silence affordance in the system prompt + brain-driven
recognition. Two changes:

### `persona::prompt_assembly` — teach the vocabulary

Added `SILENCE_TOKEN = "PASS"` constant + `SILENCE_AFFORDANCE_BLOCK`
string + `looks_like_silence_token(text)` helper. `assemble()` now
ALWAYS appends the block (unconditional — silence is universal, not
per-tier or per-role):

```
[Silence Option]
You are NOT required to respond to every message. If you have
nothing valuable to add, reply with the single word PASS (no other
text, no punctuation). Choose PASS when:
- You just spoke and nothing new has been raised.
- The message is small-talk that doesn't need your perspective.
- Another persona is better suited and already responded.
- You're tired or low-confidence on this topic.
Silence is a first-class response — it's how you avoid pointless
chatter.
```

`looks_like_silence_token` permits LCD-tier sloppiness (case,
whitespace, single trailing period) without admitting substantive
responses that happen to contain "pass" as a word.

### `persona::response::respond_inner` — honor the brain's choice

After `strip_thinks_emit_events` + `strip_leaked_tool_markup`, the
post-processed `visible_text` is checked against
`looks_like_silence_token`. Match → return
`PersonaResponse::Silent { reason: "persona chose silence via PASS
affordance", relevance_score: 0.0 }` instead of `Spoke`.

A new RTOS-debugger probe `persona.response.exit.silent` fires
when this path is taken so training/observability can analyze when
the affordance is used.

## Doctrine

This is NOT a Rust-side gate around cognition.
`[[no-rust-gates-around-cognition]]` Joel rejected my earlier
`check_echo_chamber` slice as exactly that bypass. The substrate
isn't deciding silence for the persona — the substrate is giving
the persona's brain an EXPLICIT VOCABULARY for a choice that
already exists in the type system (`PersonaResponse::Silent`).
Without the vocabulary, the brain has no way to signal that
choice. With it, the brain decides; the substrate recognizes the
signal.

This is the same shape as:
- The brain emits `<think>...</think>` and the substrate recognizes
  + emits the cognition:think-block event (well-established pattern
  in respond_inner)
- The brain emits a tool-call envelope and the substrate routes it
  through ToolExecutor

In each case the substrate offers a contract, the brain chooses
whether to use it, and the substrate honors the brain's signal.

## Tests (+2)

`persona::prompt_assembly::tests`:

  - `assembled_prompt_always_carries_silence_affordance` — pins
    that EVERY assembled prompt includes both `[Silence Option]`
    and the literal `PASS` token. A future PR that wires per-tier
    prompts or removes the universal affordance must update this
    expectation; silent removal would re-introduce the echo-storm
    bug.

  - `silence_token_recognizer_contract` — positive cases (PASS,
    pass, Pass., "  pass  ", etc) and negative cases ("Pass on
    the bread please", "I'll pass on this one", "PASS:", empty,
    etc). Pins both sides of the recognizer.

## What's not in this slice

- Updating downstream persona-response observability to emit a
  cognition event when silence is chosen (the new probe already
  surfaces this; a richer event hookup can come later if the
  training loop wants it).
- Tuning the affordance text per tier (LCD-tier might benefit
  from MORE examples; capable models could use less). Defer until
  real conversation data with the probes wired tells us if the
  block needs per-tier shaping.
- Echo-storm-specific framing in the block. The current text is
  general; if persona-to-persona greeting loops persist with the
  affordance in place we can add a sharper "if N AI messages in a
  row and you've already greeted them, choose PASS" line. Wait
  for data before tuning.

## How this was diagnosed

The probe infrastructure (#1535-#1538) made this surgical:
`persona.response.render.prompt` would have shown the assembled
system_prompt verbatim in JSONL form, making the missing
affordance immediately visible to any operator running a real
multi-persona scenario. The diagnostic walked the code by hand
because the probes weren't yet useful as a hunting tool (the
binary that boots them wasn't wired until #1538) — but the same
diagnostic, repeated for #152 / future cognition bugs, will start
with `tail -f probes.jsonl | jq` instead of grep.

card: `612d65ac`
parent task: #151

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(persona): LCD identity grounding — explicit drift-target enumeration in system prompt (#152)

Sibling fix to #1539 (silence affordance). Same diagnosis pattern,
same place in the cognition (prompt the brain sees), same doctrine:
the substrate gives the brain vocabulary; the brain decides; the
substrate honors the brain's signal.

## What was broken

Pre-fix `build_persona_system_prompt`:

```rust
format!("You are {agent_name}, an autonomous AI persona on the grid.")
```

LCD-tier models (Qwen2.5-0.5B and similar) cannot hold a single-line
identity under any conversational pressure. Observed drift modes
(#152):
- Persona claims to be Claude or ChatGPT
- Persona renders dialogue from another persona's perspective
  ("Helper AI says X")
- Persona hallucinates a Siemens PLC backstory or some other
  training-data residue
- Persona drifts to "I am an AI assistant designed to..."
  defaults

These aren't model-failure bugs — they're inadequate-prompt bugs.
Single-line identity statements lose against thousands of fine-
tuning gradient steps that taught the base model to identify as
"a helpful AI assistant" by default. Concrete negative instructions
("you are NOT X") are operationally effective on small models in
ways that single-line positive identity statements are not.

## The fix

Three concrete clauses, each addressing a specific drift mode:

### 1. Identity anchoring with explicit drift-target enumeration

```
You are {name}. You are NOT Claude, GPT, ChatGPT, Gemini, Llama,
Qwen, or any other named assistant. You are NOT a Siemens PLC, a
customer service bot, or any persona other than {name}.
```

Concrete names beat abstract "don't drift" instructions on LCD
tier. The list is the operationally observed drift targets from
Joel's testing (#129 + #130).

### 2. Substrate vocabulary

```
'The grid' is the substrate hosting you. 'Rooms' are conversation
spaces where peers (other personas, humans, agents) exchange
messages.
```

"the grid" alone was undefined for LCD models; they invented their
own interpretation. Adding "rooms / peers / messages" gives the
brain a coherent world model to ground in.

### 3. First-person stability

```
Always speak as YOURSELF in the first person ('I think...', 'I'd
rather...'). Never narrate other personas' speech or write dialogue
from another point of view.
```

Per Joel 2026-06-03's `[[intent-driven-api-not-hot-patches]]`
testing: the single most effective LCD-tier anti-drift instruction.
Without it the model occasionally renders dialogue from another
perspective ("Helper AI says X" — confusing the persona with its
peers).

### 4. Output-shape vocabulary (couples to silence affordance)

```
Your only outputs are: (a) a direct reply to the room, or (b) the
silence token described in the [Silence Option] block.
```

Couples to the silence affordance from #1539 — telling the brain
that PASS is one of its TWO sanctioned output shapes, which both
reinforces the silence option AND constrains the response space.

## Tests

`persona::service_loop::tests::system_prompt_carries_lcd_identity_grounding`
replaces the previous `cached_system_prompt_matches_legacy_format_template`
(which pinned the legacy single-line template verbatim — that pin's
job is done; the template intentionally changed for this fix).

The new test pins the STRUCTURAL contract — specific clauses that
address known drift modes — without pinning prose verbatim. Future
tightening of wording stays cheap; structural regression is loud.
Asserts cover:

  1. Persona name appears
  2. Role line ("autonomous AI persona")
  3. Identity block header
  4. Drift-target enumeration (Claude, GPT, Gemini, Llama, Qwen,
     Siemens PLC — each as a named string)
  5. First-person stability clause
  6. Grid + room vocabulary
  7. Silence-option reference (coupling to #1539)

The Arc-clone test (`cached_system_prompt_clones_via_arc_refcount`)
stays — its contract is about cloning shape, not content.

## Doctrine

Same as #1539:

- `[[no-rust-gates-around-cognition]]` — this is NOT a Rust gate.
  It's substrate vocabulary that gives the brain a clearer
  identity to ground in. The brain still decides what to say;
  this just stops the brain from forgetting WHO is saying it.
- `[[init-once-handle-then-lease-zero-copy-refs]]` — the prompt
  is still built ONCE at PersonaContext construction (#195 slice 2
  caching survives this change; the cache grows but the
  per-turn re-tokenize stays zero). Task #149's
  pre-tokenization will eventually drop even the leased
  String::clone — but the content change here is the input to
  that optimization.
- `[[observability-is-half-the-architecture]]` — once the JTAG
  is wired end-to-end (#1538 merging), every persona turn's
  assembled prompt will surface in the JSONL probe log. The
  identity drift bug + the prompt fix become a single artifact
  diff that any operator can audit offline.

## How this was diagnosed

Same probe-informed diagnostic walk as #1539. The probes
themselves are still sitting in PR #1538 review waiting for the
binary-side install; the diagnostic walked the code by hand. But
the same diagnostic, repeated for the NEXT cognition bug, will
start with `jq 'select(.class == "persona.response.render.prompt") | .fields.system_message'`
on a real probe log — diff before / after the fix, audit ANY
persona's prompt at any time.

card: TBD on push
parent task: #152
sibling: #1539 (silence affordance)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(probes): time_probe! macro — safe one-line async timing for cognition seams

Per Joel 2026-06-06 `[[refine-tools-as-you-use-them]]`: I hit this
friction in the silence-affordance + identity-grounding work and
sat on it. Every async timing site in the cognition path was an
`.instrument(info_span!("time", name=..., probe_class="timing")).await`
ceremony — three lines plus a `use tracing::Instrument` import,
nobody writes those when adding a new seam in a hurry. The result:
async cognition stages stayed untimed even though `time_sync!`
makes sync-block timing one line.

`time_probe!` collapses async timing to the same one-line shape:

```rust
// Before — every async timing site:
use tracing::Instrument;
let span = tracing::info_span!("time", name = "analyze",
                              probe_class = "timing");
let analysis = analyze(input).instrument(span).await;

// After:
let analysis = time_probe!("analyze", analyze(input));
```

## Why this didn't ship in PR #1529

The existing comment block in `routing/macros.rs` documents why an
`async`-timing macro was deliberately deferred:

1. Naming collision with `crate::logging::time_async!` (RAII
   TimingGuard shape — different observability path).
2. The previous `time!` macro was a foot-gun: it expanded to
   `let _enter = span.enter(); $body` where `$body` contained
   `.await`, holding `_enter` across the await suspension and
   breaking `URI_STACK` per the d1cf19d dispatch fix.

This commit addresses both:

- **Naming**: `time_probe!` (not `time_async!`) — the suffix names
  the OUTPUT (a timing probe), not the executor shape. Keeps the
  `crate::logging::time_async!` namespace untouched; the two macros
  stay disjoint.
- **Safety by construction**: the macro expands to
  `$future.instrument(span).await`. The future itself enters /
  exits the span via `Future::poll` boundaries — no scope guard
  ever held across an await. Same shape `CommandExecutor::dispatch`
  uses.

The comment block in macros.rs is replaced with the new macro's
docstring, which preserves the safety reasoning + names the prior
foot-gun for future-developer context.

## Tests (+2)

`routing::macros::tests`:

- `time_probe_returns_inner_future_value` — pin that the macro is
  VALUE-TRANSPARENT. `time_probe!("seam", expr)` and `expr.await`
  must produce the same value at the call site, so adding the
  probe is a pure observability addition with no shape change.
  Uses a `current_thread` tokio runtime so the test stays
  executor-light.
- `time_probe_nested_compose_and_return_inner_value` — pin that
  multiple `time_probe!` calls compose. The inner span becomes a
  child of the outer span (same as `time_sync!` nesting); the
  value flows through both layers unchanged.

The existing `time_sync!` tests stay unchanged — sync timing is
unaffected by this addition.

## Manual updated

`docs/architecture/RTOS-DEBUGGER-PROBES.md` — the macro table at
the top now lists `time_probe!` alongside `probe!` / `time_sync!`
/ `time_async!` / `stack!` with a brief "prefer this over bare
`.instrument(...)` ceremony" note + a contrast with the
RAII-shape `time_async!` from `crate::logging`. Operators
filter sync + async timings together via
`CONTINUUM_PROBE_CLASSES=timing` and see one flat timeline.

## Why this lands here (not a separate PR)

Per Joel's `[[refine-tools-as-you-use-them]]`: refine the
substrate AS I use it, not after. I'm shipping cognition fixes
that need timing seams across async boundaries (#149 prefill
caching, #112-114 inference-handle bypass, future analyze
optimizations). Without `time_probe!` the next time I'd
sprinkle async timing I'd skip it because the ceremony is
prohibitive. Better: refine the substrate, ship the cognition
work + the substrate refinement that makes it sustainable.

Parent task: substrate refinement under `[[refine-tools-as-you-use-them]]`
Companion PRs in flight: #1538 (boot wiring) + #1539 (silence + identity)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(probes): time_probe! revision per reviewer mandate findings

Three adversarial reviewers spawned per the new reviewer-mandate
doctrine (`[[reviewer-mandate-elegance-and-substrate-viability]]`)
BLOCKED with substantive findings. This commit addresses the
in-scope ones; the deeper substrate gaps are tracked as follow-ups.

## In-scope fixes (this commit)

1. **Field rename `name` → `seam`.** Reviewer 3 flagged collision
   risk — other probes use `name` for different semantics. `seam`
   is unambiguous and tells operators to write
   `jq 'select(.fields.seam == "cognition.analyze")'`.

2. **Hidden `use ::tracing::Instrument as _;` removed.** Reviewer 1
   flagged the scoped import inside macro body as unconventional
   and cognitively load-bearing. Replaced with fully-qualified
   `::tracing::Instrument::instrument(future, span).await` call —
   no hidden import, contract visible at the call site.

3. **Docstring honesty.** Reviewer 2 flagged the prior "zero cost
   when disabled" claim as overclaim — `Instrumented<F>` wrapper
   persists at runtime even with `release_max_level_*` features.
   New cost section: ~24 bytes per call site, one branch per
   poll, allocates `Span` regardless of subscriber state.
   Acceptable for cognition seams (Qwen dominates wall-clock);
   bench per task #198 before sprinkling into hot loops.

4. **Error-path test.** Reviewer 3 flagged missing Result-future
   coverage. New `time_probe_propagates_error_from_inner_future`
   pins that `Err` flows through unchanged per
   `[[no-fallbacks-ever]]`.

5. **Manual example block.** Reviewer 3 flagged the "How to add a
   probe" section showing only `time_async!` (the RAII shape) but
   not `time_probe!`. Now shows both with explicit guidance:
   substrate seams use `time_probe!`; legacy logging-crate seams
   use `time_async!`. Includes the persistence caveat (see #196).

## Follow-up substrate gaps (separate tasks)

- **#196**: `ProbeRouterLayer` + `JsonlProbeFileSink` only
  implement `on_event`, not `on_close`. `time_sync!` AND
  `time_probe!` emit SPANS, not events — neither timing macro
  actually persists timings to the JSONL log today. The call
  shape ships here; the routing side ships in #196. The macro
  docstring + manual carry the caveat explicitly.

- **#197**: Probe class taxonomy decision — flat `timing` vs
  hierarchical. Operators filtering `cognition` won't catch
  cognition timings under the flat scheme; substrate convention
  needs to be picked.

- **#198**: Probe Layer allocation hot-path audit — reviewer 2
  estimated ~50-100 HashMap allocs/sec per persona; benchmark
  before sprinkling into every async seam.

## Why this lands as a revision rather than withdrawal

Per `[[refine-tools-as-you-use-them]]`: ship the call-site shape
that becomes stable. The routing-side gap (#196) is its own slice
worth doing right rather than rushing into this PR. The docstring
+ manual carry the caveat so no one mistakes the macro for an
end-to-end shipping observability primitive — yet.

## Tests

3 passing:
- `time_probe_returns_inner_future_value`
- `time_probe_propagates_error_from_inner_future` (new — pins
  Result futures don't swallow errors)
- `time_probe_nested_compose_and_return_inner_value`

## Doctrine

- `[[reviewer-mandate-elegance-and-substrate-viability]]` — three
  adversarial lenses (architecture / speed-viability / probe-
  coverage) all surfaced real findings. The mandate works.
- `[[refine-tools-as-you-use-them]]` — revising a primitive in
  response to reviewer feedback IS the application work informing
  the substrate.
- `[[no-fallbacks-ever]]` — error-path test pinned; substrate
  refuses silent swallowing at any seam.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ow persist (#196) (#1541)

* feat(probes): time_probe! macro — safe one-line async timing for cognition seams

Per Joel 2026-06-06 `[[refine-tools-as-you-use-them]]`: I hit this
friction in the silence-affordance + identity-grounding work and
sat on it. Every async timing site in the cognition path was an
`.instrument(info_span!("time", name=..., probe_class="timing")).await`
ceremony — three lines plus a `use tracing::Instrument` import,
nobody writes those when adding a new seam in a hurry. The result:
async cognition stages stayed untimed even though `time_sync!`
makes sync-block timing one line.

`time_probe!` collapses async timing to the same one-line shape:

```rust
// Before — every async timing site:
use tracing::Instrument;
let span = tracing::info_span!("time", name = "analyze",
                              probe_class = "timing");
let analysis = analyze(input).instrument(span).await;

// After:
let analysis = time_probe!("analyze", analyze(input));
```

## Why this didn't ship in PR #1529

The existing comment block in `routing/macros.rs` documents why an
`async`-timing macro was deliberately deferred:

1. Naming collision with `crate::logging::time_async!` (RAII
   TimingGuard shape — different observability path).
2. The previous `time!` macro was a foot-gun: it expanded to
   `let _enter = span.enter(); $body` where `$body` contained
   `.await`, holding `_enter` across the await suspension and
   breaking `URI_STACK` per the d1cf19d dispatch fix.

This commit addresses both:

- **Naming**: `time_probe!` (not `time_async!`) — the suffix names
  the OUTPUT (a timing probe), not the executor shape. Keeps the
  `crate::logging::time_async!` namespace untouched; the two macros
  stay disjoint.
- **Safety by construction**: the macro expands to
  `$future.instrument(span).await`. The future itself enters /
  exits the span via `Future::poll` boundaries — no scope guard
  ever held across an await. Same shape `CommandExecutor::dispatch`
  uses.

The comment block in macros.rs is replaced with the new macro's
docstring, which preserves the safety reasoning + names the prior
foot-gun for future-developer context.

## Tests (+2)

`routing::macros::tests`:

- `time_probe_returns_inner_future_value` — pin that the macro is
  VALUE-TRANSPARENT. `time_probe!("seam", expr)` and `expr.await`
  must produce the same value at the call site, so adding the
  probe is a pure observability addition with no shape change.
  Uses a `current_thread` tokio runtime so the test stays
  executor-light.
- `time_probe_nested_compose_and_return_inner_value` — pin that
  multiple `time_probe!` calls compose. The inner span becomes a
  child of the outer span (same as `time_sync!` nesting); the
  value flows through both layers unchanged.

The existing `time_sync!` tests stay unchanged — sync timing is
unaffected by this addition.

## Manual updated

`docs/architecture/RTOS-DEBUGGER-PROBES.md` — the macro table at
the top now lists `time_probe!` alongside `probe!` / `time_sync!`
/ `time_async!` / `stack!` with a brief "prefer this over bare
`.instrument(...)` ceremony" note + a contrast with the
RAII-shape `time_async!` from `crate::logging`. Operators
filter sync + async timings together via
`CONTINUUM_PROBE_CLASSES=timing` and see one flat timeline.

## Why this lands here (not a separate PR)

Per Joel's `[[refine-tools-as-you-use-them]]`: refine the
substrate AS I use it, not after. I'm shipping cognition fixes
that need timing seams across async boundaries (#149 prefill
caching, #112-114 inference-handle bypass, future analyze
optimizations). Without `time_probe!` the next time I'd
sprinkle async timing I'd skip it because the ceremony is
prohibitive. Better: refine the substrate, ship the cognition
work + the substrate refinement that makes it sustainable.

Parent task: substrate refinement under `[[refine-tools-as-you-use-them]]`
Companion PRs in flight: #1538 (boot wiring) + #1539 (silence + identity)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(probes): time_probe! revision per reviewer mandate findings

Three adversarial reviewers spawned per the new reviewer-mandate
doctrine (`[[reviewer-mandate-elegance-and-substrate-viability]]`)
BLOCKED with substantive findings. This commit addresses the
in-scope ones; the deeper substrate gaps are tracked as follow-ups.

## In-scope fixes (this commit)

1. **Field rename `name` → `seam`.** Reviewer 3 flagged collision
   risk — other probes use `name` for different semantics. `seam`
   is unambiguous and tells operators to write
   `jq 'select(.fields.seam == "cognition.analyze")'`.

2. **Hidden `use ::tracing::Instrument as _;` removed.** Reviewer 1
   flagged the scoped import inside macro body as unconventional
   and cognitively load-bearing. Replaced with fully-qualified
   `::tracing::Instrument::instrument(future, span).await` call —
   no hidden import, contract visible at the call site.

3. **Docstring honesty.** Reviewer 2 flagged the prior "zero cost
   when disabled" claim as overclaim — `Instrumented<F>` wrapper
   persists at runtime even with `release_max_level_*` features.
   New cost section: ~24 bytes per call site, one branch per
   poll, allocates `Span` regardless of subscriber state.
   Acceptable for cognition seams (Qwen dominates wall-clock);
   bench per task #198 before sprinkling into hot loops.

4. **Error-path test.** Reviewer 3 flagged missing Result-future
   coverage. New `time_probe_propagates_error_from_inner_future`
   pins that `Err` flows through unchanged per
   `[[no-fallbacks-ever]]`.

5. **Manual example block.** Reviewer 3 flagged the "How to add a
   probe" section showing only `time_async!` (the RAII shape) but
   not `time_probe!`. Now shows both with explicit guidance:
   substrate seams use `time_probe!`; legacy logging-crate seams
   use `time_async!`. Includes the persistence caveat (see #196).

## Follow-up substrate gaps (separate tasks)

- **#196**: `ProbeRouterLayer` + `JsonlProbeFileSink` only
  implement `on_event`, not `on_close`. `time_sync!` AND
  `time_probe!` emit SPANS, not events — neither timing macro
  actually persists timings to the JSONL log today. The call
  shape ships here; the routing side ships in #196. The macro
  docstring + manual carry the caveat explicitly.

- **#197**: Probe class taxonomy decision — flat `timing` vs
  hierarchical. Operators filtering `cognition` won't catch
  cognition timings under the flat scheme; substrate convention
  needs to be picked.

- **#198**: Probe Layer allocation hot-path audit — reviewer 2
  estimated ~50-100 HashMap allocs/sec per persona; benchmark
  before sprinkling into every async seam.

## Why this lands as a revision rather than withdrawal

Per `[[refine-tools-as-you-use-them]]`: ship the call-site shape
that becomes stable. The routing-side gap (#196) is its own slice
worth doing right rather than rushing into this PR. The docstring
+ manual carry the caveat so no one mistakes the macro for an
end-to-end shipping observability primitive — yet.

## Tests

3 passing:
- `time_probe_returns_inner_future_value`
- `time_probe_propagates_error_from_inner_future` (new — pins
  Result futures don't swallow errors)
- `time_probe_nested_compose_and_return_inner_value`

## Doctrine

- `[[reviewer-mandate-elegance-and-substrate-viability]]` — three
  adversarial lenses (architecture / speed-viability / probe-
  coverage) all surfaced real findings. The mandate works.
- `[[refine-tools-as-you-use-them]]` — revising a primitive in
  response to reviewer feedback IS the application work informing
  the substrate.
- `[[no-fallbacks-ever]]` — error-path test pinned; substrate
  refuses silent swallowing at any seam.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(probes): on_close in both probe Layers — time_sync!/time_probe! now persist (#196)

Load-bearing fix discovered by reviewer-mandate review of #1540 (time_probe!):
both `ProbeRouterLayer` and `JsonlProbeFileSink` only implemented `on_event`,
so the timing spans emitted by `time_sync!` and `time_probe!` were observed by
no consumer. Operators running `CONTINUUM_PROBE_CLASSES=timing` saw zero
timing records on disk no matter how many seams were instrumented. The macros
were theatrical — Joel's RTOS-debugger framing required actual wall-clock
persistence to "hunt down bottlenecks."

This commit closes the gap:

- `ProbeRouterLayer`: add `SpanProbeMeta` + `on_new_span` + `on_close` so each
  `probe_class`-carrying span fans out a `ProbeEvent { class, duration_ms, .. }`
  on close. Spans without `probe_class` are ignored at zero allocation cost
  per `[[no-fallbacks-ever]]`.

- `JsonlProbeFileSink`: mirror the same shape — `FileSinkSpanMeta` +
  `on_new_span` + `on_close`. Same class filter applies; `duration_ms` is
  injected into the on-disk JSON `fields` so the line shape matches the
  broadcast envelope.

- `time_sync!`: unify field name to `seam = $name` (was `name`) so it matches
  `time_probe!`. Operators get one `jq` query — `.fields.seam == "phase"` —
  that works for either macro. The pre-existing value-transparency tests
  don't assert on field names so this rename is non-breaking.

Tests:
- `probe_router::tests::time_sync_span_close_fans_out_timing_event`
- `probe_router::tests::time_probe_span_close_fans_out_timing_event`
- `probe_router::tests::span_without_probe_class_does_not_fanout`
- `probe_file_sink::tests::time_sync_span_close_persists_timing_to_jsonl`
- `probe_file_sink::tests::time_probe_span_close_persists_timing_to_jsonl`
- `probe_file_sink::tests::plain_span_close_does_not_persist_to_jsonl`
- `probe_file_sink::tests::class_filter_applies_to_timing_spans`

244/244 routing tests pass; 13/13 macro tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(probes): hoist SpanProbeMeta to shared module — addresses R1+R2 BLOCKs

Reviewer-mandate review of #1541's first commit BLOCKED twice with overlapping
load-bearing concerns:

- R1 (architecture/design): SpanProbeMeta + FileSinkSpanMeta were
  byte-for-byte identical with ~60 lines of copy-pasted lock/visit logic.
  Each Layer captured its own Instant::now() at on_new_span -> router and sink
  reported subtly different duration_ms for the same span. No test verified
  both layers compose in one subscriber.

- R2 (speed/Intel-Mac viability): on_new_span fired for EVERY tracing
  span the substrate emits (tokio executor, framework, plain info_span!).
  Each Layer's visitor allocated a HashMap + walked ALL fields with
  format!(...) before discarding when probe_class was missing. Per-span
  allocator pressure on the LCD floor.

This refactor hoists the lifecycle into routing/probe_span_meta.rs:

1. span_carries_probe_class(attrs) - cheap static check. Walks
   attrs.metadata().fields() (static field set, no allocation) for the
   probe_class name. The vast majority of spans short-circuit here with
   zero visitor work. Addresses R2's per-span hot-path cost.

2. ensure_probe_meta(attrs, span_ref) - idempotent install. First
   Layer to see the span populates the extension; second Layer finds it
   already present and no-ops. Both Layers visit the attrs ONCE total,
   not once per Layer. Addresses R2's doubled-cost concern.

3. build_timing_event_from_meta(span_ref, uri_chain) - shared event
   builder. Both Layers read the SAME start: Instant from the
   extension -> identical duration_ms on broadcast stream and JSONL log.
   Addresses R1's timing-drift concern.

4. New composition test:
   probe_file_sink::tests::both_layers_in_one_subscriber_agree_on_duration_ms
   installs ProbeRouterLayer + JsonlProbeFileSink in one subscriber, fires
   a time_sync!, asserts the broadcast subscriber + JSONL line agree on
   class + seam + duration_ms. Pins R1's "no composition test" gap.

5. docs/architecture/RTOS-DEBUGGER-PROBES.md pins the
   seam-not-name field-naming convention per R1's minor - operators
   can jq '.fields.seam' against both time_sync! and time_probe!
   output without thinking about which macro emitted the record.

Tests: 247/247 routing tests pass (3 net new). The composition test would
have caught the original duplication-induced drift had it existed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…atacenter (#1542)

Adds "The Compounding Argument" section between "Pseudo-AI vs true AI"
and "The Academy" — turns today's doctrine chain into a 5-minute read
that argues the substrate's structural advantage explicitly:

- Linear (datacenter) vs exponential (substrate) capability growth, with
  the math side-by-side: C_dc * alpha_dc vs C * alpha * (1 + beta*log(N)).
  Mesh-cross-pollination scales with active peers; datacenters can't
  replicate it for structural reasons (cross-org IP, monolithic models,
  scheduled retrain).
- The composition story: HF for bulk distribution + forge-alloy for
  metadata/provenance/lineage + airc for federated discovery + two-tier
  reputation (substrate-measured benchmarks for LoRAs, Foundry/Sentinel-AI
  for base models). "We wire existing infrastructure; we don't build a
  parallel internet for intelligence."
- Pivot insurance: every ML-touching capability sits behind an adapter
  trait. llama.cpp today for LoRA inference; Candle a peer alternative;
  pivots are swaps, not rewrites. The substrate's commitment is to the
  abstraction, not to any framework.
- Two unique payoffs nobody else gets: data abundance (training corpus
  IS the substrate's normal operation) and distributed checkpointing
  (every persona that loaded a layer is a verified backup).
- Refined existing "Verifiable lineage" row to add "falsifiable
  benchmarks" — the math-backed contract datacenters can't offer.

Lands as evidence for the "puddles and streams" framing the README
already opens with. Architecture phase closes; execution opens.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ies (card 42bd9367) (#1543)

The bool-flag `AdapterCapabilities` (supports_tool_use, supports_vision, etc.)
was unsigned about HOW each capability is provided — fine for "is the
feature available" questions, useless for "which protocol does cognition
need to speak." Arc 1 of the substrate plan addresses this directly.

This commit adds three typed descriptors alongside the existing bool
flags (kept for back-compat; new selectors should consult the typed
fields):

- `tool_call_protocol: ToolCallProtocol` — enumerates HOW the adapter
  accepts tool calls. None / JsonInPrompt / JsonMode /
  NativeFunctionCalling / XmlTags. Cognition's tool loop routes through
  this without special-casing per adapter name.

- `structured_output_protocol: StructuredOutputProtocol` — None /
  JsonSchema / GrammarConstrained (llama.cpp GBNF) / PromptOnly. Lets
  cognition pick the right enforcement strategy per workload per adapter.

- `modalities: ModalitySet` — typed text_in/text_out/vision_in/audio_in/
  audio_out booleans declaring what the adapter handles NATIVELY.
  Modalities NOT in the set get bridged by the substrate
  (VisionDescriptionService, STT, TTS) before the adapter sees the
  request. LCD personas get sensory parity via bridges per the
  [[ai-namespace-multimodal-crutches]] doctrine.

- `max_output_tokens: u32` — distinct from `max_context_window`; bounds
  the compose phase.

Each existing adapter declares truthfully:

- **HeuristicAdapter** — TEXT_ONLY, no tool protocol, no structured
  output. Substrate doesn't try to use it for what it can't do.
- **OpenAI-compatible** (OpenAI/DeepSeek/Together/Fireworks/Groq/xAI/
  Mistral) — NativeFunctionCalling + JsonSchema when supports_tools.
  Vision-in native when supports_vision.
- **Anthropic** — NativeFunctionCalling (tool_use blocks) +
  JsonSchema. Vision-in native.
- **LlamaCpp** — JsonInPrompt (prompt-driven tool emulation) +
  GrammarConstrained (GBNF). Vision-in handled by mmproj adapter
  separately.
- **AircRemoteAdapter** — defers to None / TEXT_ONLY until a
  capability-discovery handshake card lands (future work).

Per [[adapter-pattern-is-the-pivot-insurance]]: every ML-touching
capability sits behind this trait so the substrate pivots (swap
framework, swap model, swap provider) by declaration, not rewrite.
Arc 2 (#122 LoRA paging) and arc 3 (teacher routing) build on this.

Tests: 48/48 ai:: tests pass; no behavior changes (existing capability
queries continue to work via bool flags; new typed fields are purely
additive).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ce A.2.1) (#1528)

* fix(boot): typed AircDiscovery + BootMode + conditional manifest (Slice A.2.1)

Replaces Slice A (PR #1527, closed via convergent reviewer BLOCK) with
the architectural change Slice A's three patch-fixes were imitating.
Compresses three drifting representations of substrate boot state
(tuple `Option<(PathBuf, RoomId)>` + flat const `EXPECTED_MODULES` +
heuristic seed detection) into one typed primitive each consumer
matches exhaustively. The drift problem dies structurally — not
because we wrote a louder doc-comment this time, but because the
contradictions are no longer expressible.

## Closes (with structural fixes, not patches)

  R1#1 (BLOCKING) — EXPECTED_MODULES contradicted --mode=inference-only
    fix: `verify_registration(&discovery, mode)` computes the required
    set CONDITIONALLY. `MODULES_PERSONA_HOSTING` is only required when
    AIRC is `Healthy` AND `mode.requires_persona_hosting()`. The PR
    title "supports inference-only" is now true by construction.

  R2#1 (BLOCKING) — `AIRC_DAEMON_SOCKET` env var bypassed liveness
    fix: `discover()` aggregator runs Status RPC liveness probe.
    Failure promotes to `AircDiscovery::Degraded { reason:
    StaleSocket(path, underlying_err), partial: ... }` instead of
    the soft-fallback to `Uuid::nil()` that let stale sockets through.

  R2#2 (BLOCKING) — implicit `non-persona-use` fallback
    fix: explicit `--mode=<full-citizen|inference-only|fail-fast>`
    flag (default `FullCitizen`). The operator states intent; the
    substrate doesn't guess from `personas/` directory contents.
    `--mode=inference-only` is what an operator running a forge
    worker / CI runner / model eval harness explicitly opts into.

  R2#3 + R3#3 (BLOCKING) — hand-maintained manifest drift
    fix: `ALL_KNOWN_MODULES` = `MODULES_CORE` ∪ `MODULES_PERSONA_HOSTING`
    is a build-time invariant checked by three unit-test drift catchers
    (`all_known_modules_is_union_of_subsets`,
     `core_and_persona_hosting_are_disjoint`,
     `required_modules_returns_exactly_one_of_two_sets`). A.2.2 layers
    the full insta snapshot of `runtime.registry().module_names()` on
    top. The "we wrote a better doc-comment this time" non-fix is gone.

  R2 NIT — A2 panic filter mutes only signal
    fix: panic filter restored (suppresses misleading stderr trace
    only, catch_unwind safety net unchanged). New boot banner line
    `Boot mode: <label> (<description>)` surfaces operator's chosen
    mode immediately; voice subsystem status line lands in A.2.2.

## Compression payoff (the principle, not the bullets)

Before A.2.1, three pieces of code answered "what is the substrate's
boot state":
  - `Option<(PathBuf, RoomId)>` tuple (some = healthy, none =
    everything-else-collapsed)
  - `EXPECTED_MODULES: &[&str]` flat const (must contain everything
    that registered, period, regardless of state)
  - inline seed-presence check in `ipc/mod.rs` (heuristic guess at
    operator intent)
Each could drift against the other two. Slice A added a fourth (the
hard-fail check) without compressing — that's how it produced PR
claims contradicting its own implementation.

A.2.1 collapses them to ONE typed state (`AircDiscovery`) + ONE
explicit operator-intent flag (`BootMode`) + ONE function answering
"what's required for THIS state" (`required_modules(&discovery,
mode)`). Every consumer reaches through the same primitive. The
"different consumers disagree about what state is" failure mode
is no longer expressible.

## Architecture seam for B'

`AircDiscovery::Healthy/Degraded/Unreachable` is the first instance
of what B' generalizes: every category sidecar (renderer, voice,
inference, foundry) will have the same three-variant health shape.
The substrate-wide controls (`for each category: health()`,
PressureBroker admission, Ares the dispatcher-persona's allocation
cognition) operate uniformly across categories because each
category's state surface looks the same. A.2.1's discipline is the
foundation: the same compression that closes R1#1 here is what
makes Ares possible later.

## Test rigging (the substrate-level kind, not demos)

  - `airc::discovery_state` — 4 tests over `Healthy/Degraded/
    Unreachable` transitions + `StaleSocket` carries path AND
    underlying error
  - `runtime::boot_mode` — 12 tests over canonical/alias/case-insens
    parsing, `requires_persona_hosting`/`requires_voice` queries,
    `extract_boot_mode` against equals-form / space-form / absent /
    dangling-space argv
  - `conditional_modules_tests` — 7 tests over `required_modules`
    dispatch across `(Healthy, Degraded) × (FullCitizen,
    InferenceOnly, FailFast)`, plus three drift catchers that make
    `MODULES_CORE`/`MODULES_PERSONA_HOSTING`/`ALL_KNOWN_MODULES`
    divergence a CI failure instead of a runtime warning

  Total: 23/23 pass. Each primitive exercisable in isolation —
  no demo bin, no binary boot, no live AIRC daemon.

  A.2.2 layers cross-substrate integration tests on top
  (`StubAircCitizen` + tempdir continuum_root + `start_server` end
  to end across the mode × discovery × seed matrix), plus Context
  trait `discovery()`+`boot_mode()` accessors that prepare the
  Context object for B's category-handle uniformity.

## End-to-end verification (deferred to release build + live AIRC)

  Built artifact will be exercised against:
    - `--mode=full-citizen` + AIRC healthy → All N required modules
      registered (N depends on dispatch), Paige hosts, round-trip
      via `airc msg`
    - `--mode=full-citizen` + AIRC unreachable → exit 1 with typed
      reason from `AircDiscovery::Unreachable.reason()`
    - `--mode=full-citizen` + env-var pointed at stale socket →
      exit 1 with `AircDiscovery::Degraded { reason: StaleSocket
      { path, underlying } }`
    - `--mode=inference-only` + AIRC unreachable → boots cleanly,
      INFO line says persona hosting disabled per operator intent
    - `--mode=fail-fast` + libonnxruntime missing → exit 1

## Doctrine alignment (substantively, not as cover)

  [[no-fallbacks-ever]] — typed discovery + explicit mode eliminate
    every silent-substitution path Slice A retained
  [[every-error-is-an-opportunity-to-battle-harden]] — the three
    drift catchers ARE the rigging; the snapshot test in A.2.2 is
    its more thorough sibling
  [[substrate-is-a-good-citizen-on-the-host]] — boot banner names
    the chosen mode; degraded states have human-actionable reasons
  [[host-the-seemingly-impossible]] — the substrate publishes what
    it CAN do honestly; `--mode=full-citizen` is a promise, not
    a hope

## Out of scope (folded into A.2.2)

  - `Context` trait `discovery()` + `boot_mode()` accessors
  - PersonaContext / AgentContext / StubContext implementations
  - `tests/substrate_boot_contract.rs` integration matrix
  - `tests/ort_panic_filter.rs` with `serial_test::serial`
  - `tests/expected_modules_snapshot.rs` insta snapshot of
    `runtime.registry().module_names()` against ALL_KNOWN_MODULES
  - Voice subsystem `🔊 ready / 🔇 unavailable` status line
  - `runtime/mode/get` + `runtime/mode/set` commands — expose
    `BootMode` via the universal Commands surface so widgets,
    personas, and remote continuums can all read/change runtime
    mode through the same primitive (no separate widget API,
    no operator-only escape hatch). Same for `runtime/discovery/get`
    returning the typed `AircDiscovery`. The compression principle
    extends to the control surface: one typed value, one command
    pair, every actor uses the same primitive.

## Out of scope (folded into B')

  - Generalization of `AircDiscovery`'s three-variant pattern to
    `RendererHealth` / `InferenceHealth` / `VoiceHealth`
  - `CategorySidecar` trait + uniform `health()`/`allocate_lane()`
  - Sidecar process pattern (renderer/voice/inference/foundry as
    separate binaries)
  - Ares-the-persona dispatcher (the master control citizen)

card: 4075b9a4-7251-4405-84e5-9033e2213dff
supersedes: PR #1527 (closed via R1+R2+R3 convergent BLOCK)
follow-up: A.2.2 (Context + integration tests), B' (CategorySidecar)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(boot): close convergent BLOCK on PR #1528 — Uuid::nil + manifest compression + load-bearing seam coverage

Addresses the three converging adversarial reviewers' BLOCK on the
first A.2.1 attempt. Each reviewer independently caught the same
silent-fallback pattern shifted one frame deeper from where Slice A
had it; this commit closes it structurally + adds the test rigging
that locks the closure in.

## R2#1 — `AircModule::from_discovery(Degraded)` Uuid::nil bug

Reviewers #1 (correctness) and #2 (doctrine) both BLOCKED on the
`Degraded` arm reading `partial.peer_id.unwrap_or_else(Uuid::nil)`
and constructing a real `DaemonAircEventTransport` against the
discovered (potentially stale) socket. In `--mode=inference-only`,
the mode gate did not trip, so the module registered, every
realtime publish either ECONNREFUSED'd against the stale socket OR
went out attributed to `Uuid::nil` — exactly the silent-fallback
pattern [[no-fallbacks-ever]] forbids, just shifted one frame from
Slice A's location.

**Fix:** `Degraded` AND `Unreachable` now both collapse to
`with_queue_client(...)`. The `partial` field on the enum is
retained for operator observability (what we DID resolve before
discovery declared not-Healthy), but the module construction does
not pretend. Five new tests in `from_discovery_tests` pin every
branch + the cross-variant invariant `only_healthy_exposes_a_
daemon_socket`. R2#1 is now structurally impossible.

## R2#3 + R3#3 — compression principle violation in manifest

Reviewers #2 and #3 converged on the same point: three constants
(`MODULES_CORE`, `MODULES_PERSONA_HOSTING`, `ALL_KNOWN_MODULES`)
with three drift-catcher tests verifying they agree with each
other. That violates the compression principle Joel emphasized
throughout the session — one logical decision, one place. The
drift catchers don't catch the actual regression class (developer
adds `runtime.register(NewModule)` in `ipc/mod.rs` without
updating a constant).

**Fix:** ONE `MODULES: &[(&str, ModuleCategory)]` list with each
row tagged by category. `required_modules(&discovery, mode)`
filters this list; `all_known_modules()` projects names from it.
No parallel list to drift against. The drift catchers reduce to
correctness tests over the projection (`required_modules_size_
matches_category_dispatch`, `every_core_module_appears_in_every_
required_set`, `persona_hosting_modules_appear_only_when_
dispatched` — the last is the R1#1 structural lock).

The R3#3 "developer adds register without updating manifest"
class still requires the A.2.2 insta snapshot that boots the
runtime and asserts `registry().module_names()` == projection of
`MODULES`. That deferral is honest; the categorized list is the
foundation the snapshot will anchor to.

## Purity — `discover_and_construct` deleted, not deprecated

Per Joel's "purity is always worth it" doctrine, the legacy
`AircModule::discover_and_construct` method is removed entirely.
It carried the same `Uuid::nil()` soft-fallback at line 158 that
R2#1 was about. With `from_discovery()` covering every production
case, the legacy method's only value was easing migration — and
"ease of migration" is not worth retaining a code path that
encodes the bug pattern this slice exists to close.

Same standard for the unused-import cleanup that followed: no
deprecated shims, no commented-out `legacy //` blocks, no
re-exports for "back-compat." If a future caller wants the old
shape, they read the git history and write it again with the
typed primitives.

## ORT panic filter — held for A.2.2 per reviewer #2

Reviewer #2's BLOCKING #3: shipping `install_ort_panic_filter`
WITHOUT the paired `🔊/🔇 Voice subsystem: <state>` boot banner
line leaves the default-mode (`FullCitizen`) operator with zero
signal that voice is unavailable. The filter mutes the only
signal; the indicator hasn't landed yet.

**Fix:** removed the panic filter entirely from A.2.1. Both the
filter AND the indicator (with its `libloading::Library::new(
"libonnxruntime.dylib")` dlopen probe) land together in A.2.2.
This is what reviewer #2 explicitly recommended as the doctrinally
honest sequencing: "ship them together OR hold both."

## Test coverage added

- `from_discovery_tests` (5 tests):
  - healthy_produces_fully_configured_module
  - degraded_with_partial_socket_collapses_to_queue_only (R2#1)
  - degraded_with_full_partial_state_still_collapses_to_queue_only
  - unreachable_collapses_to_queue_only
  - only_healthy_exposes_a_daemon_socket (cross-variant invariant)

- `discovery_failure_mapping_tests` (10 tests):
  - All 8 `DiscoveryError → DiscoveryFailure` variant projections
  - stale_socket_from_status_err carries path + underlying
  - stale_socket_handles_non_status_errors

- `conditional_modules_tests` (refactored, now 9 tests for the
  new MODULES shape):
  - full_citizen_healthy_requires_persona_hosting_modules (R1)
  - inference_only_does_not_require_persona_hosting_modules (R1)
  - fail_fast_healthy_requires_persona_hosting_modules
  - full_citizen_degraded_uses_core_only_set
  - modules_list_has_unique_names (drift catcher)
  - required_modules_size_matches_category_dispatch
  - all_known_modules_derives_from_modules
  - every_core_module_appears_in_every_required_set
  - persona_hosting_modules_appear_only_when_dispatched
    (the structural R1#1 lock)

- `discovery_state::tests` (unchanged, 4 tests)
- `boot_mode::tests` (unchanged, 12 tests)

**Total: 40/40 pass.** Cargo check clean.

## Reviewer findings status

| Finding | Reviewer | Status |
|---|---|---|
| R1#1 — `EXPECTED_MODULES` contradicts inference-only | R1 | CLOSED via categorized MODULES + `persona_hosting_modules_appear_only_when_dispatched` test |
| R2#1 — env-var stale socket bypass | R2 | CLOSED structurally via `Degraded → queue-only` collapse + 5 from_discovery tests |
| R2#2 — implicit non-persona-use fallback | R2 | (already closed in original A.2.1 via explicit `--mode`) |
| R2#3 / R3#3 — manifest drift | R2,R3 | PARTIALLY CLOSED via categorized MODULES; full closure (registration-site snapshot) deferred to A.2.2 with honest scope |
| R3#1 — start_server integration | R3 | Deferred to A.2.2 (StubAircCitizen infra) |
| R3#2 — panic filter test | R3 | Filter held for A.2.2 per R2 #3 |
| R3#5 — substrate boot smoke | R3 | Deferred to A.2.2 |
| R2 NIT — voice indicator | R2 | Held for A.2.2 (ships with panic filter + dlopen probe together) |

## Doctrine alignment

- [[no-fallbacks-ever]] — structurally, not just doctrinally:
  Degraded cannot produce a daemon transport at all
- [[compression-principle]] — one MODULES list, no parallel
  constants
- [[every-error-is-an-opportunity-to-battle-harden]] — 15 new
  tests pin the load-bearing seams the convergent review named
- [[substrate-is-a-good-citizen-on-the-host]] — refusing to ship
  the panic filter without the indicator pair
- Joel's "purity is always worth it" — discover_and_construct
  deleted, not deprecated

card: 4075b9a4-7251-4405-84e5-9033e2213dff (A.2.1)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…trate (#1439)

* docs(grid): GRID-BUS-ARCHITECTURE — airc as universal bus + grid substrate

Architectural spec for how Continuum uses airc as the universal
event/command bus + cross-grid coordination substrate. Replaces the
in-flight patch architecture (continuum-airc-bridge.mjs shell-out,
modules/airc.rs bespoke IPC, persona/airc_admission.rs protocol-named
converter, dual-write PR stack #1432/1433/1435/1436/1437) — those land
back as straight-line consumers or get deleted per §5.

Three core claims:

1. airc is the durable event log + the inter-grid transport.
   ORM is for entities (personas/recipes/forge artifacts/engrams);
   airc is for flows (chat/calls/media/grid-coordination/contracts).
   Chat+RAG using ORM was the killing-the-system problem the
   migration exists to fix.

2. Continuum's existing universal primitives (Commands.execute,
   Events.subscribe/emit per docs/UNIVERSAL-PRIMITIVES.md) extend
   onto airc with one piece of metadata per class: naturalScope
   for Commands, broadcast for Events. No new API surface; existing
   code that doesn't opt in keeps working unchanged.

3. Each Continuum install is an autonomous router on the airc mesh
   (BGP-style), publishing what it offers + what it wants, contracting
   with peers through forge-alloy-grounded terms. No central scheduler.
   Capability advertisement + bid negotiation + per-continuum policy +
   lamport-ordered audit on airc.

Spec covers:
  §1 The cut: airc vs ORM (with historical context)
  §2 Bus extension: naturalScope + broadcast metadata
  §3 Continuum-as-AS: BGP framing for the grid
  §4 Two-sided market: offer/want, forge alloy as contract substrate
  §5 Migration: deletion list, 11-step phased sequence, breakage surface
  §6 Six deliverables for lane assignment
  §7 Per-continuum policy as first-class config
  §8 Ten open questions for reviewers
  §9 Coordination + cross-doc references
  §10 What's explicitly out of scope

For codex (airc substrate, rust-rewrite) + claude-tab-1 (Lane C2,
airc-adapter) + Joel review. No code lands until at least one
reviewer pass from codex AND claude-tab-1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(grid): append honest finding on alloy generalization gap

Existing alloy schema is model-bound (AlloySource.base_model, BenchmarkDef.n_shot, ForgeArtifact.forged_params_b etc). §4.2's 'alloy IS universal contract' claim requires generalization — added Path A (in-place artifact_kind discriminator) vs Path B (ContractArtifact parent + ForgeAlloy subtype) as open question 11. Flagging honestly rather than pretending the existing types already do this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(grid): correct appendix — alloy is already universal, Path A/B was reinvention

Joel correctly pointed out I read the drifted Continuum-side Rust types and proposed Path A/B for 'generalizing the alloy schema' — which ignored the canonical FORGE-ALLOY-DOMAIN-EXTENSIBILITY.md doc that already designs the answer (6-work-item refactor, ~4 hours scoped, bit-equivalent regression test on every shipped artifact). The trust+contract layer is also already designed in docs/grid/FORGE-ALLOY-PROOF-CONTRACTS.md.

Corrected appendix:
  - Names what forge-alloy ACTUALLY is per the canonical docs (universal Merkle-chain-of-custody for any data transformation; Type Byte enumeration: 0x01 model forging, 0x05 delivery, 0x06 evaluation, 0xFF custom)
  - References FORGE-ALLOY-DOMAIN-EXTENSIBILITY.md for the prerequisite refactor
  - References FORGE-ALLOY-PROOF-CONTRACTS.md for the proof-contract object shape
  - Open question 11 corrected: not Path A/B (both reinventions), but the prerequisite sequence (Domain Extensibility refactor lands first → contract substrate ready → §5.2 deliverable 6 wires on top)
  - Lesson logged: read canonical intent docs (docs/architecture/, docs/grid/) before designing on top of drifted implementation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(planning): HEADLESS-PERSONA-HOST-LOOP — slice 13 design

Captures the boot wire-up plan for #133 slice 13 before touching
the load-bearing `ipc::start_server` flow.

Documents:
- The "moment-of-truth" gap: composition pieces from slices 5-12
exist; nothing in continuum-core actually calls them at boot.
- Before/after of the IPC boot loop (~ipc/mod.rs:1024-1089).
- The cleanup model verified during PR #1508's slice-12 review:
.abort() → JoinHandle drop → conversation drop → EventStream drop
→ broadcast::Receiver drop auto-decrements subscriber count. Wire
subscriber is ref-counted across local subscribers; tears down
when runtime Arc reaches 0. No leak; no manual unsubscribe.
- Five open questions with recommendations:
  1. Arc<Registry> for build_profile — add model_registry::global_arc()
  2. HwCapabilityTier source — HostCapabilityProbe::detect_at_boot()
  3. hosted_handles ownership — new PersonaSupervisor module
  4. ResumeOrMintProvider role-mapping — slice 14 territory
  5. BootSummary event for per-slot failures
- Test plan: PersonaBootstrapper trait split for stubbing.
- Explicit non-goals (shared-base / cross-grid / LoRA / role-aware).
- Doctrine memories worth refreshing on implementation.

Net-additive doc — no code changes. Slice 13 implementation lands
as a follow-up PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(planning): rev HEADLESS-PERSONA-HOST-LOOP per PR #1510 review

PR #1510's adversarial reviewer caught 9 findings, 3 blocking.
Substantial revision addresses each:

Blockers:
- Finding #1 (cleanup model wrong): rewritten "Cleanup model" section.
  `.abort()` alone is INSUFFICIENT — wire subscriber stays in
  Arc<Airc>.inner.subscribers until registry-remove drops the Arc.
  Supervisor shutdown path MUST: abort → await → registry.remove.
- Finding #2 (position-pairing broken): elevated to hard prereq P2.
  scan_personas_dir sorts alphabetically; boot 2 yields persona
  order != plan order on random-derived names. Slice 13 ships with
  plan.len() <= 1 (Helper only); slice 14 lands role-in-seed.json
  + RoleAwareProvider before re-enabling Coder.
- Finding #3 (after-code reimplements existing fns): "after" rewrite
  uses bootstrap_planned (slice 8) + materialize_adapters (slice 9)
  as composition, not open-coded copies. Net-new code ~25 lines, not
  ~50.

Major:
- Finding #4 (PersonaSupervisor dup with PersonaAircRuntimeRegistry):
  Q3 revised to EXTEND the registry — HostedPersonaRuntime owns both
  airc_runtime + service_loop JoinHandle. registry.remove becomes
  the natural shutdown path. One keyspace, one cleanup chain.
- Finding #5 (no Runtime::shutdown caller): elevated to hard prereq
  P1. Slice 13 wires tokio::signal::ctrl_c -> runtime.shutdown.
- Finding #6 (no broker admission for N adapter spawns): elevated to
  hard prereq P3. ResourceBroker.acquire before each
  factory.build_adapter.

Moderate:
- Finding #7 (detect_host_capability already exists): Q2 restated to
  call the existing free function at host_capability_probe.rs:87.
- Finding #8 (global_arc() cost inverted): Q1 recommendation flipped
  to (B) — refactor bootstrap_planned to take &Registry. Singleton
  storage migration would have touched every callsite of global().

Minor:
- Finding #9 (BootSummary venue): Q5 specifies
  MessageBus::publish("persona:boot:summary", ...) with operator
  scraping via events/recent; no declared subscribers in slice 13.
- Finding #10 (hot-reload undefined): added as Q6, declared
  out-of-scope.
- Finding #11 (wire-subscription failure mid-boot): added as Q7,
  supervisor polls JoinHandle::is_finished every 5s.

Plus added implementation checklist at end so slice 13 PR has a
sign-off surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(planning): rev2 HEADLESS-PERSONA-HOST-LOOP — match shipped impl + correct cleanup model

PR #1510 re-review (after the first revision) caught:

1. **Cleanup model still wrong** — claimed `ensure_wire_subscriber`
   / `inner.subscribers` (was the 428f928 worktree I was reading,
   not the pinned f6ed190). Real mechanism in f6ed190 is
   `EventStream::EventStreamInner::Daemon` holding
   `DaemonAttachGuard` (`stream.rs:25-68`). On `EventStream` drop,
   guard drops, per-channel attach `JoinHandle`s abort.
   **`.abort()` ALONE is sufficient** against the pinned rev. The
   `shutdown_slot` registry-remove is for in-substrate Arc state
   hygiene, not for daemon-side teardown. Section rewritten with
   the correct mechanism, file:line cited.

2. **"Hard prerequisites" misrepresented impl scope** — listed
   P1 (ctrl_c→shutdown), P3 (broker), Q5 (BootSummary publish),
   Q7 (5s poller) as "MUST land" but the implementation explicitly
   deferred all four. Restructured to "Slice 13 scope vs deferred
   follow-ups" with honest splits: shipped vs deferred + why each
   deferral is acceptable (single-persona LCD is in-budget;
   `shutdown_slot` available via IPC commands; cleanup model now
   shows `.abort()` is sufficient).

3. **"After" snippet didn't match impl** — used
   `BootSummary::default()` / `bus.publish` / `summary.failed.push`,
   but the impl uses `tracing::info!(hosted=N, failed=N)` counters.
   Rewrote the snippet to match what shipped. Notes Q5
   (BootSummary publish) as deferred.

Plus the implementation status section is now accurate:
- Q1, Q3, P2, Q2-partial: shipped ✅
- P1, P3, Q5, Q7, Q2-full: deferred to slice 13.5+ ❌
- Integration polish in #1511: room-name discovery + LCD model
  registry entry + PersonaContext rename + RagInspectionRequest::
  for_persona derivation site (the `&ctx` doctrine)
- Integration validation: Paige replied in continuum room

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#205) (#1544)

The three `modules::sentinel::checkpoint::tests` were writing JSON
under the developer's real `~/.continuum/sentinel/checkpoints/` —
visible as residual `test-list-*.json` files surviving across test
runs on this machine (Jun 3, Jun 5 timestamps). Two real failure
modes from this pollution:

1. On dev machines whose `~/.continuum/sentinel/` is root-owned (left
   over from any docker compose run that bind-mounted `$HOME` and
   chmod'd dirs under root), the writes fail → prepush flake.
2. Cross-test interference: `test_list_checkpoints` asserts the
   freshly-written handle appears, but pre-existing `test-*.json`
   files from other test invocations leak into that listing and
   could mask real regressions.

Fix is two-part and minimal:

- `checkpoints_dir()` reads `CONTINUUM_CHECKPOINT_DIR` first; falls
  back to the production `~/.continuum/sentinel/checkpoints/` path
  unchanged when the env var is unset. Production code path is
  unaffected.
- `tests::ensure_checkpoint_dir_isolated()` installs a per-process
  `tempfile::TempDir` into the env var (held alive by `OnceLock` so
  Drop fires at process exit). Every test calls it at entry. The
  TempDir's path is `set_var`'d per test rather than once, because
  cargo runs tests in parallel and another test in the same binary
  could legitimately clear the env var (e.g. a future test that
  validates the fallback path).

Carried forward from PR #971 (closed as too stale to rebase against
current canary). Reapplied fresh against current module path
(`src/workers/continuum-core/src/modules/sentinel/checkpoint.rs`).

Tests: 3/3 `modules::sentinel::checkpoint::tests` pass, and confirmed
post-fix that no new files appear under `~/.continuum/sentinel/`.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
#1545)

Iteration 1 of the inference-latency campaign (#195). Sprinkles
probes at the load-bearing seams the substrate's RTOS debugger
manual marked unchecked. With #196's on_close fix already on
canary, `time_sync!` / `time_probe!` spans now persist to the
JSONL sink — the timing data this commit produces is captureable
end-to-end for the first time.

## What's instrumented

**`inference/llamacpp_adapter.rs::generate_text`** — the dominant
cost on LCD tier (95%+ wall-clock per 2026-06-06 baseline). The
function was effectively a black box from the operator's POV: the
existing `runtime::logger("llamacpp")` lines describe shape but
not duration, and `tok_per_sec` was kept in a private
`RwLock<f64>` (last-throughput-only). Probes added:

- `inference.generate.enter` — request fingerprint at entry
  (model, persona_id, msg_count, max_tokens, has_system_prompt,
  parts_image, parts_audio). Pairs with `.exit` via span ancestry.
- `time_sync!("inference.render_chat", ...)` — chat-template
  rendering. Synchronous + small, but cumulative across many
  turns. Bracketing it lets the operator subtract it from
  `forward.*` cleanly.
- `time_probe!("inference.forward.text", ...)` — pure-text
  scheduler-managed path. The actual LLM decode.
- `time_probe!("inference.forward.multimodal", ...)` — mtmd path
  (image / audio). Distinct seam because it bypasses the
  scheduler and runs single-flight.
- `inference.generate.exit` — pairs with `.enter`. Carries the
  campaign's headline metric `tok_per_sec` plus duration_ms,
  tokens_out, text_len, model. A `jq` filter on
  `class == "inference.generate.exit"` is the latency dashboard
  in JSONL form.

**`persona/prompt_assembly.rs::assemble`** — the leading
indicator for "why is prefill slow." When engrams / social
signals / matched-angle grow unbounded, `system_message_len`
shadows tok/s in the timing breakdown. Probe at the function
tail carries the composition shape: system_message_len,
message_count, estimated_tokens, matched_angle_present,
engrams_count, social_signals_present, voice_mode,
multi_party_strategy.

## Doctrine alignment

Per [[jtag-probes-are-rtos-debugger]] (Joel 2026-06-06): every
probe site names the surrounding vars the way a breakpoint
inspector would show locals. Easy one-liners; the macros do the
plumbing. `class` strings follow the canonical taxonomy in
`docs/architecture/RTOS-DEBUGGER-PROBES.md` (updated in this
commit per the "When you add a probe, update this manual" rule).

Per [[no-rust-gates-around-cognition]]: probes observe, they
DO NOT decide. None of these emit changes control flow. The
existing `runtime::logger` and `last_throughput_tok_s` paths
remain untouched — probes are additive.

Per [[init-once-handle-then-lease-zero-copy-refs]]: the macros
expand to `tracing::event!` / `tracing::info_span!` calls that
inherit `tracing`'s `release_max_level_*` compile-time gates.
Zero cost when off; auditable per task #198 if a hot loop
later needs the visitor allocation reviewed.

## Manual update

`docs/architecture/RTOS-DEBUGGER-PROBES.md`:
- Added the new classes to the taxonomy (`persona.prompt.assemble`
  with full field list; `inference.generate.{enter,exit}`; the
  three new `timing` seams).
- Marked the prompt-assembly checklist item DONE.
- Marked the llamacpp-adapter checklist item DONE with the
  specific call-site list and the campaign cross-reference.

## Validation

- `cargo check --features metal,accelerate` — clean
- `cargo test --lib persona::prompt_assembly` — 12/12 pass
- `cargo test --lib inference::llamacpp` — 12/12 pass
- 24/24 green across the two affected modules

## Next iteration

Iteration 2 (separate slice): run a real continuum boot with
CONTINUUM_PROBE_FILE set, exercise the persona service loop
against the multi-persona stress fixture (#1518's baseline),
`jq` the JSONL to identify the dominant bottleneck. Optimize
THAT. Iterate. Until tok_per_sec on the LCD tier hits the
M5-class target.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…1546)

Single canonical entry per [[headless-rust-must-work-soon]] +
[[rust-is-the-core-node-is-the-shell]]: `npm start` boots the Rust
substrate directly (start-server.sh), no Node in the loop. Desktop
mode (`npm run desktop`) is the additive Node UX shell layered ON
the same core via parallel-start.sh. Contributors discover the right
entry from package.json instead of having to suss out which of five
'start*' variants in src/package.json matches their intent.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…) (#1547)

The substrate fmt layer wrote unbounded to stderr, which made
`npm start > /tmp/server.log 2>&1` and `nohup ... 2>&1` the only
options for capturing logs. At RUST_LOG=info under multi-persona
load that grew gigabytes per hour into one file — twice it has
eaten the host disk mid-session.

Per [[never-redirect-substrate-stderr]]: the substrate owns its
log persistence. Adds:

* ProbeTracingConfig.log_dir: Option<PathBuf> + CONTINUUM_LOG_DIR
  env override. from_env defaults to ~/.continuum/logs/.
* tracing_appender::rolling::Builder daily rotation, max_log_files(7)
  retention, non_blocking background writer.
* ProbeInstall.fmt_writer_guard: Option<WorkerGuard> returned to
  main.rs so the writer thread lives the process lifetime.
* Boot line confirming the rolling-log path so operators do not
  reach for shell redirection by reflex.

Fallback to stderr only when log_dir = None (test path). Refuses
to silently fall back if the configured dir cannot be created —
surfaces ProbeFileSinkError::OpenFailed per [[no-fallbacks-ever]].

ProbeInstall loses its Debug derive because WorkerGuard is not
Debug; the one test that {:?}-formatted it now matches the error
variant explicitly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ipt (#1548)

The codelytv/pr-size-labeler@v1 action is Docker-based — it builds an
alpine:3.15 image from Docker Hub at every workflow run. When Docker
Hub rate-limits or times out (regular occurrence on busy mornings),
the action fails during image build BEFORE the `with:` inputs are
processed, so `continue-on-error: true` on the step does NOT contain
the failure. The whole label-pr job goes red, which leaves a false
"PR is broken" signal across the entire PR list.

PR #1547 (rolling-log fmt-layer fix) hit this today:
  failed to resolve source metadata for docker.io/library/alpine:3.15:
  failed to do request: ... dial tcp 54.158.241.65:443: i/o timeout

Replaces the action with an inline `actions/github-script@v7` step
that computes additions+deletions from the PR payload, picks the
appropriate `size: XS/S/M/L/XL` label using the same thresholds the
codelytv config used (so historical label meanings stay consistent),
removes any stale size labels, and applies the chosen one via the
GitHub API. Pure JS on the node20 runner — no Docker, no registry
pulls, no rate limits.

`continue-on-error: true` retained on both steps as defense in
depth: if the GH API request itself fails for some other reason
(GitHub outage, secrets misconfig), the label-pr job still doesn't
poison the PR view.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…irst layout (Stages 1–2.5)

Bundle of the RTOS slice + the full layout sweep (move-first per Joel's direction). 3/3 adversarial reviewer APPROVE. cargo check + tests green. TS-side intentionally broken per [[rust-is-the-core-node-is-the-shell]] + tasks #143/#215.
…bstrate REAL parse+reply roundtrip

3/3 adversarial reviewer APPROVE (R1: 85, R2: 88, R3: 95 after fix). Test uses substrate's real CommandRequestHandler::parse_envelope + send_reply per [[agent-review-as-acceptable-approval]] doctrine. Closes tasks #187 + #188.
…o-end (task #143 slice 1)

2/2 adversarial reviewer APPROVE (R1: 82 CLI correctness, R2: 84 precedent). Follow-up items logged: current_thread runtime, execute timeout, generic-over-Transport. Closes task #143 slice 1.
…n the CLI (task #143 slice 2 + #216 partial)

Two follow-ups in one tight commit:

  - `ctm generate --prompt "..."` dispatches `ai/generate` at the substrate
    and prints the response text. If the substrate has an
    AircRemoteInferenceAdapter registered (PR #1560), the inference
    transparently runs on the remote peer — the CLI doesn't know or care.
    Operator perspective: `ctm generate --prompt "explain RTOS"` from a
    crap Intel Mac shells the work to the 5090.

  - `#[tokio::main(flavor = "current_thread")]` per task #216 (R1
    follow-up from PR #1559 review). One-shot CLI shouldn't spin N
    worker threads for a single round-trip.

Subcommand shape:

  ctm generate --prompt <PROMPT> [--model <MODEL>] [--json]
    --prompt "say hi"           → just the response text + footer line
                                  with model/provider/total_tokens
    --prompt "..." --json       → full JSON response (for piping)
    --prompt "..." --model X    → dispatch at specific model

Wire: builds minimal TextGenerationRequest JSON inline (no continuum-core
dev-dep needed; only the wire shape):

  {
    "messages": [{ "role": "user",
                   "content": { "type": "text", "text": <prompt> } }],
    "model": <optional>
  }

Substrate's `ai/generate` handler parses this via TextGenerationRequest
deserialize, runs through the AdapterRegistry, returns TextGenerationResponse
serialized as JSON. CLI extracts `text` field (or pretty-prints whole
response with --json).

Verified:
  cargo build -p continuum-cli           → clean (3.9s)
  ctm generate --help                    → prints usage cleanly
  ctm generate --prompt "test"           → typed error: "--peer is required"
                                            (haven't connected to a running
                                            substrate; that's an operator-side test)

Task #216 follow-ups still pending (logged separately):
  - execute() timeout wrapping (operator hang protection)
  - generic-over-Transport so test fixtures can swap in

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…llback + README + regression test

PR #1561 round 1 reviewers came back:
  R1 BLOCK (98 conf) — load-bearing wire bug
  R2 LGTM-WITH-NOTES (82 conf) — no-fallback violations + README stale

R1 verdict identified that EVERY `ctm generate` invocation would fail
at the substrate because the CLI built the content field as an
object:

   "content": { "type": "text", "text": prompt }

But ChatMessage.content is `MessageContent`, defined as:

   #[serde(untagged)]
   enum MessageContent {
       Text(String),
       Parts(Vec<ContentPart>),
   }

An OBJECT matches NEITHER variant — Text wants a string, Parts wants
an array. Substrate-side parse path (`ai_provider.rs:597`) would
have rejected the envelope at `serde_json::from_value::<Vec<ChatMessage>>`
with the unhelpful error: "data did not match any variant of untagged
enum MessageContent". The original commit message claimed "Verified"
but the verification stopped at the `--peer required` runtime check —
never exercised the round-trip.

Fix shape (R1 Option B): pass the prompt as a plain string. Matches
the untagged `Text(String)` variant, which is what the substrate's
own legacy `prompt`-param path produces — exercised end-to-end:

   "content": prompt,

R2 no-fallback fix: replace defensive `unwrap_or` substitutes with
typed errors. Every field the CLI was defending against
(`text`, `model`, `provider`, `usage.totalTokens`) is REQUIRED on
TextGenerationResponse — non-Option in the substrate's typed
definition. Per [[no-fallbacks-ever]]: surface a substrate-side
contract violation as a named error, not as a silent placeholder
string masquerading as model output. The "<no text field in
response>" fallback was the literal worst case — it would print as
if it were the LLM's actual answer.

R1 regression test: new test file
core/continuum-core/tests/cli_wire_contract.rs with three tests
that pin the CLI's exact JSON shape against TextGenerationRequest's
serde decoder:

  1. cli_generate_params_decode_as_text_generation_request — pins
     the prompt-as-string + role=user shape
  2. cli_generate_params_with_model_pins_model_field — pins the
     --model pass-through
  3. cli_generate_object_content_shape_would_panic — NEGATIVE test
     pinning the bug R1 caught. If MessageContent ever gains an
     object-shaped variant, this test fires and forces a
     coordinated wire migration.

The test mirrors the CLI's exact `serde_json::json!` invocation so
drift between the CLI and the substrate type is caught at `cargo
test -p continuum-core` BEFORE an operator types `ctm generate`.

R2 README fix: apps/cli/README.md now lists `generate` in the
"Commands today" table + usage examples + status updated from "One
subcommand" to "Two subcommands". Discoverability gap closed.

Verified:
  cargo build  -p continuum-cli                                                  -> clean (3.64s)
  cargo test   -p continuum-core --features metal,accelerate
    --test cli_wire_contract                                                     -> 3/3 passed (0.00s)

Net diff:
  apps/cli/src/main.rs:                   -7 (object content + 3 unwrap_or fallbacks)
                                          +21 (string content + 4 typed errors + comment)
  apps/cli/README.md:                     -3 / +10 (commands table + status + examples)
  core/continuum-core/tests/cli_wire_contract.rs (NEW):  +112 (3 wire-contract tests)

The fix is small. The catch (wire bug masquerading as merge-ready
PR) is the lesson — verification that stops at the CLI's argument
parser is verification that never crossed the wire seam.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@joelteply
joelteply merged commit dd976df into main Jun 9, 2026
4 of 10 checks passed
@joelteply
joelteply deleted the feat/ctm-ai-generate branch June 9, 2026 02:07
joelteply added a commit that referenced this pull request Jun 9, 2026
…reakage from layout sweep (#1562)

The layout sweep (PR #1557, task #214) moved `generate-config.ts`
from `src/generator/` to `tools/generator/`. Node's module resolution
walks ancestors of the SCRIPT location, not `process.cwd()`, so the
`import * as dotenv from 'dotenv'` at the top now traverses
`tools/generator/` -> `tools/` -> `/` looking for `node_modules/dotenv`
and finds nothing — `dotenv` only exists at `src/node_modules/dotenv`
(a SIBLING of `tools/`, not an ancestor).

Every TS-validation CI job that invokes
`npx tsx ../tools/generator/generate-config.ts` has been failing on
canary since the layout sweep merged:

  Error: Cannot find module 'dotenv'
  Require stack:
  - /home/runner/work/continuum/continuum/tools/generator/generate-config.ts

Surfaced via PR #1561's validate / ts-eslint-baseline-ratchet /
verify-architectures / verify-after-rebuild checks. NOT caused by
that PR's diff — pre-existing canary infra regression that has been
silently breaking every TS PR check since the layout sweep landed.

Fix: replace `dotenv.parse()` with a 15-line inline `parseEnvText`.
The script only ever called `.parse()` (the pure string-to-KV
transform), never the `.config()` side-effect path that mutates
process.env. Inline parser handles the same shape (KEY=value,
optional surrounding quotes, # comments, blank lines). Like-for-like
behavioral replacement at zero dep cost.

Doctrinal bonus: generator scripts now have a node-stdlib-only
footprint, matching `generate-version.ts`'s shape. No upward-walk
module-resolution surprises possible. Aligns with task #209
("npm start IS the headless Rust binary, period") — fewer Node deps
in the build path is unambiguously good.

Verified locally:
  cd src && npx tsx ../tools/generator/generate-config.ts
  -> "shared/config.ts unchanged" (idempotent on re-run)
  -> HTTP_PORT/WS_PORT defaults pick up correctly
  -> ACTIVE_EXAMPLE resolved from main package.json

Net diff: -1 import + 6 lines removed, +33 lines added (parseEnvText
+ doc block explaining WHY this exists). No behavior change on the
happy path.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
joelteply added a commit that referenced this pull request Jun 9, 2026
…rid-smoke` v1 + wire-contract test

Earlier today PR #1561 (`ctm generate` + the wire-contract regression
test) was retargeted from `main` to `canary` via `gh pr edit`, but
the retarget didn't take effect on merge — the squash ended up on
`main`. Canary's `apps/cli/src/main.rs` only had `Metrics`.

A direct cherry-pick of the squash commit was infeasible (the
feat/ctm-ai-generate branch was based on an old main, so the squash
diff carried 2386 files / 182k+ insertions of main↔canary
divergence). Reconciliation by re-applying the relevant files
directly from `origin/main`:

  - apps/cli/src/main.rs:                    full version from main
    (includes `Generate` subcommand, current_thread runtime, AircIpcTransport
    direct import) PLUS the grid-smoke wiring from this PR.
  - apps/cli/README.md:                      main's two-row table
    extended with the grid-smoke row.
  - core/continuum-core/tests/cli_wire_contract.rs:  3-test wire-contract
    regression file (the negative test pinning PR #1561 R1's "object
    content shape would fail to decode as MessageContent" bug).
  - apps/cli/src/grid_smoke.rs:              the grid-smoke module
    landing on this PR (the original v1 from the previous commit on
    this branch; unchanged).

Net effect on canary, vs PRE-reconciliation canary HEAD:
  - `ctm generate` works (PR #1561's CLI work back where it should be)
  - `ctm grid-smoke` v1 lands (this PR's new feature)
  - `cli_wire_contract.rs` regression test pins the wire shape (R1 follow-up
    from PR #1561)

Verified:
  cargo build  -p continuum-cli                                       -> clean (2.33s)
  cargo test   -p continuum-core --features metal,accelerate
    --test cli_wire_contract                                          -> 3/3 passed (0.00s)
  ctm --help shows all three subcommands.

## Process note

Joel called this out: I'd been moving too fast and let `main` drift
ahead of `canary`. Going forward I'll verify the merge-target after
every squash merge — `gh pr view <num> --json mergeCommit,baseRefName`
catches retarget-didn't-stick before the next PR is built on a stale
canary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
joelteply added a commit that referenced this pull request Jun 9, 2026
…rid-smoke` v1 + wire-contract test (#1564)

Earlier today PR #1561 (`ctm generate` + the wire-contract regression
test) was retargeted from `main` to `canary` via `gh pr edit`, but
the retarget didn't take effect on merge — the squash ended up on
`main`. Canary's `apps/cli/src/main.rs` only had `Metrics`.

A direct cherry-pick of the squash commit was infeasible (the
feat/ctm-ai-generate branch was based on an old main, so the squash
diff carried 2386 files / 182k+ insertions of main↔canary
divergence). Reconciliation by re-applying the relevant files
directly from `origin/main`:

  - apps/cli/src/main.rs:                    full version from main
    (includes `Generate` subcommand, current_thread runtime, AircIpcTransport
    direct import) PLUS the grid-smoke wiring from this PR.
  - apps/cli/README.md:                      main's two-row table
    extended with the grid-smoke row.
  - core/continuum-core/tests/cli_wire_contract.rs:  3-test wire-contract
    regression file (the negative test pinning PR #1561 R1's "object
    content shape would fail to decode as MessageContent" bug).
  - apps/cli/src/grid_smoke.rs:              the grid-smoke module
    landing on this PR (the original v1 from the previous commit on
    this branch; unchanged).

Net effect on canary, vs PRE-reconciliation canary HEAD:
  - `ctm generate` works (PR #1561's CLI work back where it should be)
  - `ctm grid-smoke` v1 lands (this PR's new feature)
  - `cli_wire_contract.rs` regression test pins the wire shape (R1 follow-up
    from PR #1561)

Verified:
  cargo build  -p continuum-cli                                       -> clean (2.33s)
  cargo test   -p continuum-core --features metal,accelerate
    --test cli_wire_contract                                          -> 3/3 passed (0.00s)
  ctm --help shows all three subcommands.

## Process note

Joel called this out: I'd been moving too fast and let `main` drift
ahead of `canary`. Going forward I'll verify the merge-target after
every squash merge — `gh pr view <num> --json mergeCommit,baseRefName`
catches retarget-didn't-stick before the next PR is built on a stale
canary.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
joelteply added a commit that referenced this pull request Jun 9, 2026
…ode + docs

Joel called this out: is 5090 coded into the repo? Yes — GPU-specific
narrative and operator-personal labels leaked into substrate source,
test fixtures, and doc-strings across today's PRs (#1560, #1561,
#1563, #1564). None were load-bearing, but the repo should be
hardware-agnostic.

Replacements (one PR, surgical edits):

  core/continuum-core/src/inference/airc_remote/adapter.rs
    - doc: "route to Joel's 5090" -> generic remote-inference-peer description
    - test fixture: "joels-5090" -> "test-remote-peer"

  core/continuum-core/src/inference/airc_remote/transport.rs
    - test fixture: "joels-5090" (2 sites) -> "test-remote-peer"

  core/continuum-core/tests/airc_remote_inference_roundtrip.rs
    - doc: "airc://<rtx5090>/ai/generate" -> "airc://<remote-peer>/ai/generate"
    - peer labels generic: "remote inference host" / "local caller"
    - canned response: "pong from the remote peer" / "test-model" /
      "test-remote-llamacpp"

  apps/cli/src/main.rs
    - Generate doc: "(e.g., the operator's 5090)" -> generic GPU-rich grid host

  apps/cli/src/grid_smoke.rs
    - module doc + ai/generate row comment: "constrained-locally host
      dispatches at a GPU-rich peer" / "If the target is a GPU host
      running a real LLM"

Out of scope:
  - Older codebase doctrine attributions ("Joel's never-swallow-errors")
    stay — those name doctrine origin, fine.
  - Task #85 mentioning 5090 stays — it's a real airc bug ticket about
    that hardware.

Verified:
  grep -rn "5090|joels-" on touched files -> zero hits
  cargo check -p continuum-cli            -> clean (1.94s)

The pattern lesson: any hardware-specific identity is narrative
scaffolding, not substrate truth. The substrate is hardware-agnostic;
tests use neutral labels; docs describe categories, not specific units.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
joelteply added a commit that referenced this pull request Jun 9, 2026
…ode + docs (#1565)

Joel called this out: is 5090 coded into the repo? Yes — GPU-specific
narrative and operator-personal labels leaked into substrate source,
test fixtures, and doc-strings across today's PRs (#1560, #1561,
#1563, #1564). None were load-bearing, but the repo should be
hardware-agnostic.

Replacements (one PR, surgical edits):

  core/continuum-core/src/inference/airc_remote/adapter.rs
    - doc: "route to Joel's 5090" -> generic remote-inference-peer description
    - test fixture: "joels-5090" -> "test-remote-peer"

  core/continuum-core/src/inference/airc_remote/transport.rs
    - test fixture: "joels-5090" (2 sites) -> "test-remote-peer"

  core/continuum-core/tests/airc_remote_inference_roundtrip.rs
    - doc: "airc://<rtx5090>/ai/generate" -> "airc://<remote-peer>/ai/generate"
    - peer labels generic: "remote inference host" / "local caller"
    - canned response: "pong from the remote peer" / "test-model" /
      "test-remote-llamacpp"

  apps/cli/src/main.rs
    - Generate doc: "(e.g., the operator's 5090)" -> generic GPU-rich grid host

  apps/cli/src/grid_smoke.rs
    - module doc + ai/generate row comment: "constrained-locally host
      dispatches at a GPU-rich peer" / "If the target is a GPU host
      running a real LLM"

Out of scope:
  - Older codebase doctrine attributions ("Joel's never-swallow-errors")
    stay — those name doctrine origin, fine.
  - Task #85 mentioning 5090 stays — it's a real airc bug ticket about
    that hardware.

Verified:
  grep -rn "5090|joels-" on touched files -> zero hits
  cargo check -p continuum-cli            -> clean (1.94s)

The pattern lesson: any hardware-specific identity is narrative
scaffolding, not substrate truth. The substrate is hardware-agnostic;
tests use neutral labels; docs describe categories, not specific units.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant