Skip to content

Phase 2.5 (CLI Consolidation) complete — 2.5.I/2.5.J close-out + Batch A–E → main#69

Merged
cemililik merged 50 commits into
mainfrom
development
Jul 8, 2026
Merged

Phase 2.5 (CLI Consolidation) complete — 2.5.I/2.5.J close-out + Batch A–E → main#69
cemililik merged 50 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Phase 2.5 (CLI Consolidation) → main — release

This brings Phase 2.5 complete from development to main (152 commits). The Phase-2.5 spine + experience + observability workstreams (2.5.A–H) landed earlier on development via their own feature PRs (#60#67); this PR additionally contains the 2.5 close-out completed in this session — the consolidation lanes 2.5.I / 2.5.J and the doable-now Batch A–E backlog — which reaches milestone M2.5-4 and declares Phase 2.5 complete.

The close-out followed a strict per-step cadence: implement → Opus review → fix → Sonnet review → fix, with a dedicated security-review pass before the review rounds on the security-touching steps. Every step's toolchain gates were green before commit.

Close-out contents (this session)

2.5.I — regression harness + DB concurrency hardening

  • loadFull wrapped in a single read transaction (torn-read guard).
  • Every multi-statement write txn is BEGIN IMMEDIATE with a bounded, deterministic (no-jitter, per ADR-0040) SQLITE_BUSY/SQLITE_LOCKED retry (withBusyRetry, over the pre-existing WAL + busy_timeout). ADR-0064 §5 records the amendment.
  • New e2es: concurrent chat+run (two-connection coexistence + two-process contention with a READY handshake), Home → chat → resume → export cassette-chain, and query-shape perf budgets (EXPLAIN QUERY PLAN: index-served, no filesort) + the 80×24 degrade assertion. An advisory Windows CI lane runs the DB + harness suites + a headless no-TTY smoke.

2.5.J — docs-debt + color

  • Corrected the "encrypted history" wording to the accurate CLI posture (unencrypted; 0700/0600 + keychain, no credentials at rest — ADR-0050) across uvp/vision/tutorial + the README spine.
  • --color/--no-color > NO_COLOR (any non-empty ⇒ off) > FORCE_COLOR (0/false ⇒ off, supports-color convention) > default-on, orthogonal to the --json/CI output mode, with a full test matrix.

Batch A–E (doable-now backlog)

  • A test-hardening: media-gc flake fix + a 2.5.A git_status union pin.
  • B 2.5.H TUI polish: a persistent Esc to stop abort hint during streaming + a line-bounded reasoning panel (last-N rendered rows, not raw chars).
  • C fix(core): AgentParseError line/col diagnostics (the session {{ctx.*}} interpolation half deferred — governed by the Proposed ADR-0060, Phase-2.6).
  • D the ADR-0057 approval/security batch (each its own diff + a security-review pass), behind an append-only ADR-0057 amendment note:
    • [c] reject-with-typed-reason at the approval prompt (both surfaces; sanitized + length-bounded; the fail-closed floor unchanged);
    • the non-TTY fail-closed approval policy consolidated to one nonInteractiveApprovalPrompt;
    • SCOPE-denial conversational recovery (a denial-taxonomy change: only the fs pure scope-tier escape + media_scope_denied become recoverable on the recoverToolFailures surfaces; user/guardrail/SSRF/confidentiality denials stay fatal);
    • the view-only Ctrl+T reasoning toggle whitelisted through the approval key-swallow;
    • the Trojan-Source bidi floor (strip U+202A–202E / U+2066–2069 / LRM/RLM/ALM at the shared display boundary; ZWJ/ZWNJ preserved).
  • E the profile-aware advertise-filter + the in-house, dependency-free, ReDoS-safe .gitignore / .relaviumignore matcher for @-mention.

Deferred (tracked in deferred-tasks.md)

  • The two gate.ts cross-process-resume items — relavium budget resume and secret re-provide on gate resume — are deferred to a focused follow-up (both refactor the same security-sensitive resume path; Post-M0: comprehensive-review fixes + Phase 0 completion docs #4 relaxes a fail-closed guarantee, so it's best landed together with fresh context + a security review, not at the tail of this session).
  • The session {{ctx.*}} interpolation stays with the Proposed ADR-0060 (Phase-2.6).
  • project-tier extraRoots stays blocked on its config source.

Acceptance gate (green)

pnpm turbo run lint typecheck test build (24/24 tasks; 1641 CLI + 985 core tests), pnpm -w run format:check, pnpm lint:fence-check, pnpm lint:engine-deps — all pass. The @relavium/llm seam is untouched; packages/core stays platform-free; the two ADR touches are append-only amendment notes (ADR-0057, ADR-0064 §5); no new runtime dependency.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added typed “why” capture when rejecting pending approvals, including a fail-closed path for non-interactive runs.
    • Improved color controls with --color/--no-color and documented NO_COLOR/FORCE_COLOR precedence.
    • Enhanced workspace/session continuity with safer resume/export and stronger terminal sanitization.
  • Bug Fixes
    • Hardened tool and filesystem denial handling, including clearer recoverable scope escapes and stricter confidentiality refusals.
    • Improved read-only tool hosting by filtering write actions appropriately.
    • Reduced risk of torn reads and lock contention during concurrent SQLite activity.
  • Tests
    • Added concurrency, session-chain, and query-shape performance guardrails (including an advisory Windows lane).
  • Documentation
    • Updated CLI references and architecture/security notes for the above behaviors.

cemililik and others added 30 commits July 7, 2026 18:29
2.5.H landed on main (PR #68, development→main). Add the workstream PR reference
(#67 — the 2.5.H feature PR to development, matching how 2.5.A–G cite their feature
PR; #68 is the dev→main release mechanism, like #66) to the 2.5.H status surfaces:
phase-2.5-cli-consolidation.md (top status + the 2.5.H block header + Status line),
current.md, and CLAUDE.md. Date-only; no other change.

format:check + lint:fence-check clean.

Refs: phase-2.5-cli-consolidation.md (2.5.H)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ternal fixes

- Mark 2.5.G done (PR #66) across the phase-2.5 doc, current.md, and CLAUDE.md;
  milestone M2.5-2 (Home + entry + onboarding) reached.
- Fix phase-2.5 internal inconsistencies: EA1-EA7 -> EA1-EA8, the broken
  #engine-amendments-appendix-ea1ea7 anchor -> -ea1ea8, and add EA8 -> ADR-0061
  to the amendment->ADR mapping.
- deferred-tasks: check off [chat].max_turns (RESOLVED in 2.5.G S11, PR #66);
  reword the CLI ToolHost entry to reflect the fs/process (2.5.A) + egress/os
  (2.5.E) arms are wired behind the approval floor -- only the desktop surface
  remains.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… reseat reconciliation

Opus review of b4d9619 surfaced leftover inconsistencies from 2.5.G pulling
ADR-0059 forward:

- ADR index: 0059 was still "Proposed | 2026-06-28"; its body + this reconciliation
  treat it as a shipped 2.5.G deliverable. Set to Accepted | 2026-07-06.
- phase-2.5 "Explicitly out of scope": drop "mid-session model reseat" from the
  Phase-2.6 bullet (it shipped in 2.5.G per ADR-0059, PR #66); keep session
  {{ctx.*}} interpolation (ADR-0060, still Proposed).
- phase-2.6 2.6.C: add a status note (reseat shipped early in 2.5.G) and fix the
  now-false "When ADR-0059 flips to Accepted" conditional (it is Accepted; the
  ADR-0024 amendment note is already recorded).
- deferred-tasks: tighten the desktop ToolHost phrasing ("no tool host wired yet"
  rather than "binds an empty host" — apps/desktop has no host at all yet).

Refs: docs/roadmap/phase-2.5-close-plan.md Step 1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…te + phase-2.6 reseat framing

Sonnet review verified PR #66 merged 2026-07-07 (gh api merged_at
2026-07-07T10:10:40Z), not 2026-07-06 (the -06 is the ADR-0059/0066 Accepted
date). The wrong date also self-contradicted existing "merged 2026-07-07" text
for the same PR.

- Correct every "PR #66, 2026-07-06" merge citation to 2026-07-07 across the
  phase-2.5 doc (top status, 2.5.G header + Status block, M2.5-2 row), current.md,
  CLAUDE.md, and the phase-2.6 2.6.C note. ADR-date "2026-07-06" strings
  (ADR-0059 Accepted, the ADR-0024 amendment) are left untouched — they are correct.
- phase-2.6: add a single Status-block note so the higher-level Goal / Outcomes /
  In-scope / Milestones / Exit-criteria mentions of "mid-session model switching"
  are reconciled to "shipped early in 2.5.G" in one place (they framed a shipped
  feature as future work).
- CLAUDE.md: align terminology to "post-2.5.G model-UX follow-up" (matching the
  phase-2.5 Status block + current.md).

Refs: docs/roadmap/phase-2.5-close-plan.md Step 1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rding + README Phase-2.5 prose

2.5.J Task 1. Per ADR-0050 the CLI history.db is UNENCRYPTED at rest (guarded by
0700/0600 owner-only permissions + the OS keychain; no credentials stored); only
the desktop surface uses a SQLCipher-encrypted store.

- uvp.md / vision.md: drop the false "encrypted local history" claim; state the
  real protection (owner-only file perms + keychain).
- tutorials/cli/start-a-chat-session.md: "encrypted history.db" -> unencrypted +
  0700/0600 per ADR-0050 (linked).
- architecture/agent-sessions.md: the two surface-blind "encrypted ... SQLCipher"
  statements now carry the CLI-unencrypted vs desktop-SQLCipher split (same defect
  class the Task-1 scope missed; the phase-2.5 2.5.J task is about surface-blindness).
- README.md: add a Phase-2.5 (CLI Consolidation) prose sentence to the Status para
  (per close-plan D5 — expand prose, do NOT mint global M3.5/M3.6 IDs).

Left untouched (accurate): the CI-secret "encrypted CI secret" (GitHub Actions),
and the desktop/VS Code docs whose surface genuinely uses SQLCipher.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…QLCipher sweep

Opus review confirmed the commit was correct/complete for its named scope and
surfaced same-class pre-existing leftovers. Completing the sweep so no CLI /
cross-surface doc still over-claims CLI-side encryption:

- architecture/local-first-and-security.md: the "Run history" security-table row
  now splits CLI-unencrypted (0600/0700 + keychain) vs desktop-SQLCipher.
- tech-stack.md: the Database cell + the local-first-storage bullet now state the
  CLI store is unencrypted at rest (ADR-0050), keeping the desktop-SQLCipher fact.
- decisions/0029: append-only dated amendment note (mirrors ADR-0024) clarifying
  rule (c)'s "encrypted in history.db" is surface-specific; body unchanged.
- architecture/agent-sessions.md: tighten the keychain phrasing (keys live there;
  it does not "guard" history.db).
- deferred-tasks.md: track the VS Code docs' "SQLCipher history.db = same store as
  CLI" contradiction as a Phase-4 forward-design follow-up (blocked on the
  cross-host physical-store decision; genuinely out of scope here).

Refs: docs/roadmap/phase-2.5-close-plan.md Step 2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on + runs.db unencrypted

Sonnet review confirmed the commits accurate/complete and surfaced two refinements:

- uvp.md / vision.md: the added "owner-only file permissions" parenthetical is a
  CLI-specific control, but the sentence is about sessions "resumable on any
  surface" — attributing it across surfaces understates desktop's SQLCipher.
  Make it surface-neutral ("kept on your machine; API keys ... in the OS keychain,
  never at rest"), matching the plan's "drop 'encrypted', do not overclaim".
- reference/desktop/tauri-plugins.md: the tauri-plugin-sql row grouped runs.db with
  history.db under SQLCipher, but runs.db is intentionally unencrypted + git-committed
  (metadata only) on every surface. Scope encryption to history.db.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ransaction (2.5.I S1)

session-store.loadFull issued two independent SELECTs (session row, then messages).
A concurrent writer -- a second `relavium` process, or a `run` sharing history.db --
committing an append + a session-total update BETWEEN them yields a torn read (a
session whose totals do not match the returned transcript). Wrap the two reads in
one deferred `db.transaction` so both come from a single WAL snapshot (mirrors the
house transaction pattern; a read-only body COMMITs a no-op).

Tests (packages/db/src/session-store.test.ts): a two-connection, temp-file snapshot
suite -- WAL isolation needs a shared file, since two :memory: handles are separate
DBs. One test reproduces loadFull's read pair inside the reader's transaction with a
real committed write interleaved between the reads and asserts the snapshot is
consistent (the write is invisible; a fresh read after sees it); a control shows the
same interleave WITHOUT the transaction tears (stale session + post-write transcript).

The write-side BEGIN IMMEDIATE + SQLITE_BUSY retry this pairs with lands in Step 4
(ADR-0064 §5 amendment).

Refs: docs/roadmap/phase-2.5-close-plan.md Step 3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…loadFull + comment/teardown

Opus review confirmed the fix correct on every concurrency dimension and surfaced
that the regression test guarded SQLite/drizzle snapshot behavior but NOT loadFull's
wrapper (removing the wrapper kept both tests green).

- Add a coupling test that spies the reader connection's `transaction` and asserts
  loadFull opens exactly one (single-read methods open zero). Verified it FAILS when
  the wrapper is reverted, so it now genuinely guards the fix.
- session-store.ts: correct the forward-reference comment ("(landed alongside)" was
  false — the write-side BEGIN IMMEDIATE + retry lands in Step 4; drop the premature
  §5 pin).
- Failure-isolate the temp-file teardown (try/finally) + vi.restoreAllMocks.
- deferred-tasks: track the analogous run-resume reconstruction torn-read exposure
  (lower impact — event-sourced fold self-heals) surfaced in review.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the temp-file teardown

Sonnet review mutation-verified the fix + coupling test (both correct) and found the
teardown still asymmetric: if reader.close() threw, rmSync never ran and the temp dir
leaked. Nest the finally so the dir is swept regardless of which close fails.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…etry on write paths (2.5.I S2)

`history.db` is a single shared file two concurrent `relavium` processes may write
(a `run` persisting events while a `chat` refreshes the model catalog — ADR-0064 §5).
Harden the write path:

- New packages/db/src/retry.ts (`withBusyRetry`): a bounded, FAIL-LOUD SQLITE_BUSY/
  SQLITE_LOCKED retry with DETERMINISTIC linear backoff (no jitter, never Math.random,
  per ADR-0040) via a synchronous Atomics.wait sleep. Rethrows the original error on an
  exhausted budget or any non-lock fault — never silently drops a write (ADR-0050).
- Switch the multi-statement write transactions to BEGIN IMMEDIATE (`{ behavior:
  'immediate' }`) so a DEFERRED read→write lock-upgrade race (SQLITE_BUSY_SNAPSHOT, which
  busy_timeout does NOT cover) cannot occur: persistEvent (run history),
  replaceProviderModels (the model-catalog bulk live-upsert), and the provider `upsert`
  read-then-write (previously an unguarded read-then-write). Each is wrapped in withBusyRetry.
  Single-statement writes rely on SQLite's built-in busy handler.

Docs:
- ADR-0064: the approved append-only amendment note (DB write-path concurrency), recording
  the two repo-wide conventions (every write txn IMMEDIATE; every write routes through the
  retry) as the concrete realization of §5's concurrent-process requirement. No new ADR.
- database-schema.md: a new "Concurrency & transaction behavior" section — the one canonical
  home retry.ts and the store doc-comments cite.

Tests (203 pass): retry.test.ts unit cases (deterministic backoff, fail-loud, SQLITE_LOCKED,
maxAttempts) + a REAL SQLITE_BUSY contention suite (a held write lock released during the
injected backoff; and a never-released lock that fails loud). Plus an IMMEDIATE-coupling test
in each of the three store suites (spies db.transaction, asserts `{ behavior: 'immediate' }`) —
a revert to DEFERRED fails them.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 4; ADR-0064 §5 amendment note

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…curacy + retry hardening

Opus review confirmed the mechanism correct and surfaced a missed writer + doc
overclaims:

- HIGH: model-catalog `upsert` (the `relavium models pricing` read-then-write) was a
  FOURTH unguarded multi-statement writer that can race `models refresh` on the same
  (provider, model) — a lost update or a UNIQUE crash. Wrap it in the same
  withBusyRetry + BEGIN IMMEDIATE, and add its IMMEDIATE-coupling test. Update the
  database-schema.md + ADR-0064 enumerations to list all four writers (the docs had
  claimed "every multi-statement writer" while covering three).
- MEDIUM: the database-schema.md loadFull bullet overclaimed. loadFull's read-txn
  guarantees SNAPSHOT consistency, not TURN atomicity — the CLI persister writes a
  turn's messages + totals as separate auto-committed statements, so a snapshot can
  observe messages ahead of their totals. Reword to what is actually guaranteed +
  track the non-atomic turn persister as a follow-up (deferred-tasks).
- LOW: document the ~25s compounded worst-case block (5 attempts × 5s busy_timeout);
  floor maxAttempts at 1 (Math.max) so a stray 0/negative can't disable the first try;
  add a full-default-budget backoff-sequence test [25,50,75,100] pinning base×attempt.
- retry.ts: caveat that BEGIN IMMEDIATE applies only to the outermost txn (a nested
  call demotes to SAVEPOINT); mirror it in database-schema.md.
- NIT: failure-isolate the two contention-test teardowns (nested finally).

Full db gate green; 207 tests pass.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 4

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rence GC writers + retry floor test

Sonnet review mutation-tested the mechanism (all correct) and found the docs'
"every multi-statement writer" claim overclaimed: media-reference-store's
addReference / removeRunReferences / reclaimExpired are multi-statement
read-then-write GC writers with no transaction wrapper — and media GC IS host-wired
(2.S), so a chat GC sweep racing a run's media production is the exact ADR-0064 §5
scenario. reclaimExpired even documented an (now-false-under-concurrency)
"single-connection ⇒ select-then-update is consistent" assumption.

- Wrap all three in withBusyRetry + BEGIN IMMEDIATE (recordObject stays a single
  atomic upsert — no wrap). reclaimExpired now takes one clock read for both the
  cutoff and the soft-delete stamp (stable across retries). Add a coupling test
  asserting all three open an IMMEDIATE transaction.
- Update the database-schema.md + ADR-0064 enumerations to list the media-reference
  GC writers, so "every multi-statement writer" is now accurate.
- Add a retry test pinning the maxAttempts:0 floor (the 7b7d9ff hardening had no
  regression test).

Full db gate green (207 tests); apps/cli media-gc consumers (19) still pass.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 4; ADR-0064 §5 amendment note

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two scenarios (close-plan D3) proving a `relavium chat` and a `relavium run` share
one history.db safely (ADR-0064 §5):

- Coexistence (in-process, two connections): the REAL runCommand persists run events
  on its own connection while a REAL SessionStore writes a transcript on a second
  connection, interleaved. Asserts both land intact and the loadFull snapshot is
  consistent. (One synchronous better-sqlite3 process can't overlap two txns, so this
  proves coexistence, not lock contention — see the two-process test for that.)
- Contention (real two OS processes): two child processes race a burst of provider
  upserts (the BEGIN IMMEDIATE + withBusyRetry path) against the same file; asserts
  both exit 0 with empty stderr and all 2×N distinct providers landed (no escaped
  SQLITE_BUSY, no lost/corrupt row). This is the only form that reproduces real
  cross-process WAL write-lock contention. The child (concurrent-writer.mjs) imports
  the BUILT @relavium/db by an argv abs path (it can't use vitest source resolution);
  the test is visibly it.skipIf-skipped, never silently passed, if the dist is absent.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion + honest framing

Opus review empirically showed (≈100 controlled runs) the two-process test was a
reliable cross-process COEXISTENCE smoke but a weak CONTENTION guard: distinct-name
upserts + the always-on busy_timeout rarely contend, so it caught an IMMEDIATE-only
regression ~2.5% and a retry-only regression 0%. The precise clause-guards already
live deterministically in @relavium/db (provider-store.test.ts IMMEDIATE spy;
retry.test.ts held-lock).

- Give it real teeth deterministically: the parent now HOLDS the WAL write lock
  (BEGIN IMMEDIATE on a third connection) while the children spawn, so their first
  upsert genuinely busy-waits cross-process every run; released after a short hold.
  The pass condition never depends on the timing (best-effort force, not a flake).
- Reframe the docstring + title honestly: a cross-process safety/coexistence smoke;
  point at provider-store.test.ts + retry.test.ts as the precise Step-4 guards.
- Fix the stderr flake vector: assert exit 0 + no /SQLITE|Error/ line, not exact-empty
  stderr (a CI runner's benign Node warning would have broken it).
- NIT: group node:crypto with the other node: builtins.

3× stable locally; typecheck + lint clean.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ces real contention

Sonnet review MEASURED that the 150ms fixed hold was shorter than the child's ~183ms
startup (Node bootstrap + the dynamic @relavium/db import), so contention never fired
(0/23 trials); "forcing a busy-wait every run" was empirically false.

- HIGH: replace the fixed timer with a deterministic READY handshake. The child prints
  READY immediately before its first write; the parent releases the held write lock
  only after BOTH children signal + a small margin — so a genuine cross-process
  busy-wait happens every run, independent of startup speed. Empirically verified: both
  children now block ~120-140ms on the held lock (was 0ms). A `close`-resolves-`ready`
  fallback means a dead child can never hang the wait.
- MEDIUM: reconcile the docstring to the handshake mechanism (no more "every run" claim
  that the inline comment contradicted).
- LOW: harden releaseLock cleanup — guard on `holder.sqlite.inTransaction` (never COMMIT
  with no open txn) and nest the finally (release → close → rmSync) so a failure in one
  step never skips the rest.

3× stable; typecheck + lint + format clean.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…I S4)

The coverage the 2.K regression harness (offline `run` fixtures only) lacks: a
transcript's full journey over a real file-backed history.db.

- A fresh cassette-driven session (the Home graduating into chat via buildChatSession)
  persists a turn through the real createSessionPersister.
- A chat-resume on a FRESH connection to the same file (loadFull → buildResumedChatSession)
  continues it, appending PAST the persisted max sequenceNumber.
- exportSession serializes it to a .relavium.yaml.

Asserts the roles land ['user','assistant','user','assistant'] gap-free [0,1,2,3] and
both user turns survive into the exported scaffold. The interactive ink Home/chat TUI
is out of scope (needs a render-test dep — SKIP-listed); this drives the exact
session-host + persister + export seam the Home graduates into.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 6

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng + total accumulation

Opus review confirmed the chain genuine (real cross-process resume, deterministic,
leak-free, both cassettes drive) and flagged assertions the test should make (all
values verified to flow correctly, just unasserted):

- MEDIUM: the resumed turn now uses a CAPTURING resolver that records its provider
  request; assert the request carried the reconstructed PRIOR transcript ('first user
  message' + 'reply from the fresh session') into the model's context — the defining
  behavior of resume, previously untested at this file-backed seam.
- MEDIUM: assert the session totals DOUBLE across resume (adopt-then-accumulate) — a
  resume that failed to seed the prior totals would reset the row to 1×.
- MEDIUM: assert both assistant replies survive into the exported YAML (not just the
  user turns).
- LOW: assert resumed.sessionId === SESSION_ID; narrow `full` before mapping.
- NIT: disableMcp: true on the fresh build to state the offline intent explicitly.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 6

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n shape + full usage guards

Sonnet review traced the engine cost/reconstruction mechanics (all confirmed correct)
and flagged assertions that could false-green:

- MEDIUM: the context-threading proof checked only that the turn strings appeared
  SOMEWHERE in the request — a scrambled/duplicated/mis-tagged reconstruction would
  pass. Now assert the EXACT role+order+text array
  ([user, assistant, user] with the precise texts) reconstructSessionState threaded
  into the request.
- MEDIUM: add `> 0` guards for freshOutput + freshCost (only freshInput had one), so
  the 2× accumulation assertion keeps teeth on all three axes if a fixture/price drifts.
- LOW: assert exactly one provider call drove the resumed turn (toHaveLength(1)) before
  reading requests[0].

Refs: docs/roadmap/phase-2.5-close-plan.md Step 6

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Query-shape budgets for the hot read paths (the 2.5.B Home recent-sessions/runs
strips + `relavium list`), per close-plan D2 (shape, not flaky wall-clock):
EXPLAIN QUERY PLAN asserts each read is INDEX-served with NO filesort
(USE TEMP B-TREE) and NO full-table SCAN — the concrete guarantee the store docs
claim ("served off idx_… (no filesort)").

- listSessions → idx_agent_sessions_updated
- listRuns → idx_runs_created
- loadFull's message read → a single indexed range scan (no N+1, no full scan)

A dropped/renamed index, or an ORDER BY the index stops covering, flips the plan and
fails here. The other §2.5.I perf item — the 80×24 narrow-terminal degrade — already
exists and is asserted in render/tui/home-projection.test.ts (homeFitsTerminal /
tooSmallMessage at the boundary), so it needs no new coverage (close-plan D4).

Refs: docs/roadmap/phase-2.5-close-plan.md Step 7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ages filesort gap + drift coupling

Opus review verified the replicas match the stores exactly and inspected real plans;
two MEDIUMs worth closing:

- MEDIUM: the loadMessages budget FALSE-PASSED a filesort regression — it omitted the
  index-name pin + the USE TEMP B-TREE guard, so dropping idx_session_messages_seq
  (which falls to idx_session_messages_session + a temp b-tree) still passed. Parameterize
  the helper's table name and run loadMessages through it, pinning idx_session_messages_seq
  and forbidding the temp b-tree — now all three reads share one enforced contract.
- MEDIUM: the test replicates the store queries (the stores don't expose their SQL);
  strengthen the drift coupling from a bare "Mirrors" comment to explicit bidirectional
  file:line back-references — added at the session-store listSessions/loadMessages and
  run-history-store listRuns query sites, so a store ORDER BY edit trips the reviewer.
- LOW: drop `.limit` so the plan covers both the bounded Home strip AND the unbounded
  chat-list/`relavium list` (identical plan); soften the "no N+1" wording to what a
  single-statement EXPLAIN actually proves ("one indexed range scan, not a per-message read").
- NIT: document the covering-index + SQLite-wording caveats on the helper.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rance + closed table union

Sonnet mutation-tested every budget (dropped each pinned index, confirmed all three now
fail correctly + that the pre-fix loadMessages false-passed the filesort). Two cheap
LOW hardenings:

- Loosen the index-served check to /USING (COVERING )?INDEX/ so a future .select()
  narrowing (which flips SQLite to "USING COVERING INDEX") doesn't false-FAIL a
  still-index-served plan.
- Type the helper's `table` as a closed union ('agent_sessions'|'runs'|
  'session_messages') so a computed/metachar table name can never reach the RegExp.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-mode smoke (2.5.I S6)

The DB write-path hardening (BEGIN IMMEDIATE + the retry's Atomics.wait sleep + WAL
locking), the two-process concurrency e2e, and the no-TTY raw-mode path are the parts
most likely to diverge on Windows — but ci.yml ran ubuntu-only (release.yml already
smokes the built binary cross-OS, but never the test suite).

- Add an advisory `windows-concurrency` job to ci.yml: install + build, then the
  @relavium/db concurrency suite + the CLI concurrency/perf/session-chain harness on
  windows-latest, plus a headless raw-mode smoke (--help loads the native addons; a
  non-TTY `chat` must exit cleanly, never a setRawMode crash). The required check stays
  the ubuntu `ci` job; POSIX 0600/0700 assertions are not exercised (Windows no-op, ADR-0050).
- Fix a real cross-OS bug the Windows job would expose: the two-process e2e's child did
  `await import(<filesystem path>)`, which fails on Windows (a bare `C:\…` path is not a
  valid import specifier). Pass the built @relavium/db as a `file://` URL (cross-OS);
  keep the path form only for the existence gate. Still green on macOS.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… guard + honest smoke framing

Opus review verified the file:// fix (empirically on Node 22), the YAML/pins, and the
vitest filter, and flagged:

- MEDIUM: the "raw-mode smoke" mislabeled what it does — `chat` selects the PLAIN driver
  when stdout is a pipe (the driver-selection gate is TTY-gated), so ink/setRawMode is
  never entered on ANY OS. Reword the job/step comments to what actually runs: the bundle
  loads, and a piped `chat` picks the plain driver + exits cleanly on EOF (the smoke
  guards that driver-selection gate, not Windows raw mode).
- MEDIUM: `rmSync(dir, { recursive, force })` has no EPERM/EBUSY retry — a real Windows
  flake when deleting a dir that just held SQLite WAL sidecars. Add maxRetries:5 +
  retryDelay:100 to all 13 SQLite-teardown rmSync sites the Windows job runs (the
  concurrency + session-chain harness + the @relavium/db suite).
- LOW: add the Turborepo cache restore to the Windows job (parity with ci/coverage;
  the cold build was tight against the 25-min budget). Soften the --help native-addon
  claim (the real Windows addon exercise is the DB vitest step).

Full db suite (207) + CLI harness (6) green; lint + format clean.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Y gate in the smoke comment

Sonnet review verified everything empirically (207 db + 6 harness tests, YAML parse,
tag→SHA, live rmSync repro) and found the step-level comment still misattributed the
driver-selection gate to stdin. The gate is process.stdout.isTTY (selectChatDriver /
io.stdoutIsTty); stdin's TTY-ness plays no role. Reword to name the actual gate (the
smoke still exercises it — stdout is captured non-TTY inside the $(...)).

Refs: docs/roadmap/phase-2.5-close-plan.md Step 8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ith the 2.5.I coverage (2.5.I S7)

The canonical harness reference documented only the 2.K run-fixture stream contract.
Reconcile it with the Phase-2.5.I extensions:

- Bump the Last-updated + Scope; add ADR-0064/0050 + database-schema.md to Related.
- Add the new harness suites to "Where it lives": the concurrency e2e (+ its child
  fixture), the session-chain e2e, and the query-shape perf budgets.
- Add a "§2.5.I extensions" section documenting each suite (what it proves, what it
  doesn't — e.g. the concurrency e2e is a cross-process safety smoke, the precise
  BEGIN IMMEDIATE/retry guards live in @relavium/db white-box tests; the 80×24 degrade
  is in home-projection.test.ts) + the advisory windows-concurrency CI lane.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 9

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ocess claim + full-suite wording

Opus review verified the reconcile is highly accurate (every suite claim, store
back-reference, Windows lane, and link checks out); two wording precisions:

- LOW: "The harness is an in-process suite … does not spawn the built binary" now sits
  above a table listing the concurrency e2e, whose cross-process case DOES spawn child
  node processes. Scope the subject to "The `run`-fixture harness" and note the §2.5.I
  concurrency suite is the deliberate exception.
- NIT: the Windows lane runs the FULL @relavium/db test suite, not a concurrency-only
  split — reword.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 9

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olor flag (2.5.J S3)

Color was resolvable only via --no-color (raw.color !== false); no env input. Add the
standard env conventions + the symmetric --color flag (close-plan D6/D7).

- resolveColor(raw, env) with the precedence: --color/--no-color flag > NO_COLOR
  (any non-empty ⇒ off, the no-color.org accessibility contract, wins over FORCE_COLOR)
  > FORCE_COLOR (not 0/false/empty ⇒ on) > default-on. FORCE_COLOR is force-ON only;
  disabling is NO_COLOR / --no-color.
- Add --color to BOOLEAN_FLAGS (symmetric with --no-color; both present ⇒ last wins).
- resolveGlobalOptions(raw, defaultCwd, env = {}); run.ts passes io.env.
- Color stays orthogonal to the output MODE (--json/CI/non-TTY suppress ANSI separately
  in detectOutputMode — unchanged).
- Document in program.ts --help + commands.md (the precedence + the mode-orthogonality).
- Tests: the full precedence matrix in options.test.ts (NO_COLOR any-value-off incl.
  '0'; empty falls through; FORCE_COLOR truthy-on / 0-false-empty fall-through;
  NO_COLOR beats FORCE_COLOR; flag beats both directions) + --color extraction.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 10; ADR-0047

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l teeth + fix doc-link depth

Opus review proved the FORCE_COLOR branch was inert: `color` defaults to `true` and the
force-ON-only branch could only ALSO return `true`, so it never changed the outcome —
yet it was documented + tested as a live feature (dead code shipped as a feature).

- Adopt the supports-color convention so FORCE_COLOR has an observable effect:
  FORCE_COLOR=0/false ⇒ OFF (distinct from the default-on); any other value stays on.
  NO_COLOR still wins (checked first); an explicit --color/--no-color flag beats both.
  Update program.ts --help + commands.md to match, and rewrite the tests so
  FORCE_COLOR=0/false now assert OFF (the teeth) + a flag-beats-FORCE_COLOR=0 case.
- LOW: fix the resolveColor JSDoc ADR-0047 link depth (../../../ → ../../../../, matching
  output-mode.ts / render/select.ts in the same tree).

20 options tests pass; typecheck + lint + format + fence clean.

Refs: docs/roadmap/phase-2.5-close-plan.md Step 10; ADR-0047

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…E_COLOR framing

Sonnet mutation-tested the fix (removing the FORCE_COLOR=0 branch fails a test; observ-
ability confirmed end-to-end) and found a residual honesty gap: after FORCE_COLOR became
opt-OUT-only, the "NO_COLOR beats FORCE_COLOR (opt-out wins over opt-in)" test + the JSDoc
"intentionally beats" framing describe an opt-in FORCE_COLOR no longer has — the check
ORDER is now inconsequential (a truthy FORCE_COLOR never force-ONs).

- Reword the JSDoc: both env vars are opt-OUT signals; a truthy FORCE_COLOR has no
  independent effect, so there is no NO_COLOR-vs-FORCE_COLOR conflict to resolve.
- Reword the test to what it actually verifies (NO_COLOR turns off even alongside
  FORCE_COLOR=1 — a truthy FORCE_COLOR never force-ONs over NO_COLOR).
- (Also reconciled the untracked close-plan's D6 row to the shipped semantics.)

Refs: docs/roadmap/phase-2.5-close-plan.md Step 10

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cemililik and others added 19 commits July 8, 2026 00:46
….5.A git_status union pin

- media-gc.test.ts: the "sweeps a row-less CAS orphan" case was non-deterministic — with
  orphanMinAgeMs:0 and no injected clock, `settledBefore` (Date.now() − 0) can land BELOW
  the just-put orphan's filesystem mtimeMs (coarse/rounded-up mtime), so `mtimeMs <=
  settledBefore` is false and the orphan is skipped as a concurrent writer. Inject a
  fixed +60s clock so the orphan is unambiguously settled. 5× stable.
- session-host.test.ts: add the 2.5.A UNION pin — a chat session dispatching git_status
  end-to-end through the process arm (the arm + the session→host dispatch were each
  covered separately, never together). A real temp git repo + a git_status tool-call
  cassette; asserts the agent:tool_call annotation + the post-tool answer streams (the
  process arm ran it and the result folded back).

The other Batch-A pins stay covered/tracked: the persister failed-turn REAL-token/cost
fold is already pinned (persister.test.ts — "flushes the running cost on a failed turn"
+ 4 sibling cases), and the 2.I read-command pins remain in deferred-tasks (transitively
covered by the process arm + read tests).

Refs: docs/roadmap/phase-2.5-close-plan.md Step 11

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it_status union pin

Two review findings on the 2.5.A git_status union pin (session-host.test.ts):

- MEDIUM: the pin under-asserted. It only checked the agent:tool_call
  annotation + the cassette-driven 'clean' token — neither proves git_status
  ran to a clean result. The reviewer's proposed `success === true` check is
  itself inert for cleanliness: the registry stamps success:true on ANY
  resolved dispatch, and git_status's spawn RESOLVES a ProcessResult for both
  exit 0 and exit 128 ("not a repository"), so success cannot distinguish them.
  The genuine lock is the tool-result outputSummary, which carries git's
  captured `{"exitCode":0,…}` stdout — added that assertion (keeping success as
  the weaker "git actually started" check) with an honest comment on what each
  proves.
- NIT: moved `git init` inside the try so the mkdtemp'd dir can't leak if init
  throws (git absent / hostile template hook) — the finally rmSync now covers it.

Verified: 52/52 pass, stable across 3 runs; typecheck/eslint/prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 11, Batch A)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… abort hint + line-bounded reasoning panel)

Two chat-TUI live-region polishes, both landed in the pure, unit-tested
projection formatters (the React view only arranges them):

- Persistent mid-stream abort hint (EA7). The abort affordance showed on the
  pre-first-token status line ("Working… {elapsed} · Esc to stop") but vanished
  once answer content streamed — even though Esc aborts the WHOLE turn. New pure
  `streamingAbortHint(busy)` returns a compact "Esc to stop" only for a streaming
  CONTENT line; a STATUS line (compaction/shell/pre-token) already carries its
  inline hint, so it returns undefined (no double-print). Rendered as a dim line
  beneath the streaming content in ChatView.

- Bound the EXPANDED reasoning panel by rendered ROWS. The panel body is capped
  at MAX_LIVE_TOKEN_CHARS (4000) CHARS, which can wrap to 60+ rows on a short/
  narrow terminal and flicker in the live region. `formatReasoningPanel` now
  tails the expanded body to the last MAX_REASONING_PANEL_LINES (12) RENDERED
  rows — each logical line counts as ceil(len/columns) wrapped rows (min 1), so
  both many short lines AND one very long line are bounded; a single over-budget
  line is head-sliced to its tail. The `…` head-elision marker now ORs the store
  char-cap flag with this row tail. A new optional `columns` prop threads the
  live width (ChatApp reads process.stdout.columns; the Home passes its
  resize-tracked size.cols); absent ⇒ an 80-col fallback so the bound still
  applies headless/in tests.

Kept OUT (per the batch scope): Ctrl+T-during-approval — it touches the approval
floor and lands with the Step 14 security batch.

Tests: +7 projection cases (streamingAbortHint status-vs-content; reasoning
row-tail for short lines / wrapped rows / single-over-budget head-slice / marker
OR / 80-col fallback). 54 projection + 656 render+chat tests pass; typecheck,
eslint, prettier, build, fence, engine-deps all green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 12, Batch B); 2.5.H

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… div-by-zero floor

Opus review of the 2.5.H reasoning-panel row bound — no blockers; applied the
correctness/honesty items:

- MEDIUM: `tailToRenderedRows` counts `.length` (UTF-16 units), not ink's display
  width, so a wide-glyph (CJK/emoji) reasoning stream under-counts rows (renders
  up to ~2× the budget) and the prepended `…` marker can add one more row. The
  code claimed a hard "last N rendered rows" bound. Documented it honestly as an
  APPROXIMATE, cosmetic anti-flicker guard (matching the store's existing
  `.length`-based char cap; no `string-width` runtime dependency wanted) and
  softened the `formatReasoningPanel` JSDoc accordingly.
- NIT (latent div-by-zero): tightened the width guard from `columns > 0` to
  `Math.floor(columns) >= 1` so a fractional 0<columns<1 can't floor to 0 and
  make `rowsOf` divide by zero. Unreachable today (callers pass integer ≥1) — a
  provable floor.
- NIT: clarified the ChatApp render-site comment — `process.stdout.columns` is
  typed `number` but is `undefined` off a TTY (the 80-col fallback covers it;
  moot since ChatApp only mounts on a TTY).
- NIT (test gap): added the exact-fit boundary case — rows summing to EXACTLY the
  budget keep everything with NO marker (`tailed:false`, loop ends naturally).

Tests: 55 projection cases pass; typecheck/eslint/prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 12, Batch B); 2.5.H

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…w miscount

Sonnet review of the 2.5.H reasoning-panel row bound (caught a case the Opus
round missed):

- MEDIUM: a live reasoning buffer cut off right after a line break (very common
  between stream chunks) ends in '\n', which `split('\n')` turns into a phantom
  empty last segment. `rowsOf('')` clamps to 1, so that phantom spent a full
  budget row and could prematurely DROP a real line + flash the `…` marker for
  content that hasn't arrived yet (self-correcting on the next token, but a real
  untested gap). Fix: strip exactly ONE trailing '\n' before splitting — the
  not-yet-filled next line costs zero rows, while an intentional trailing blank
  line ("\n\n") keeps its blank (only the final cursor newline is dropped).
- LOW: the `columns` prop JSDoc said "Absent or ≤0 ⇒ fallback" but the guard is
  `Math.floor(columns) >= 1` (any < 1 falls back). Corrected the doc to "< 1".
- LOW: the div-by-zero guard the prior fix commit added was untested. Added a
  case pinning columns ∈ {0, 0.5, -5, NaN} → 80-col fallback (no crash).

Tests: +4 cases (trailing-newline no-drop; intentional trailing blank kept;
fractional/non-positive width fallback). 58 projection cases pass;
typecheck/eslint/prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 12, Batch B); 2.5.H

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ctx.*}} deferred)

Attach actionable line/column diagnostics to a `.agent.yaml` YAML syntax fault,
the agent sibling of parser.ts's `syntaxErrorFrom`:

- AgentParseError gains optional 1-based `line`/`column` fields (parity with
  WorkflowSyntaxError), populated for an `agent_syntax` YAMLParseError when the
  loader reports a position (skipped for a bare stream error at offset -1). A new
  `agentSyntaxErrorFrom` normalizes the decode throw: a positioned YAMLParseError
  → the YAML RULE message + folded `(source — line L, column C)` locator + the
  structured fields; an anchor/alias ReferenceError (maxAliasCount:0) → a clear
  source-free label with NO bogus position; else the generic fallback.
- The position is folded into the message (via a shared `locate()` suffix
  mirroring the config loader's `(line L, column C)` shape) so every existing
  `.message`-surfacing consumer (catalog `parseReason`, authoring, agent run)
  shows it end-to-end — the structured fields are additionally exposed for
  machine/--json readers.
- Echoing the YAML rule is secret-free by construction: `prettyErrors: false`
  (yaml-decode.ts) makes the message the rule ALONE — no source snippet, no
  authored key/value (verified: a duplicate-key fault does not echo the value).
  A regression test pins that an authored (secret-shaped) value never leaks.

The Step 13 `{{ctx.*}}` session-interpolation half is DEFERRED to Phase 2.6 /
2.6.D per the maintainer's 2.5-close decision (2026-07-08): it is governed by
ADR-0060 (Proposed) which requires a new SessionContext taint-provenance marker +
a mandatory pre-Accept security review — not a plain resolveTemplate reuse, and
not appropriate to pull forward into the close-out. Recorded in deferred-tasks.md.

Tests: +2 agent-parser cases (line/col attach + secret-free syntax message) and
the alias case; existing syntax test extended. 982 core tests pass; catalog/list
consumer suites green; typecheck/eslint/prettier/build green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 13, Batch C); ADR-0060 (deferral)

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

Opus review of the AgentParseError line/col change — no blockers; the security
claim (echoed YAML rule is secret-free) held under a 14-case empirical sweep.

Applied the one code finding (LOW): the anchor/alias branch of
`agentSyntaxErrorFrom` called the 2-arg AgentParseError ctor and swallowed the
underlying ReferenceError — diverging from parser.ts's sibling alias branch
(which attaches `cause`) and from error-handling.md's no-swallow rule. Attach the
cause (a fixed, non-secret "Alias resolution is disabled") to match. The two NITs
(the `—` em-dash glyph; a commit-message wording nuance on --json exposure) are
acceptable as-is per the review.

Tests: 8 agent-parser cases pass; typecheck/eslint/prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 13, Batch C)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…or + surfacing gap

Sonnet review of the AgentParseError line/col change — no blockers; the security
sweep held (12 more adversarial decode cases + a full enumeration of the yaml
throw surface: no authored key/value ever rides the message). Applied:

- MEDIUM-1 (regression test for the Opus-round cause fix): added `err.cause`
  assertions — the alias branch preserves the ReferenceError, the syntax branch
  the YAMLParseError. Reverting the cause fix now fails a test (testing.md).
- LOW-1: pinned the pos-only `locate()` branch — a syntax fault with NO source
  ends in a clean `(line L, column C)`, no stray `—` separator, no `undefined`.
- LOW-2: added a catalog end-to-end test — a broken `.agent.yaml` with a real
  YAML SYNTAX fault surfaces the line/column through `discoverCatalog`/parseReason
  (proving Step 13's diagnostic reaches the catalog surface, secret-free).

- MEDIUM-2 (pre-existing, DEFERRED — recorded in deferred-tasks.md): a malformed
  `.agent.yaml` on the `chat --agent` / `agent run` surfaces reaches the top-level
  handler as exit 1 + a generic "internal error" (the diagnostic only actually
  surfaces via list/create/import). `resolveChatAgent` DELIBERATELY surfaces the
  raw AgentParseError — pinned by agent-source.test.ts "not a silent default or
  CliError" — so re-tagging is a cross-surface DESIGN decision conflicting with an
  existing test, not a mechanical fix; it predates this diff and is out of the
  fix(core) close-out scope. Documented with candidate fixes (wrap at the caller
  vs. teach the top-level renderError) + the absolute-vs-relative `source` note.
  Clarified the resolveChatAgent docstring to record the deliberate raw-surface.

Also marked the original "AgentParseError line/col" deferred entry DONE.

Tests: 9 agent-parser + 22 catalog/agent-source pass; core+cli typecheck, eslint,
prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 13, Batch C); deferred-tasks.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(Trojan-Source floor)

Extend the shared render sanitizer to strip Unicode bidirectional/directional
format controls at every display boundary (CVE-2021-42574 floor):

- `stripTerminalControls` now also strips the standard Trojan-Source family —
  the embeddings/overrides U+202A–202E, the isolates U+2066–2069, and the marks
  LRM (U+200E) / RLM (U+200F) / ALM (U+061C). These live above the C0/C1 range
  the existing BARE_CONTROLS strips, so they previously survived — yet they
  reorder how a terminal renders a line, letting streamed/pasted text visually
  differ from its logical bytes (a spoofed path/command in an approval prompt).
  Every surface (chat, Home, run TUI, commands) imports this one primitive, so
  the floor is uniform. The regex is written with `\u` escapes only — never
  literal bidi bytes — so the source itself carries no Trojan-Source hazard.
- ZWJ/ZWNJ (U+200D/U+200C) are deliberately PRESERVED — they are legitimate in
  emoji sequences and Indic/Arabic/Persian shaping; an over-broad strip would
  mangle names/emoji. (Pinned by a test.)
- Reconciled the pre-existing provider.ts `BIDI_ZERO_WIDTH`: it is now a stricter
  SUPERSET (it additionally strips the zero-width/BOM family the general floor
  keeps, which a structured URL must reject) rather than a workaround for a floor
  that "MISSES bidi" — corrected its now-stale comment (the earlier "bidi is
  legitimate for RTL text" rationale is superseded: legitimate RTL renders from
  inherent character directionality, not these override controls).

Tests: +4 projection cases (strip the full family; flatten an RLO spoof; preserve
ZWJ/ZWNJ; strip through sanitizeInline). 62 projection + 484 render tests pass;
36 provider tests pass; typecheck/eslint/prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 14, Batch D); ADR-0057

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…asoning toggle in the approval swallow

During a pending per-tool approval the fail-closed key-swallow (ADR-0057) owns
the keyboard and drops every key except [y]/[a]/[n]/[esc], so a user could not
expand the model's reasoning to inform the decision. Whitelist the ONE view-only
chord — Ctrl+T (toggle-reasoning) — through `reduceApprovalKey`:

- Security-verified side-effect-free before bypassing the swallow: `toggleReasoning`
  (chat-store.ts) only flips a local `reasoningVisible` boolean and repaints — zero
  effect on `approval`, session state, or the pending decision. It cannot answer,
  cancel, or mutate anything a governed dispatch gates on.
- The whitelist is TIGHT: matched only for the exact Ctrl-without-meta 't' chord,
  placed AFTER the `escape` (abort wins) and BEFORE the `bare` gate. A bare 't',
  Ctrl+Meta+T, or Alt+T (ESC-prefixed) all stay swallowed — the approval input set
  is exactly {y/1, a/2, n/r/3, esc} plus this view-only toggle. The most-permissive
  approve/reject can still never be picked by a modified chord.
- Both surfaces (chat ChatApp + the in-Home chat) route approval keys through the
  shared `reduceChatKey`/`toggle-reasoning` case, so one change fixes both.

Tests: +1 chat-input case (Ctrl+T → toggle-reasoning during approval; bare/meta
variants stay swallowed). 132 chat-input + home-controller tests pass;
typecheck/eslint/prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 14, Batch D); ADR-0057

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on at the approval prompt

Add a `[c]` "reject with reason" branch to the per-tool approval prompt (ADR-0057
deferred follow-up): instead of a bare "denied by the user", the user types WHY,
and the reason rides the EXISTING `ToolApprovalDecision.reject.reason` seam into
the denial the turn records/shows. The engine + type plumbing already existed
(`ApprovalAnswer.reject.reason` → `toDecision` → `ToolApprovalDecision.reject.reason`
→ registry → `ToolDeniedByUserError`); this is the missing UI capture.

- `[c]` at the prompt returns a new `reject-with-reason` action (chat-input.ts) —
  a pure UI transition that OPENS a small keyboard-owning reason-input sub-mode; it
  does NOT itself answer. The bare `[n]`/`[r]`/`3` reject (no reason) is unchanged,
  and a modified `c` (Ctrl+C / Alt+C) stays swallowed (fail-closed).
- The capture sub-mode is wired into BOTH surfaces (chat `ChatApp` + the in-Home
  chat) — checked FIRST in the key handler: Esc CANCELS back to the [y]/[a]/[n]
  prompt (the approval stays pending — NOT an abort), plain Enter submits the
  reject with the reason, every other key edits the buffer (reusing the tested
  editor primitives). A stale capture (the approval settled out-of-band) is
  dropped. The reasonDraft is chat-scoped and reset on every session swap.
- The reason is sanitized + bounded before it leaves the UI (`sanitizeApprovalReason`:
  strip terminal/bidi controls, collapse to one line, trim, cap at 300 chars) so a
  pasted wall of text or a control/bidi spoof can't blow up or spoof the error line,
  logs, or `--json`. An empty reason degrades to a plain reject.
- SECURITY: the fail-closed floor is UNCHANGED — `[c]`+Enter is a reject with extra
  data; a governed dispatch still cannot proceed without an explicit decision, and
  ADR-0057's "a user deny is final" (`tool_denied`, non-retryable) is untouched.

Tests: +1 chat-input reducer case, +4 sanitizeApprovalReason cases, +2 home-controller
INTEGRATION cases (open on a real pending approval → type → reject-with-reason; and
Esc-cancels-keeps-pending → later [n] plain-rejects). 672 render+chat tests pass;
typecheck/eslint/prettier/build/fence/engine-deps green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 14, Batch D); ADR-0057

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Y fail-closed approval policy

The no-TTY approval policy (deny a governed dispatch when there is no user to
approve — never a hang, never an auto-approve) already shipped in 2.5.E (the
"High 9" fix) and is covered end-to-end (agent-run.test.ts "DENIES a governed
dispatch in the non-interactive one-shot"; chat.test.ts chatIsInteractive →
reject-immediately). But it was hand-rolled as an inline reject lambda in TWO
places (chat.ts + agent-run.ts) with slightly divergent reason strings — a
drift risk on a security-critical floor.

Step 14 "define + enforce one policy": extract the canonical
`nonInteractiveApprovalPrompt(context)` into chat-mode.ts (the home of
`ApprovalPrompt`) — every request rejects regardless of tool/mode, with the
surface named in the secret-free reason — and use it from both the
non-interactive chat driver and the one-shot `agent run`. Behavior is
byte-identical; the policy now has ONE home.

Tests: +2 chat-mode cases (denies any request/tool — never a hang/auto-approve;
names the surface in the reason). 110 chat-mode/chat/agent-run tests pass;
typecheck/eslint/prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 14, Batch D); ADR-0057

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tional recovery

Make a pure SCOPE denial recoverable so the interactive chat turn can adapt
instead of dying — the "conversational recovery" of ADR-0057, scoped tightly to
fs/media (the maintainer's 2.5-close D14-c decision).

Taxonomy change (`changes the denial taxonomy`): the `recoverable` flag moves
from `ToolExecutionError` onto the base `ToolDispatchError` (default FALSE — the
safe direction; a forgotten opt-in just stays fatal). It now gates the chat
within-turn recovery for TWO disjoint sources: an idempotent host execution
failure (unchanged), AND a scope denial refused BEFORE any side effect. Exactly
two `tool_denied`s opt in:
- `ToolPolicyError('media_scope_denied')` — read_media's idempotent scope denial.
- the CLI fs arm's PURE scope-tier escape (`FsScopeDeniedError` "the path escapes
  the allowed filesystem scope") — the canonical "path outside the workspace".

`isRecoverableToolError` (agent-turn.ts) simplifies to `recoverToolFailures &&
err.recoverable` (plus the always-correctable unknown_tool/invalid_args). A
recoverable scope denial is fed back as an `isError` tool_result so the model
adapts to an in-bounds path; the floor STILL denies every attempt (no bypass).

Kept FATAL (recoverable=false — no capability leak, no probe oracle, no
re-execution): a user reject / fail-closed approval, every other guardrail reason
(not_granted / command / domain / gate), an egress SSRF denial, and the fs
CONFIDENTIALITY (secret-store read) / protected-path / symlink / hard-link
refusals. Only on the chat surface (recoverToolFailures) — a WORKFLOW run stays
fail-fast/deterministic.

Tests: +2 agent-turn (recovers media_scope_denied when recoverToolFailures;
does NOT recover a not_granted guardrail denial even then), +2 fs (the scope
escape is recoverable:true; the confidentiality-floor read is recoverable:false).
985 core + 1626 cli tests pass; typecheck/eslint/prettier/build/engine-deps green.
Canonical taxonomy home updated (tool-registry.md).

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 14, Batch D); ADR-0057

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…urity-review.md for Batch D

Append-only ADR-0057 amendment note recording the Step-14 approval/security batch
(the deferred `[c]`/non-TTY follow-ups landed, plus the SCOPE-denial recovery
taxonomy change, the Ctrl+T approval whitelist, and the Trojan-Source floor) —
the core guarantees are unchanged (no ungated governed dispatch, no protected-path
/ jail escape, read-only `ask` default). Extends security-review.md with a "CLI
terminal-render safety + interactive approval" subsection: the bidi/Trojan-Source
render floor, the fail-closed approval keyboard + the sanitized/bounded `[c]`
reason, and the by-construction safety of the two recoverable scope denials (vs
the fatal confidentiality/SSRF/user/guardrail denials).

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 14, Batch D); ADR-0057

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ doc accuracy + surrogate-safe cap

Applied the security + Opus review findings (both returned 0 blocker / 0 high;
all five guarantees traced + confirmed). Fixes:

- MEDIUM (canonical-home drift, rule #8): agent-runner.md's failure-ladder table
  still called ToolPolicyError unconditionally "not fed back" — corrected to note
  the Step-14 `media_scope_denied` recoverable exception, linking the tool-registry.md
  taxonomy note (tool-registry.md was updated in D-3; the linked ladder home was missed).
- LOW: two stale "`[c]` … deferred follow-up" comments (chat-store.ts PendingApproval,
  chat-mode-host.ts MakeChatModeEnv.prompt) — `[c]` landed in D-1; updated both to
  `[c] reason` + the reject-reason seam.
- NIT: `sanitizeApprovalReason` capped by UTF-16 code unit — could split an astral
  pair into a lone surrogate at the 300-char boundary. Back off one unit when the cap
  lands on a high surrogate (+ a regression test).
- Doc accuracy (security-review finding): the `recoverToolFailures` recovery surface is
  the chat-read-write host (chat / Home / one-shot `agent run`) — not "interactive chat
  only"; corrected in tool-registry.md, the ADR-0057 amendment, and security-review.md.
  Documented the accepted, secret-free scope-boundary probe residual (bounded by
  maxToolCorrections; the same round-bounded feedback pre-existed for an idempotent
  not-found read) + the createDirs idempotent-mkdir exception to "before any side effect".
- deferred-tasks.md: marked the four now-landed 2.5.E entries DONE (bidi strip, `[c]`,
  scope-recovery, non-TTY policy); recorded the two optional Step-14 review follow-ups
  (approval-consent-line zero-width hardening; extract the `[c]` capture to a shared
  reducer for ChatApp test parity).

Accepted as-is (documented): the `createChatModeControl` `interactive` default (true =
the ink-REPL convention; the sole production caller always passes the computed flag;
off-TTY the worst case is a hang, never an auto-approve).

Tests: 67 projection cases pass; typecheck/eslint/prettier/fence green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 14, Batch D); ADR-0057

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…verToolFailures-surface phrasing

Sonnet review of Batch D (0 blocker / 0 high; it independently re-traced the whole
taxonomy + ran the full suites green). Applied:

- MEDIUM (canonical-home consistency): the babd0e4 fixup corrected "interactive
  chat surface [only]" → the precise "recoverToolFailures surfaces (chat / Home /
  one-shot agent run)" in 3 docs but left 5 other spots (incl. the agent-runner.md
  row it wrote) saying "interactive chat surface". Propagated the precise phrasing
  to all five: ToolDispatchError.recoverable JSDoc (errors.ts), isRecoverableToolError
  JSDoc (agent-turn.ts), FsScopeDeniedError class doc (fs.ts), the agent-runner.md
  ladder row, and tool-registry.md's "Loop-correctable vs terminal" blockquote. Also
  fixed a duplicated {@link} the edit introduced in agent-turn.ts.
- LOW: pinned `media_scope_denied`'s recoverability at its SOURCE — added
  `expect(err.recoverable).toBe(true)` to the builtins.test.ts read_media scope-denial
  case (previously only the agent-turn integration test covered it).

Deferred to the close-out Final step (a whole-epic convention, not new to this batch):
the commits' `Refs:` cite the untracked/scratch phase-2.5-close-plan.md — the Final
phase-doc reconciliation + the PR body provide the durable record.

Tests: 52 builtins cases pass (incl. the new assertion); core+cli typecheck,
eslint, prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 14, Batch D); ADR-0057

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make `wiredToolIds` (the ADR-0055 advertise-filter) read-only aware: with
`{ readOnly: true }` it stops advertising a WRITE-class tool (`fsWrite`) whose
fs arm IS wired but always denies the write on a read-only host — an
always-denied advertisement — the advertise-side complement to the read-only fs
arm's dispatch refusal (still authoritative). Read/list fs tools are unaffected;
the default (read-write) is unchanged, so the live `chat-read-write` session host
is inert. Closes the deferred "profile-unaware advertise-filter" item.

Tests: +1 assemble case (readOnly drops write_file, keeps read/list; default
keeps write_file). 15 tool-host tests pass; typecheck/eslint/prettier green.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 15, Batch E); ADR-0055

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…for @-mention

Add a dependency-free, ReDoS-safe `.gitignore` / `.relaviumignore` matcher
(gitignore.ts) and fold it into the `@`-mention completion candidate filter so
`@` no longer surfaces files the project already ignores. A UX/privacy nicety,
NOT a security control — the confidentiality floor + listing-gate remain the
authoritative fs-capability enforcement (a gitignore file is untrusted project
data; the matcher can only HIDE more, never widen what the jail refuses).

- The matcher supports the common subset: blank/`#`-comment lines, `!` negation
  (last match wins), trailing-`/` directory-only, leading-`/`/mid-`/` anchoring
  vs. any-depth basename match, and the `*` (in-segment) / `**` (cross-dir) / `?`
  globs. glob→regex is linear (no nested quantifiers → ReDoS-safe). Regex-special
  chars in a literal pattern are escaped. Documented subset limits (deferred, and
  only ever UNDER-hide — never a security gap): nested per-dir ignore files
  (only the workspace-root files are read) + `[a-z]` char classes.
- `createMentionReader` lazily reads the root ignore files ONCE (best-effort — a
  missing/unreadable/refused file degrades to no rules, never a crash), caches the
  compiled matcher, and drops an ignored entry from each listing. An ignored
  DIRECTORY is dropped too, so its children are never listed (the per-entry filter
  naturally respects "an ignored dir hides everything under it").

Tests: +12 gitignore matcher cases (each pattern family + negation + layering +
escaping) and +1 mention integration case (real .gitignore + .relaviumignore
filter a listing, negation survives). 27 mention/gitignore tests pass;
typecheck/eslint/prettier green. Marked the deferred entry done.

Refs: docs/roadmap/phase-2.5-close-plan.md (Step 15, Batch E); ADR-0061

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e 2.5 complete

Reconcile every status surface now that the 2.5 close-out has landed:

- 2.5.I (regression harness + DB concurrency hardening) and 2.5.J (docs-debt +
  NO_COLOR/FORCE_COLOR/--color) marked ✅ Done; milestone M2.5-4 reached; Phase 2.5
  declared complete — in the phase-2.5 doc, current.md, and CLAUDE.md.
- Recorded the doable-now Batch A–E backlog that landed alongside 2.5.I/J (each
  implement → Opus → Sonnet, a security-review pass on the ADR-0057 approval batch):
  A test-hardening, B 2.5.H TUI polish, C AgentParseError line/col, D the approval/
  security batch (behind the append-only ADR-0057 amendment note), E the profile-aware
  advertise-filter + the in-house .gitignore matcher.
- Recorded the DEFERRED follow-ups in deferred-tasks.md (maintainer call): the two
  gate.ts-resume items (`relavium budget resume` + secret re-provide — both refactor
  the security-sensitive cross-process resume path, best landed together with fresh
  context), the session {{ctx.*}} interpolation (Proposed ADR-0060, Phase-2.6), and
  extraRoots (blocked on its config source). Marked the four now-landed 2.5.E backlog
  entries + the bidi-strip + gitignore entries done.

This gives the close-out a durable phase-doc record (the per-commit `Refs:` pointed
at the now-deleted scratch close-plan).

Full acceptance gate green: `pnpm turbo run lint typecheck test build` (24/24 tasks,
1641 CLI tests), `format:check`, `lint:fence-check`, `lint:engine-deps` all pass.

Refs: docs/roadmap/phases/phase-2.5-cli-consolidation.md (2.5.I/2.5.J, M2.5-4)

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

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b3d66467-1cc2-4e18-8128-29ccf3176d69

📥 Commits

Reviewing files that changed from the base of the PR and between 3dffc8c and e08b8d0.

📒 Files selected for processing (14)
  • README.md
  • apps/cli/src/harness/concurrency.e2e.test.ts
  • apps/cli/src/harness/perf-budget.e2e.test.ts
  • apps/cli/src/render/tui/chat-ink.tsx
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/render/tui/gitignore.test.ts
  • apps/cli/src/render/tui/gitignore.ts
  • apps/cli/src/render/tui/home-controller.ts
  • docs/roadmap/current.md
  • docs/roadmap/deferred-tasks.md
  • docs/standards/security-review.md
  • docs/uvp.md
  • packages/db/src/media-reference-store.ts
  • packages/db/src/model-catalog-store.ts
✅ Files skipped from review due to trivial changes (4)
  • docs/uvp.md
  • README.md
  • docs/roadmap/deferred-tasks.md
  • docs/roadmap/current.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • apps/cli/src/render/tui/gitignore.test.ts
  • packages/db/src/media-reference-store.ts
  • packages/db/src/model-catalog-store.ts
  • apps/cli/src/render/tui/chat-ink.tsx
  • docs/standards/security-review.md
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/render/tui/home-controller.ts

📝 Walkthrough

Walkthrough

This PR closes Phase 2.5 with concurrency hardening, approval-flow changes, parser and TUI updates, read-only tool filtering, and broad documentation refreshes.

Changes

SQLite Concurrency Hardening

Layer / File(s) Summary
Retry helper and tests
packages/db/src/retry.ts, packages/db/src/retry.test.ts
Adds bounded retrying for SQLite lock errors with deterministic backoff and contention tests.
Store write transactions
packages/db/src/media-reference-store.*, model-catalog-store.*, provider-store.*, run-history-store.*
Wraps write paths in immediate transactions guarded by retrying, with tests asserting transaction mode.
Session snapshot reads
packages/db/src/session-store.ts, session-store.test.ts
Makes loadFull read session and messages in one transaction and adds snapshot-isolation tests.
Concurrency harness and CI
apps/cli/src/harness/*, .github/workflows/ci.yml, packages/db/src/{client,media-store,media-write}.test.ts
Adds concurrency, session-chain, and query-plan e2e suites, a child writer fixture, an advisory Windows job, and retry-hardened test cleanup.
Concurrency docs
docs/reference/desktop/database-schema.md, docs/decisions/0064-live-model-catalog.md, docs/reference/cli/regression-harness.md
Documents transaction and retry conventions plus the new harness suites.

Estimated code review effort: 4 (Complex) | ~75 minutes

Approval Flow and Recoverable Denials

Layer / File(s) Summary
Non-interactive prompt helper
apps/cli/src/chat/chat-mode.ts, chat.ts, agent-run.ts, chat-mode-host.ts, chat-store.ts
Adds a shared fail-closed non-interactive approval prompt and wires it into chat and agent-run paths.
Typed reject-with-reason UI
apps/cli/src/render/tui/chat-input.ts, chat-ink.tsx, home-controller.ts, home-app.tsx, chat-projection.ts
Adds typed denial-reason capture, threads reason state through the Home TUI, and sanitizes submitted reasons.
Recoverable denial taxonomy
apps/cli/src/engine/tool-host/{errors,fs}.ts, packages/core/src/tools/errors.ts, packages/core/src/engine/agent-turn.ts
Adds recoverable flags to tool-dispatch errors and marks media scope and filesystem escape denials recoverable where appropriate.
Approval docs
docs/decisions/0057-...md, docs/reference/shared-core/{agent-runner,tool-registry}.md, docs/standards/security-review.md
Documents the approval flow and recoverable denial behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Parser, Color, Mention, and Render Updates

Layer / File(s) Summary
Parser diagnostics
packages/core/src/agent-parser.ts, agent-parser.test.ts, apps/cli/src/chat/agent-source.ts, apps/cli/src/workflows/catalog.test.ts
Adds line/column YAML syntax diagnostics and secret-free messaging for agent parsing.
Color precedence
apps/cli/src/process/options.ts, options.test.ts, program.ts, run.ts, docs/reference/cli/commands.md
Implements --color precedence with NO_COLOR/FORCE_COLOR, plus matching help text and docs.
Ignore matcher
apps/cli/src/render/tui/gitignore.ts, gitignore.test.ts
Implements gitignore-style parsing and matching with dedicated tests.
Mention filtering
apps/cli/src/render/tui/mention.ts, mention.test.ts
Wires compiled ignore rules into mention listing and adds an integration test.
Terminal rendering hardening
apps/cli/src/render/tui/chat-projection.ts, chat-projection.test.ts, chat-ink.tsx, apps/cli/src/commands/provider.ts
Strips bidi controls, bounds the reasoning panel by rendered rows, adds an abort hint, and updates related tests and TUI plumbing.

Estimated code review effort: 4 (Complex) | ~70 minutes

Read-Only Tool Filtering and Misc Tests

Layer / File(s) Summary
Read-only tool filtering
apps/cli/src/engine/tool-host/assemble.ts, assemble.test.ts
Makes advertised tool IDs respect a read-only host mode.
Misc tests
apps/cli/src/chat/session-host.test.ts, apps/cli/src/engine/media-gc.test.ts
Adds a git_status process-arm test and stabilizes the media-gc orphan sweep clock.

Estimated code review effort: 2 (Simple) | ~15 minutes

Documentation and Roadmap Updates

Layer / File(s) Summary
Encryption posture docs
docs/architecture/*, docs/decisions/*, docs/tech-stack.md, docs/tutorials/..., docs/uvp.md, docs/vision.md
Clarifies CLI vs desktop history.db encryption posture and related storage wording.
Roadmap status updates
CLAUDE.md, README.md, docs/roadmap/*
Marks Phase 2.5 lanes complete and updates related milestone, ADR, and deferred-task text.

Estimated code review effort: 2 (Simple) | ~15 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ChatInk
  participant HomeController
  participant Store
  User->>ChatInk: press c during pending approval
  ChatInk->>HomeController: reject-with-reason
  HomeController-->>ChatInk: open reasonDraft
  User->>ChatInk: Enter reason
  ChatInk->>HomeController: submit sanitized reason
  HomeController->>Store: reject approval
Loading
sequenceDiagram
  participant Writer1
  participant withBusyRetry
  participant SQLiteDB
  Writer1->>SQLiteDB: BEGIN IMMEDIATE
  withBusyRetry->>SQLiteDB: BEGIN IMMEDIATE
  SQLiteDB-->>withBusyRetry: SQLITE_BUSY
  withBusyRetry->>withBusyRetry: sleep(backoff)
  Writer1->>SQLiteDB: COMMIT
  withBusyRetry->>SQLiteDB: retry fn
Loading

Possibly related PRs

  • HodeTech/Relavium#17: Extends the ToolDispatchError/ToolPolicyError hierarchy with the recoverable flag consumed by the same engine recovery logic.
  • HodeTech/Relavium#19: Shares the same agent-turn.ts control-flow area and recovery handling around turn execution.
  • HodeTech/Relavium#63: Related to the CLI chat mode and per-tool approval flow extended here with shared non-interactive prompting and typed rejection.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the PR’s main change: Phase 2.5 CLI consolidation completion and close-out into main.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch development

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request completes Phase 2.5 (CLI Consolidation) by introducing database concurrency hardening with BEGIN IMMEDIATE and deterministic retries, adding robust E2E test suites (concurrency, session-chain, and query-shape performance budgets), implementing a custom .gitignore matcher for completions, and addressing security concerns like Trojan-Source bidi overrides. Feedback from the review highlights a bug in the newly added .gitignore directory-matching logic, where files inside ignored directories bypass the rules, and provides a detailed refactoring suggestion to resolve it. Additionally, the reviewer recommends using optional chaining on process.stdout to prevent potential runtime crashes in headless or test environments.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread apps/cli/src/render/tui/gitignore.ts Outdated
Comment on lines +52 to +76
function globToRegExp(glob: string, anchored: boolean): RegExp {
let src = '';
for (let i = 0; i < glob.length; i += 1) {
const ch = glob[i];
if (ch === '*') {
if (glob[i + 1] === '*') {
i += 1; // consumed the second '*'
if (glob[i + 1] === '/') {
i += 1; // consumed the '/'
src += '(?:.*/)?'; // `**/` — zero or more leading directories
} else {
src += '.*'; // `**` (crosses `/`)
}
} else {
src += '[^/]*'; // a single `*` stays within a segment
}
} else if (ch === '?') {
src += '[^/]';
} else if (ch !== undefined) {
src += escapeRe(ch);
}
}
const head = anchored ? '^' : '(?:^|/)';
return new RegExp(`${head}${src}(?:/|$)`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of globToRegExp always appends (?:/|$) to the compiled regular expression. However, for directory-only rules (e.g., node_modules/), this causes files directly under the ignored directory (like node_modules/a.txt) to bypass the ignore rule when checked individually with isDir = false because rule.dirOnly && !isDir evaluates to true and skips the rule.

To fix this, we should allow globToRegExp to accept a dirOnly parameter and append / instead of (?:/|$) when dirOnly is true. This ensures that directory-only rules only match if the matched segment is followed by a slash (which is always true for descendants of the directory, or for the directory itself if we append a trailing slash).

function globToRegExp(glob: string, anchored: boolean, dirOnly = false): RegExp {
  let src = '';
  for (let i = 0; i < glob.length; i += 1) {
    const ch = glob[i];
    if (ch === '*') {
      if (glob[i + 1] === '*') {
        i += 1; // consumed the second '*'
        if (glob[i + 1] === '/') {
          i += 1; // consumed the '/'
          src += '(?:.*/)?'; // **/ - zero or more leading directories
        } else {
          src += '.*'; // ** (crosses /)
        }
      } else {
        src += '[^/]*'; // a single * stays within a segment
      }
    } else if (ch === '?') {
      src += '[^/]';
    } else if (ch !== undefined) {
      src += escapeRe(ch);
    }
  }
  const head = anchored ? '^' : '(?:^|/)';
  const tail = dirOnly ? '/' : '(?:/|$)';
  return new RegExp(`${head}${src}${tail}`);
}

Comment on lines +79 to +89
function compileRule(raw: string): IgnoreRule | undefined {
const negate = raw.startsWith('!');
let pattern = negate ? raw.slice(1) : raw;
const dirOnly = pattern.endsWith('/');
if (dirOnly) pattern = pattern.slice(0, -1);
// Anchored if a `/` is at the start or the middle (a trailing `/` was already stripped above).
const anchored = pattern.startsWith('/') || pattern.includes('/');
if (pattern.startsWith('/')) pattern = pattern.slice(1);
if (pattern.length === 0) return undefined;
return { re: globToRegExp(pattern, anchored), negate, dirOnly };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass the dirOnly flag to globToRegExp so that directory-only rules are compiled with a trailing slash requirement.

Suggested change
function compileRule(raw: string): IgnoreRule | undefined {
const negate = raw.startsWith('!');
let pattern = negate ? raw.slice(1) : raw;
const dirOnly = pattern.endsWith('/');
if (dirOnly) pattern = pattern.slice(0, -1);
// Anchored if a `/` is at the start or the middle (a trailing `/` was already stripped above).
const anchored = pattern.startsWith('/') || pattern.includes('/');
if (pattern.startsWith('/')) pattern = pattern.slice(1);
if (pattern.length === 0) return undefined;
return { re: globToRegExp(pattern, anchored), negate, dirOnly };
}
function compileRule(raw: string): IgnoreRule | undefined {
const negate = raw.startsWith('!');
let pattern = negate ? raw.slice(1) : raw;
const dirOnly = pattern.endsWith('/');
if (dirOnly) pattern = pattern.slice(0, -1);
// Anchored if a / is at the start or the middle (a trailing / was already stripped above).
const anchored = pattern.startsWith('/') || pattern.includes('/');
if (pattern.startsWith('/')) pattern = pattern.slice(1);
if (pattern.length === 0) return undefined;
return { re: globToRegExp(pattern, anchored, dirOnly), negate, dirOnly };
}

Comment on lines +106 to +115
ignores(relPath, isDir) {
const path = relPath.replace(/^\.\//, '').replace(/\/+$/, ''); // normalize a leading `./` + a trailing `/`
if (path.length === 0) return false;
let ignored = false;
for (const rule of rules) {
if (rule.dirOnly && !isDir) continue; // a `foo/` rule never matches a file
if (rule.re.test(path)) ignored = !rule.negate; // last matching rule wins (negation re-includes)
}
return ignored;
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the ignores function to append a trailing slash to path when isDir is true, and remove the manual rule.dirOnly check. Since directory-only rules are now compiled with a trailing slash requirement, they will correctly match directory paths (which now end in /) and descendant paths (which naturally contain /), while correctly ignoring file paths (which do not end in /).

    ignores(relPath, isDir) {
      const path = relPath.replace(/^\.\//, '').replace(/\/+$/, '') + (isDir ? '/' : '');
      if (path.length === 0 || path === '/') return false;
      let ignored = false;
      for (const rule of rules) {
        if (rule.re.test(path)) ignored = !rule.negate;
      }
      return ignored;
    },

Comment thread apps/cli/src/render/tui/chat-ink.tsx Outdated
// `nowMs={Date.now()}`); the frame loop re-renders, so a resize is picked up on the next tick.
// `stdout.columns` is typed `number` but is `undefined` at runtime off a TTY — the formatter's 80-col
// fallback covers that (moot here anyway: ChatApp only mounts on a TTY via the driveInk gate).
columns={process.stdout.columns}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using process.stdout.columns directly can cause a runtime TypeError if process.stdout is undefined (which can happen in certain non-TTY, headless, or test environments where standard streams are redirected or mocked).

Using optional chaining (process.stdout?.columns) is safer and prevents potential crashes.

Suggested change
columns={process.stdout.columns}
columns={process.stdout?.columns}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/roadmap/current.md (1)

417-424: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the status note in one blockquote.

The empty line between the two quoted paragraphs trips markdownlint MD028, so the docs CI may fail. Remove the separator or quote it as > if you want a blank line.

Suggested fix
 > **Status:** ✅ **Done (PR `#66`, 2026-07-07)** — all **12** dependency-ordered steps (S1–S12) landed behind
 > [ADR-0063](../../decisions/0063-cli-config-write-contract.md)/[ADR-0064](../../decisions/0064-live-model-catalog.md)/[ADR-0065](../../decisions/0065-provider-economics-and-extensibility.md),
 > each with an Opus + Sonnet review round (six with an added security round: S2/S5/S6/S8/S9/S10); the post-2.5.G
 > model-UX follow-up ([ADR-0059](../../decisions/0059-cli-mid-session-model-reseat.md) mid-session reseat +
 > [ADR-0066](../../decisions/0066-normalized-reasoning-effort-control.md) reasoning-effort) landed in the same PR.
 > With 2.5.G done, **milestone M2.5-2** (Home + entry + onboarding) is reached.
->
 > **Scope expanded to Option A (2026-07-05).**
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/roadmap/current.md` around lines 417 - 424, The status note is split
into two blockquoted paragraphs with a blank line between them, which triggers
markdownlint MD028. In the current markdown section, keep the entire note in a
single blockquote by removing the empty separator or converting the blank line
to a quoted blank line, and verify the quoted text remains contiguous in the
same blockquote block.

Source: Linters/SAST tools

🧹 Nitpick comments (7)
docs/roadmap/deferred-tasks.md (1)

521-527: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Malformed link syntax: {@link gitignore.ts} won't render in Markdown.

{@link ...} is a JSDoc/TSDoc tag, not Markdown syntax — in a .md file it will render as literal text {@link gitignore.ts} rather than a link. As per coding guidelines, docs/**/*.md should use "relative links" for cross-references.

📝 Proposed fix
-  dependency-free, ReDoS-safe in-house matcher ({`@link` gitignore.ts}) folds the workspace-root `.gitignore` +
+  dependency-free, ReDoS-safe in-house matcher ([`gitignore.ts`](../../apps/cli/src/render/tui/gitignore.ts)) folds the workspace-root `.gitignore` +
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/roadmap/deferred-tasks.md` around lines 521 - 527, The roadmap entry
uses JSDoc-style `{`@link` gitignore.ts}` inside a Markdown file, so it will not
render as a link. Update the affected note in the deferred-tasks section to use
a proper relative Markdown link for the `gitignore.ts` reference, keeping the
rest of the wording intact. Use the existing `gitignore.ts` mention in that
bullet as the anchor and ensure the cross-reference follows the docs/*.md
relative-link convention.

Source: Coding guidelines

apps/cli/src/render/tui/chat-projection.ts (1)

85-90: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

charCodeAt here is correct — SonarCloud's codePointAt suggestion is a false positive.

Detecting a lone leading surrogate at the cap boundary requires inspecting the UTF-16 code unit; codePointAt would return the combined code point (or the low half) and defeat the check. Consider a // NOSONAR / inline disable to silence the analyzer rather than changing the logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/cli/src/render/tui/chat-projection.ts` around lines 85 - 90, The UTF-16
boundary check in chat-projection’s truncation logic should stay based on
charCodeAt, not codePointAt, because it must detect a lone leading surrogate at
the MAX_APPROVAL_REASON_CHARS cap. Update the surrounding comment or add a
targeted NOSONAR/inline suppression near the truncation logic in chatProjection
so SonarCloud ignores this false positive without changing the behavior of the
slice/end calculation.

Source: Linters/SAST tools

apps/cli/src/render/tui/home-controller.ts (1)

1327-1355: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the reason-capture routing to clear the failing complexity gate.

SonarCloud fails this function at cognitive complexity 19 (limit 15). The reasonDraft capture block is self-contained and follows the same shape as routeMentionKey/routeSearchKey — extracting it into a routeReasonKey(...) : boolean helper drops handleChatKey's complexity and keeps the router flat.

♻️ Extract routeReasonKey
+  // The open `[c]` typed-reason capture owns every key (Step 14) — parity with routeMentionKey/routeSearchKey.
+  // Returns whether the key was consumed (the capture was open).
+  const routeReasonKey = (active: HomeChatSession, input: string, key: ChatKey): boolean => {
+    const openReason = state.reasonDraft;
+    if (openReason === undefined) return false;
+    if (active.store.getSnapshot().approval === undefined) {
+      set({ reasonDraft: undefined }); // the approval vanished — discard the orphaned capture
+      return true;
+    }
+    if (key.escape === true) {
+      set({ reasonDraft: undefined }); // cancel the reason; the approval stays pending
+      return true;
+    }
+    if (key.return === true && key.shift !== true) {
+      const reason = sanitizeApprovalReason(openReason.text);
+      set({ reasonDraft: undefined });
+      active.store.answerApproval(
+        reason === undefined ? { outcome: 'reject' } : { outcome: 'reject', reason },
+      );
+      return true;
+    }
+    const edit = reduceEditorMotion(input, key);
+    if (edit !== undefined) set({ reasonDraft: applyEditorAction(openReason, edit) });
+    return true;
+  };
+
   const handleChatKey = (active: HomeChatSession, input: string, key: ChatKey): void => {
     if (tearingDown === active) return; // a key arriving mid-teardown must not drive sendMessage on a cancelled session
-    // The `[c]` typed-reason capture (Step 14) owns the keyboard while open — checked FIRST. ...
-    const openReason = state.reasonDraft;
-    if (openReason !== undefined) {
-      if (active.store.getSnapshot().approval === undefined) {
-        set({ reasonDraft: undefined });
-        return;
-      }
-      if (key.escape === true) {
-        set({ reasonDraft: undefined });
-        return;
-      }
-      if (key.return === true && key.shift !== true) {
-        const reason = sanitizeApprovalReason(openReason.text);
-        set({ reasonDraft: undefined });
-        active.store.answerApproval(
-          reason === undefined ? { outcome: 'reject' } : { outcome: 'reject', reason },
-        );
-        return;
-      }
-      const edit = reduceEditorMotion(input, key);
-      if (edit !== undefined) set({ reasonDraft: applyEditorAction(openReason, edit) });
-      return;
-    }
+    // The `[c]` typed-reason capture (Step 14) owns the keyboard while open — checked FIRST.
+    if (routeReasonKey(active, input, key)) return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/cli/src/render/tui/home-controller.ts` around lines 1327 - 1355, The
`handleChatKey` function is over the cognitive complexity limit because the
`reasonDraft` capture logic is embedded inline. Extract that self-contained
branch into a new `routeReasonKey(...) : boolean` helper, following the same
routing pattern used by `routeMentionKey` and `routeSearchKey`, and have
`handleChatKey` delegate to it before continuing with the remaining key
handling. Keep the helper responsible for the full reason-capture flow,
including stale approval checks, Escape cancel, Enter-to-reject, and editor
updates, so the main router stays flat.

Source: Linters/SAST tools

apps/cli/src/harness/perf-budget.e2e.test.ts (1)

36-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Unsafe as casts on the DB boundary.

params as ReadonlyArray<...> and row as { detail?: string } are unsafe as casts, which the project's TS guideline for .ts files disallows in favor of type guards. Since this only touches test-harness code reading better-sqlite3's unknown/any-typed results, the risk is low, but a small runtime guard would keep this file compliant without much effort.

As per coding guidelines, **/*.{ts,tsx}: "TypeScript-first, strict. No any, no unsafe as."

♻️ Proposed type-guard alternative
+  function isBindValue(v: unknown): v is string | number | bigint | Buffer | null {
+    return (
+      v === null ||
+      typeof v === 'string' ||
+      typeof v === 'number' ||
+      typeof v === 'bigint' ||
+      Buffer.isBuffer(v)
+    );
+  }
+
   function planFor(query: { toSQL: () => { sql: string; params: unknown[] } }): string[] {
     const { sql, params } = query.toSQL();
-    const bind = params as ReadonlyArray<string | number | bigint | Buffer | null>;
+    if (!params.every(isBindValue)) {
+      throw new Error('unexpected non-primitive bind param from drizzle toSQL()');
+    }
     const rows = client.sqlite.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...bind);
-    return rows.map((row) => (row as { detail?: string }).detail ?? '');
+    return rows.map((row) =>
+      typeof row === 'object' && row !== null && 'detail' in row && typeof row.detail === 'string'
+        ? row.detail
+        : '',
+    );
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/cli/src/harness/perf-budget.e2e.test.ts` around lines 36 - 44, The
`planFor` helper in `perf-budget.e2e.test.ts` uses unsafe `as` casts at the
SQLite boundary for both `params` and `row.detail`, which violates the
TypeScript strictness guideline. Replace those casts with small runtime type
guards around the `toSQL()` params and the `EXPLAIN QUERY PLAN` rows, then only
pass validated bind values into `client.sqlite.prepare(...).all(...)` and safely
read `detail` from rows in the map step.

Source: Coding guidelines

docs/standards/security-review.md (1)

350-361: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restates the canonical recoverable-denial taxonomy instead of linking to it.

This paragraph re-enumerates the same recoverable/fatal denial taxonomy already canonically documented in docs/reference/shared-core/tool-registry.md (§Error taxonomy, the "recoverable" subsection). As per coding guidelines, docs/**/*.md: "Specs live in [docs/reference/]; link, don't restate. One canonical home per artifact."

Consider trimming this to a short security summary that links to the tool-registry.md taxonomy for the authoritative enumeration, so future taxonomy changes only need updating in one place.

✏️ Example trim
-- **A recoverable SCOPE denial is safe by construction.** Only two `tool_denied`s are fed back to
-  the model for in-turn conversational recovery on the `recoverToolFailures` surfaces (the
-  chat-read-write host — `relavium chat` / the Home / the one-shot `agent run`; a WORKFLOW run stays
-  fatal/deterministic): a media scope denial and the fs **pure scope-tier escape** — both refused
-  BEFORE any side effect, with a **secret-free, path-free** reason, so the model adapts to an
-  in-bounds path while the floor still denies every attempt (no bypass, bounded by
-  `maxToolCorrections`). The scope-membership signal a probing model can gather over that bounded
-  window is an accepted, secret-free workspace-boundary oracle (the same round-bounded feedback
-  already existed for an idempotent not-found read). The **confidentiality** (secret-store read),
-  protected-path, symlink/hard-link, egress-SSRF, and user/guardrail denials stay FATAL — feeding
-  those back would re-deny, risk a re-execution, or leak a probe oracle.
+- **A recoverable SCOPE denial is safe by construction.** Only two `tool_denied` reasons are fed
+  back to the model for in-turn conversational recovery (a WORKFLOW run stays fatal/deterministic).
+  See [tool-registry.md §Error taxonomy](../reference/shared-core/tool-registry.md#error-taxonomy)
+  for the authoritative list of recoverable vs. fatal denial reasons and the safety rationale.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/standards/security-review.md` around lines 350 - 361, The
security-review paragraph is restating the recoverable/fatal denial taxonomy
instead of pointing to the canonical source. Trim this section in
docs/standards/security-review.md to a brief summary and add a link to the
authoritative taxonomy in docs/reference/shared-core/tool-registry.md, keeping
the detailed enumeration only in that canonical reference. Use the surrounding
security policy text to preserve the high-level guidance while removing
duplicated denial categories.

Source: Coding guidelines

packages/db/src/model-catalog-store.ts (1)

491-589: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the transaction callback to clear the SonarCloud nesting failure.

The withBusyRetry(() => db.transaction(() => { … })) wrapper pushed the rows.map/branch bodies past the 4-level nesting threshold, tripping the failing SonarCloud check at Line 565. Hoisting the transaction body into a named local removes one level without changing behavior; apply the same shape to upsert for consistency.

♻️ Sketch: hoist the transaction body
-    replaceProviderModels: (providerId, rows, now) =>
-      withBusyRetry(() =>
-        db.transaction(
-          () => {
-            let added = 0;
-            let updated = 0;
-            // …
-            return { added, updated, deactivated: deactivateResult.changes };
-          },
-          { behavior: 'immediate' },
-        ),
-      ),
+    replaceProviderModels: (providerId, rows, now) => {
+      const applyReplace = (): ReplaceResult => {
+        let added = 0;
+        let updated = 0;
+        // …
+        return { added, updated, deactivated: deactivateResult.changes };
+      };
+      return withBusyRetry(() => db.transaction(applyReplace, { behavior: 'immediate' }));
+    },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/model-catalog-store.ts` around lines 491 - 589, The nested
inline callback in withBusyRetry(db.transaction(...)) is pushing the
model-catalog refresh logic past SonarCloud’s nesting limit. Hoist the
transaction body in model-catalog-store into a named local function, then pass
that function to db.transaction and keep withBusyRetry wrapping it; preserve the
existing add/update/deactivate logic and apply the same refactor shape to upsert
for consistency.

Source: Linters/SAST tools

packages/db/src/media-reference-store.ts (1)

171-202: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nesting depth flagged by SonarCloud (Lines 189, 244).

removeRunReferences and reclaimExpired each nest withBusyRetry(() => db.transaction(() => { ... for (...) {...} })) four function levels deep. Functionally fine and well-commented, but extracting each transaction body into a small named helper (e.g. runRemoveRunReferencesTxn(db, runId, ts)) would flatten this and make each piece independently testable/readable.

Also applies to: 223-262

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/media-reference-store.ts` around lines 171 - 202, The nested
transaction logic in removeRunReferences is too deep and should be flattened for
readability and testability. Extract the db.transaction body into a small named
helper such as runRemoveRunReferencesTxn(db, runId, ts) (and similarly for
reclaimExpired where applicable), then have removeRunReferences call that helper
inside withBusyRetry so the control flow is clearer while preserving the
existing behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/cli/src/harness/concurrency.e2e.test.ts`:
- Around line 94-119: The runChild helper in the concurrency e2e test can leave
the ready promise unresolved when spawn fails, causing Promise.all waits to hang
instead of surfacing the error. Update runChild so the child.on('error') path
also calls signalReady() before rejecting, and keep the existing close fallback
in place; use the runChild, ready, done, and signalReady symbols to make the fix
in the same helper.

In `@apps/cli/src/render/tui/gitignore.ts`:
- Around line 42-76: The globToRegExp helper in gitignore.ts is turning
workspace ignore globs into potentially backtracking-heavy regexes, which can
stall ignores() during mention completion on crafted patterns. Update
globToRegExp and its callers to avoid using greedy regex matching for untrusted
patterns, either by enforcing strict length/complexity limits before compiling
or by replacing the regex approach with a linear-time glob matcher that handles
the same anchored and segment-boundary behavior.

In `@docs/uvp.md`:
- Around line 22-23: The wording in the UVP copy overstates the security
guarantee by saying API keys are “never at rest,” which is too absolute for the
OS keychain. Update the sentence in the docs text to explicitly say the keys are
stored in the OS keychain and do not reach history.db or plaintext storage,
keeping the rest of the surrounding copy unchanged.

In `@README.md`:
- Around line 135-139: Update the README status text to match the current
roadmap: the summary under the Phase 2.5 section should no longer say only the
regression-harness/concurrency and documentation-consolidation lanes remain,
since docs/roadmap/current.md now marks 2.5.I and 2.5.J complete and Phase 2.5
finished. Adjust the wording in the README paragraph so it reflects that Phase
2.5 is complete while preserving the rest of the release/status messaging.

---

Outside diff comments:
In `@docs/roadmap/current.md`:
- Around line 417-424: The status note is split into two blockquoted paragraphs
with a blank line between them, which triggers markdownlint MD028. In the
current markdown section, keep the entire note in a single blockquote by
removing the empty separator or converting the blank line to a quoted blank
line, and verify the quoted text remains contiguous in the same blockquote
block.

---

Nitpick comments:
In `@apps/cli/src/harness/perf-budget.e2e.test.ts`:
- Around line 36-44: The `planFor` helper in `perf-budget.e2e.test.ts` uses
unsafe `as` casts at the SQLite boundary for both `params` and `row.detail`,
which violates the TypeScript strictness guideline. Replace those casts with
small runtime type guards around the `toSQL()` params and the `EXPLAIN QUERY
PLAN` rows, then only pass validated bind values into
`client.sqlite.prepare(...).all(...)` and safely read `detail` from rows in the
map step.

In `@apps/cli/src/render/tui/chat-projection.ts`:
- Around line 85-90: The UTF-16 boundary check in chat-projection’s truncation
logic should stay based on charCodeAt, not codePointAt, because it must detect a
lone leading surrogate at the MAX_APPROVAL_REASON_CHARS cap. Update the
surrounding comment or add a targeted NOSONAR/inline suppression near the
truncation logic in chatProjection so SonarCloud ignores this false positive
without changing the behavior of the slice/end calculation.

In `@apps/cli/src/render/tui/home-controller.ts`:
- Around line 1327-1355: The `handleChatKey` function is over the cognitive
complexity limit because the `reasonDraft` capture logic is embedded inline.
Extract that self-contained branch into a new `routeReasonKey(...) : boolean`
helper, following the same routing pattern used by `routeMentionKey` and
`routeSearchKey`, and have `handleChatKey` delegate to it before continuing with
the remaining key handling. Keep the helper responsible for the full
reason-capture flow, including stale approval checks, Escape cancel,
Enter-to-reject, and editor updates, so the main router stays flat.

In `@docs/roadmap/deferred-tasks.md`:
- Around line 521-527: The roadmap entry uses JSDoc-style `{`@link` gitignore.ts}`
inside a Markdown file, so it will not render as a link. Update the affected
note in the deferred-tasks section to use a proper relative Markdown link for
the `gitignore.ts` reference, keeping the rest of the wording intact. Use the
existing `gitignore.ts` mention in that bullet as the anchor and ensure the
cross-reference follows the docs/*.md relative-link convention.

In `@docs/standards/security-review.md`:
- Around line 350-361: The security-review paragraph is restating the
recoverable/fatal denial taxonomy instead of pointing to the canonical source.
Trim this section in docs/standards/security-review.md to a brief summary and
add a link to the authoritative taxonomy in
docs/reference/shared-core/tool-registry.md, keeping the detailed enumeration
only in that canonical reference. Use the surrounding security policy text to
preserve the high-level guidance while removing duplicated denial categories.

In `@packages/db/src/media-reference-store.ts`:
- Around line 171-202: The nested transaction logic in removeRunReferences is
too deep and should be flattened for readability and testability. Extract the
db.transaction body into a small named helper such as
runRemoveRunReferencesTxn(db, runId, ts) (and similarly for reclaimExpired where
applicable), then have removeRunReferences call that helper inside withBusyRetry
so the control flow is clearer while preserving the existing behavior.

In `@packages/db/src/model-catalog-store.ts`:
- Around line 491-589: The nested inline callback in
withBusyRetry(db.transaction(...)) is pushing the model-catalog refresh logic
past SonarCloud’s nesting limit. Hoist the transaction body in
model-catalog-store into a named local function, then pass that function to
db.transaction and keep withBusyRetry wrapping it; preserve the existing
add/update/deactivate logic and apply the same refactor shape to upsert for
consistency.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e7da5fc9-e826-48b5-861d-37d5ced7be73

📥 Commits

Reviewing files that changed from the base of the PR and between 84fb7a4 and 3dffc8c.

📒 Files selected for processing (81)
  • .github/workflows/ci.yml
  • CLAUDE.md
  • README.md
  • apps/cli/src/chat/agent-source.ts
  • apps/cli/src/chat/chat-mode-host.ts
  • apps/cli/src/chat/chat-mode.test.ts
  • apps/cli/src/chat/chat-mode.ts
  • apps/cli/src/chat/session-host.test.ts
  • apps/cli/src/commands/agent-run.ts
  • apps/cli/src/commands/chat.ts
  • apps/cli/src/commands/provider.ts
  • apps/cli/src/engine/media-gc.test.ts
  • apps/cli/src/engine/tool-host/assemble.test.ts
  • apps/cli/src/engine/tool-host/assemble.ts
  • apps/cli/src/engine/tool-host/errors.ts
  • apps/cli/src/engine/tool-host/fs.test.ts
  • apps/cli/src/engine/tool-host/fs.ts
  • apps/cli/src/harness/concurrency.e2e.test.ts
  • apps/cli/src/harness/fixtures/concurrent-writer.mjs
  • apps/cli/src/harness/perf-budget.e2e.test.ts
  • apps/cli/src/harness/session-chain.e2e.test.ts
  • apps/cli/src/process/options.test.ts
  • apps/cli/src/process/options.ts
  • apps/cli/src/program.ts
  • apps/cli/src/render/tui/chat-ink.tsx
  • apps/cli/src/render/tui/chat-input.test.ts
  • apps/cli/src/render/tui/chat-input.ts
  • apps/cli/src/render/tui/chat-projection.test.ts
  • apps/cli/src/render/tui/chat-projection.ts
  • apps/cli/src/render/tui/chat-store.ts
  • apps/cli/src/render/tui/gitignore.test.ts
  • apps/cli/src/render/tui/gitignore.ts
  • apps/cli/src/render/tui/home-app.tsx
  • apps/cli/src/render/tui/home-controller.test.ts
  • apps/cli/src/render/tui/home-controller.ts
  • apps/cli/src/render/tui/mention.test.ts
  • apps/cli/src/render/tui/mention.ts
  • apps/cli/src/run.ts
  • apps/cli/src/workflows/catalog.test.ts
  • docs/architecture/agent-sessions.md
  • docs/architecture/local-first-and-security.md
  • docs/decisions/0029-tool-policy-hardening.md
  • docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md
  • docs/decisions/0064-live-model-catalog.md
  • docs/decisions/README.md
  • docs/reference/cli/commands.md
  • docs/reference/cli/regression-harness.md
  • docs/reference/desktop/database-schema.md
  • docs/reference/desktop/tauri-plugins.md
  • docs/reference/shared-core/agent-runner.md
  • docs/reference/shared-core/tool-registry.md
  • docs/roadmap/current.md
  • docs/roadmap/deferred-tasks.md
  • docs/roadmap/phases/phase-2.5-cli-consolidation.md
  • docs/roadmap/phases/phase-2.6-conversational-authoring.md
  • docs/standards/security-review.md
  • docs/tech-stack.md
  • docs/tutorials/cli/start-a-chat-session.md
  • docs/uvp.md
  • docs/vision.md
  • packages/core/src/agent-parser.test.ts
  • packages/core/src/agent-parser.ts
  • packages/core/src/engine/agent-turn.test.ts
  • packages/core/src/engine/agent-turn.ts
  • packages/core/src/tools/builtins.test.ts
  • packages/core/src/tools/errors.ts
  • packages/db/src/client.test.ts
  • packages/db/src/media-reference-store.test.ts
  • packages/db/src/media-reference-store.ts
  • packages/db/src/media-store.test.ts
  • packages/db/src/media-write.test.ts
  • packages/db/src/model-catalog-store.test.ts
  • packages/db/src/model-catalog-store.ts
  • packages/db/src/provider-store.test.ts
  • packages/db/src/provider-store.ts
  • packages/db/src/retry.test.ts
  • packages/db/src/retry.ts
  • packages/db/src/run-history-store.test.ts
  • packages/db/src/run-history-store.ts
  • packages/db/src/session-store.test.ts
  • packages/db/src/session-store.ts

Comment thread apps/cli/src/harness/concurrency.e2e.test.ts
Comment thread apps/cli/src/render/tui/gitignore.ts Outdated
Comment thread docs/uvp.md Outdated
Comment thread README.md Outdated
…g, docs, Sonar refactors

Address the PR #69 review findings (verified each against current code; fixed the
still-valid ones, kept changes minimal):

Correctness / security:
- gitignore.ts: replace the glob→regex compiler with a LINEAR two-pointer glob
  matcher — closes THREE findings at once: (1) the ReDoS on untrusted `.gitignore`
  patterns (a crafted `**`-chain could backtrack super-linearly through the
  compiled regex; the matcher has no regex, so it is bounded/linear), (2) the
  directory-only bug (a `cache/` rule now correctly ignores FILES under it —
  `cache/a.txt` — not just the dir itself), and (3) the two SonarCloud
  super-linear-regex flags (no regex left). +2 tests (descendant match, and a
  64-`**` × 200-segment pattern returns instantly).
- concurrency.e2e.test.ts: the `runChild` `error` handler now calls `signalReady()`
  before rejecting, so a spawn failure can't leave `await child.ready` hanging.
- chat-ink.tsx: `process.stdout?.columns` — optional-chain `stdout`, which can be
  undefined in a headless/redirected-stream harness (`.columns` on it would throw).

Docs accuracy:
- uvp.md: "API keys … never at rest" → "in the OS keychain — never in history.db
  or any plaintext store" (the keychain IS at-rest storage; the claim was too absolute).
- README.md: Phase 2.5 status → complete (M2.5-4), not "only 2.5.I/J remain".
- current.md: fix the MD028 (merge the Status/Last-updated header blockquotes);
  bump the date.
- deferred-tasks.md: the `{@link gitignore.ts}` won't render in Markdown → a proper
  relative link.
- security-review.md: trim the restated denial taxonomy to a summary + link the
  canonical tool-registry.md §error-taxonomy (one home).

Strict-TS / Sonar (maintainability):
- perf-budget.e2e.test.ts: replace two `as` casts at the SQLite boundary with
  runtime guards (`toBindValue` + `detailOf` via `in`-narrowing).
- chat-projection.ts: `// NOSONAR` + rationale on the intentional `charCodeAt`
  (codePointAt would combine a surrogate pair, defeating the lone-surrogate check).
- home-controller.ts: extract `routeReasonKey` (cognitive complexity 19 → under 15).
- media-reference-store.ts + model-catalog-store.ts: hoist the `db.transaction`
  bodies into named factory-scoped helpers (function-nesting under the limit).

Skipped: the phase-2.5-doc MD028 (pre-existing in the 2.5.G section, not this diff);
model-catalog `upsert` "for consistency" (not Sonar-flagged — kept minimal).

Gate: `pnpm turbo run lint typecheck test build` (24/24), format:check, fence,
engine-deps — all green.

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

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

@cemililik
cemililik merged commit 7fac102 into main Jul 8, 2026
11 checks passed
cemililik added a commit that referenced this pull request Jul 8, 2026
Post-merge traceability: the 2.5.I/2.5.J + Batch A–E close-out landed via PR #69
(development → main, merged 2026-07-08). Add the durable PR reference to every
status surface (phase-2.5 doc, current.md, CLAUDE.md, README) — the per-commit
`Refs:` had pointed at the now-deleted scratch close-plan, so the PR number is
the durable pointer — and note Phase 2.5 is merged to `main`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cemililik added a commit that referenced this pull request Jul 8, 2026
… + PR #69 refs

Rigorous audit of deferred-tasks.md against what the 2.5 close-out actually
shipped — mark done ONLY what is genuinely in the merged code (verified each
against the source), and leave the intentionally-deferred items open:

Newly marked [x] DONE (were still [ ] — done this round but missed):
- Profile-unaware advertise-filter → `wiredToolIds({ readOnly })` (Step 15, E).
- Compact abort hint during streaming → `streamingAbortHint` (Step 12, B).
- Bound the EXPANDED reasoning panel by rendered LINES → `MAX_REASONING_PANEL_LINES`
  row-tail (Step 12, B).
- Allow Ctrl+T / /thinking during a pending approval → the whitelist in
  `reduceApprovalKey` (Step 14, D-4).
- Two transitively-covered test gaps → the git_status union pin was ADDED
  (session-host.test.ts) and the failed-turn persister fold was CONFIRMED already
  pinned (persister.test.ts) (Step 11, A).

Also added the durable `PR #69` merge reference to the six earlier close-out
entries, so all eleven closed items consistently cite the merge.

Left [ ] on purpose (NOT closed by this round — verified): the two gate.ts-resume
follow-ups (budget resume + secret re-provide), the session {{ctx.*}} interpolation
(Proposed ADR-0060), extraRoots (blocked on its config source), the two Step-14
review follow-ups (approval-consent zero-width; extract the [c] reducer), the
AgentParseError chat-surface diagnostic gap, and the cross-process gate-resolve
TOCTOU (needs a store-level uniqueness constraint — BEGIN IMMEDIATE does not close it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cemililik added a commit that referenced this pull request Jul 8, 2026
All ten workstreams 2.5.A–J shipped (milestone M2.5-4, merged to `main` via
PR #69); the go/no-go exit criteria hold and Phase 2.6 is unblocked. Reconcile
the phase-status surfaces that still framed 2.5 as in-progress:

- phase-2.5-cli-consolidation.md: banner "In progress" → "✅ COMPLETE"; mark the
  go/no-go exit criteria "all five met" at phase close.
- current.md: retire the "active phase = Phase 2.5" pointer (2.5 is historical);
  name Phase 2.6 (Conversational Authoring, Planned/unblocked) as the next phase
  in both the intro and the "Not started yet" section.
- phase-2.6-conversational-authoring.md: note the 2.5 dependency is satisfied —
  status "Planned — next up", now unblocked.
- CLAUDE.md: the Phase-2.5 status paragraph lead "is now underway" → "✅ complete"
  (its tail already read complete), resolving the lead/tail contradiction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant